From 284dbaf5370d9c2cc40b136c3b17d33ea6b894f0 Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 11 Sep 2026 12:23:51 -0400 Subject: [PATCH] fix(pm): isolate bootstrap dependencies and unify YAML on ruamel Activation reaches plugin discovery before the application dependencies exist. Give PM its own locked Python project and runtime so it can install or repair the application without importing that dependency tree. Keep PM outside the application workspace. A shared uv workspace resolves the application graph and cannot provide this isolation. Route mutations through an isolated worker and preserve transaction callbacks, cancellation, custom package registrations, and correlated receipts. Use the same runtime builder for source installs and packaged payloads. Keep offline wheelhouse support in that builder. Nix builds the independent PM lock as a separate derivation. Refuse lazy-disabled bootstrap before installing tools or dependencies. Move first-party YAML readers and writers to ruamel. Keep the application lock's transitive PyYAML requirements for third-party packages. Verification: - Focused canonical Python suite: 177 passed, 1 host-gated skip. - Electron backend probes: 12 passed. Electron typecheck passed. - Both uv locks, scoped lint, Bash syntax, and whitespace checks passed. - Cold activation, corrupt-app repair, offline staging, and relocation ran. - Built and exercised the Nix PM runtime and standalone YAML merge script. Six broader caller test files retain the same 24 failing test IDs as an archive of HEAD. The existing real-home guard blocks those tests before they can exercise the affected paths. No full-suite pass is claimed. Native Windows signing and full Bionic package execution remain unverified. --- .github/workflows/canary-release.yml | 2 +- .github/workflows/deploy-site.yml | 4 +- .github/workflows/desktop-bundled-release.yml | 6 +- .github/workflows/docs-site-checks.yml | 2 +- .github/workflows/plugin-catalog-ci.yml | 6 +- .github/workflows/pm-toolchain.yml | 4 +- .github/workflows/skills-index.yml | 2 +- Dockerfile | 8 +- agent/curator.py | 2 +- agent/i18n.py | 2 +- agent/model_metadata.py | 2 +- agent/onboarding.py | 4 +- agent/proxy_sources/iron_proxy.py | 8 +- agent/skill_bundles.py | 2 +- agent/skill_utils.py | 7 +- .../electron/backend-probes-runtime.test.ts | 20 +- apps/desktop/electron/backend-probes.ts | 4 +- evals/browser_use/single_run.py | 3 +- evals/desktop_bug_campaign/hooks_live.py | 3 +- .../linux_launcher_probe.py | 4 +- .../desktop_bug_campaign/persistence_live.py | 3 +- .../session_time_persistence_controls.py | 2 +- .../live_ab/nested_delegate_deadline.py | 3 +- .../review_probes/context_cap_probe.py | 2 +- evals/provider_fallback/probe_104360.py | 2 +- evals/provider_wire/issue_104359.py | 2 +- gateway/config_loader.py | 2 +- gateway/hooks.py | 2 +- gateway/profile_routing.py | 2 +- gateway/readiness.py | 2 +- gateway/run.py | 4 +- gateway/status_phrases.py | 2 +- hermes_cli/agent_import.py | 2 +- hermes_cli/backup.py | 2 +- hermes_cli/cli_info_mixin.py | 2 +- hermes_cli/config.py | 2 +- hermes_cli/doctor_platform.py | 2 +- hermes_cli/env_loader.py | 2 +- hermes_cli/inventory.py | 2 +- hermes_cli/main.py | 6 +- hermes_cli/managed_scope.py | 2 +- hermes_cli/mcp_catalog.py | 2 +- hermes_cli/model_switch_providers.py | 2 +- hermes_cli/plugin_catalog.py | 2 +- hermes_cli/plugin_packs.py | 4 +- hermes_cli/plugin_validate.py | 2 +- hermes_cli/plugins_admission.py | 2 +- hermes_cli/plugins_cmd.py | 2 +- hermes_cli/plugins_manifest.py | 4 +- hermes_cli/plugins_transaction.py | 2 +- hermes_cli/plugins_updates.py | 4 +- hermes_cli/profile_distribution.py | 2 +- hermes_cli/profiles.py | 4 +- hermes_cli/runtime_repair.py | 2 +- hermes_cli/skills_hub.py | 2 +- hermes_cli/skin_cmd.py | 2 +- hermes_cli/skin_engine.py | 2 +- hermes_cli/tools_config_cua.py | 2 +- hermes_cli/update_cmd_deps.py | 2 +- hermes_cli/venv_sync.py | 2 +- hermes_cli/web_routers/analytics.py | 2 +- hermes_cli/web_server.py | 2 +- hermes_cli/web_server_dashboard.py | 2 +- hermes_cli/web_server_memory.py | 2 +- hermes_time.py | 2 +- hermes_wisdom/agent_led/share_flow.py | 2 +- hermes_yaml.py | 77 +++ nix/configMergeScript.nix | 15 +- nix/hermes-agent.nix | 10 +- nix/pm-runtime.nix | 28 ++ .../assets/setup.sh.tmpl | 17 +- .../references/kanban-setup.md | 19 +- .../touchdesigner-mcp/scripts/setup.sh | 14 +- .../migration/openclaw-migration/SKILL.md | 3 + .../scripts/openclaw_to_hermes.py | 24 +- .../telephony/scripts/telephony.py | 9 +- .../references/experiment-patterns.md | 6 +- optional-skills/security/godmode/SKILL.md | 3 + .../godmode/scripts/auto_jailbreak.py | 21 +- plugins/memory/hindsight/embedded_runtime.py | 2 +- plugins/memory/holographic/__init__.py | 4 +- plugins/plugin_loader.py | 2 +- pm/__init__.py | 4 +- pm/cli.py | 10 +- pm/client.py | 224 +++++++++ pm/extras.py | 2 +- pm/launch.py | 10 + pm/plugins_state.py | 2 +- pm/pyproject.toml | 16 + pm/receipt.py | 40 ++ pm/recovery.py | 4 +- pm/registry.py | 97 +++- pm/runtime.py | 188 ++++++++ pm/runtime_stage.py | 71 +++ pm/uv.lock | 68 +++ pm/worker.py | 138 ++++++ providers/__init__.py | 4 +- pyproject.toml | 3 +- scripts/bundles/native.py | 15 + scripts/bundles/payload.py | 40 +- scripts/ci/setup_toolchain.py | 11 + scripts/ci/verify_toolchain.py | 4 +- scripts/discord-voice-doctor.py | 2 +- scripts/e2e_shared_metrics_staging.py | 2 +- scripts/install.ps1 | 2 + scripts/install.sh | 6 +- scripts/iso-certify.py | 3 +- scripts/lib/wisdom-demo-env.sh | 2 +- scripts/releases/darwin.py | 4 +- scripts/termux/build_deb.sh | 30 +- scripts/termux/termux_pkg_build.sh | 5 +- scripts/tool_search_livetest.py | 2 +- scripts/validate_plugin_catalog.py | 12 +- .../hermes-agent/references/windows-quirks.md | 2 +- .../hermes-agent-skill-authoring/SKILL.md | 5 +- tests/agent/test_actual_auxiliary_routing.py | 2 +- tests/agent/test_auxiliary_client.py | 4 +- ...y_client_base_url_host_validation_52608.py | 12 +- tests/agent/test_auxiliary_main_first.py | 2 +- .../test_auxiliary_named_custom_providers.py | 4 +- .../test_auxiliary_user_default_headers.py | 4 +- .../test_builtin_memory_disabled_surface.py | 2 +- tests/agent/test_codex_usage_attribution.py | 2 +- tests/agent/test_credential_pool.py | 8 +- tests/agent/test_i18n.py | 2 +- tests/agent/test_image_gen_registry.py | 2 +- tests/agent/test_model_metadata.py | 8 +- tests/agent/test_onboarding.py | 2 +- tests/agent/test_probe_cache_followups.py | 4 +- .../agent/test_skill_session_platform_gate.py | 2 +- tests/agent/test_video_gen_registry.py | 2 +- tests/ci/test_classify_changes.py | 2 +- .../ci/test_desktop_release_tag_admission.py | 2 +- tests/ci/test_live_comment.py | 4 +- tests/ci/test_stable_release_graph.py | 4 +- tests/ci/test_termux_input_archive.py | 6 +- tests/cli/test_cli_init.py | 8 +- tests/cli/test_cli_mcp_config_watch.py | 24 +- tests/cli/test_cli_provider_resolution.py | 12 +- tests/cli/test_cli_save_config_value.py | 4 +- tests/cli/test_personality_none.py | 10 +- ..._cron_multiplex_profile_route_preflight.py | 2 +- ...st_cron_multiplex_shared_route_delivery.py | 2 +- tests/cron/test_sessiondb_init_hang.py | 2 +- tests/docker/test_image_payload.py | 2 +- tests/gateway/relay/test_cold_opt_out.py | 2 +- tests/gateway/relay/test_explicit_disable.py | 2 +- .../test_25107_stale_base_url_api_mode.py | 2 +- tests/gateway/test_approvals_command.py | 2 +- tests/gateway/test_busy_session_ack.py | 2 +- tests/gateway/test_buzz_adapter.py | 2 +- tests/gateway/test_choice_picker.py | 2 +- tests/gateway/test_cjk_fts_config_bridge.py | 2 +- .../test_config_env_bridge_authority.py | 2 +- .../gateway/test_discord_channel_controls.py | 4 +- tests/gateway/test_display_config.py | 4 +- tests/gateway/test_dm_topics.py | 14 +- tests/gateway/test_fast_command.py | 2 +- tests/gateway/test_matrix_mention.py | 4 +- .../test_model_command_context_offload.py | 2 +- .../test_model_command_custom_providers.py | 2 +- .../test_model_command_expensive_confirm.py | 2 +- .../test_model_command_flat_string_config.py | 2 +- tests/gateway/test_model_picker_persist.py | 2 +- .../gateway/test_model_switch_persistence.py | 2 +- tests/gateway/test_multiplex_phase0.py | 2 +- .../gateway/test_plugin_message_injection.py | 2 +- tests/gateway/test_profile_routing.py | 2 +- tests/gateway/test_reasoning_command.py | 2 +- tests/gateway/test_run_progress_topics.py | 32 +- ...est_runtime_env_reload_config_authority.py | 2 +- .../test_silent_partial_delivery_95382.py | 4 +- tests/gateway/test_slack_model_picker.py | 4 +- ...test_slash_config_writes_routed_profile.py | 2 +- .../test_stale_finalize_suppression.py | 8 +- tests/gateway/test_stt_config.py | 4 +- tests/gateway/test_telegram_reactions.py | 4 +- tests/gateway/test_verbose_command.py | 2 +- tests/hermes_cli/test_agent_import.py | 2 +- tests/hermes_cli/test_agent_plugins.py | 2 +- tests/hermes_cli/test_api_key_providers.py | 12 +- tests/hermes_cli/test_approval_transport.py | 2 +- tests/hermes_cli/test_approvals_command.py | 2 +- tests/hermes_cli/test_atomic_yaml_write.py | 4 +- tests/hermes_cli/test_auth_commands.py | 6 +- tests/hermes_cli/test_auth_nous_provider.py | 8 +- tests/hermes_cli/test_auth_provider_gate.py | 4 +- tests/hermes_cli/test_aux_picker_inventory.py | 2 +- tests/hermes_cli/test_backup.py | 6 +- .../hermes_cli/test_bedrock_mantle_key_env.py | 2 +- tests/hermes_cli/test_config.py | 10 +- .../test_config_dotted_key_names.py | 2 +- tests/hermes_cli/test_config_loader_e2e.py | 2 +- tests/hermes_cli/test_config_set_coercion.py | 2 +- .../test_config_set_platforms_redirect.py | 4 +- .../test_custom_provider_model_switch.py | 16 +- ...test_dashboard_basic_auth_plugin_enable.py | 2 +- .../test_deferred_platform_client_tools.py | 4 +- .../test_destructive_slash_confirm_gate.py | 2 +- tests/hermes_cli/test_doctor.py | 8 +- tests/hermes_cli/test_early_recovery.py | 2 +- tests/hermes_cli/test_fallback_cmd.py | 2 +- ...est_fleet_config_migration_windows_live.py | 2 +- .../test_gemini_free_tier_setup_block.py | 4 +- tests/hermes_cli/test_gmi_provider.py | 2 +- tests/hermes_cli/test_inventory.py | 2 +- .../test_keyed_provider_credential_pool.py | 2 +- .../hermes_cli/test_managed_scope_loaders.py | 2 +- tests/hermes_cli/test_mcp_catalog.py | 2 +- .../test_mcp_catalog_env_boundary.py | 2 +- tests/hermes_cli/test_mcp_config.py | 2 +- .../test_mcp_reload_confirm_gate.py | 2 +- tests/hermes_cli/test_mcp_security.py | 2 +- .../test_memory_dependency_admission.py | 7 +- tests/hermes_cli/test_memory_setup.py | 2 +- .../test_model_assignment_env_key_mirror.py | 2 +- .../test_model_picker_excluded_providers.py | 2 +- .../test_model_provider_persistence.py | 4 +- .../test_model_switch_custom_providers.py | 2 +- .../test_model_switch_opencode_anthropic.py | 2 +- .../test_personality_single_owner.py | 2 +- tests/hermes_cli/test_pet_toggle.py | 6 +- tests/hermes_cli/test_plugin_api_compat.py | 2 +- tests/hermes_cli/test_plugin_capabilities.py | 2 +- tests/hermes_cli/test_plugin_catalog.py | 2 +- .../test_plugin_config_state_bridge.py | 2 +- .../test_plugin_dependency_consent.py | 2 +- tests/hermes_cli/test_plugin_event_bus.py | 6 +- tests/hermes_cli/test_plugin_install_ref.py | 2 +- tests/hermes_cli/test_plugin_manifest_v2.py | 4 +- .../test_plugin_message_injection.py | 2 +- .../test_plugin_ownership_ledger.py | 2 +- tests/hermes_cli/test_plugin_packs.py | 2 +- .../test_plugin_scanner_recursion.py | 4 +- tests/hermes_cli/test_plugin_validate.py | 2 +- tests/hermes_cli/test_plugin_version_order.py | 2 +- tests/hermes_cli/test_plugins.py | 16 +- .../test_plugins_admission_setter.py | 20 +- tests/hermes_cli/test_plugins_cmd.py | 2 +- .../test_plugins_cmd_category_discovery.py | 12 +- .../hermes_cli/test_plugins_cmd_deps_flow.py | 22 +- .../test_plugins_cmd_enable_disable_nested.py | 4 +- ...test_plugins_transcription_registration.py | 4 +- .../test_plugins_tts_registration.py | 4 +- tests/hermes_cli/test_plugins_update_sync.py | 3 +- tests/hermes_cli/test_profile_display_name.py | 2 +- tests/hermes_cli/test_profiles.py | 9 +- .../test_read_raw_config_readonly.py | 2 +- .../hermes_cli/test_reasoning_full_command.py | 2 +- tests/hermes_cli/test_relay_plugin_cutover.py | 2 +- tests/hermes_cli/test_resolve_turn_limit.py | 4 +- tests/hermes_cli/test_set_config_value.py | 46 +- tests/hermes_cli/test_setup_tts_xai_oauth.py | 2 +- .../test_sibling_config_migration.py | 2 +- tests/hermes_cli/test_skin_cmd.py | 2 +- tests/hermes_cli/test_skin_engine.py | 12 +- tests/hermes_cli/test_startup_fast_guards.py | 5 +- tests/hermes_cli/test_timestamps_command.py | 2 +- tests/hermes_cli/test_update_channel.py | 8 +- ...test_update_config_clears_custom_fields.py | 2 +- .../test_user_providers_model_switch.py | 4 +- tests/hermes_cli/test_venv_sync.py | 12 +- tests/hermes_cli/test_venv_sync_currency.py | 9 +- tests/hermes_cli/test_web_plugins_catalog.py | 2 +- tests/hermes_cli/test_web_server.py | 6 +- .../test_web_server_messaging_profiles.py | 2 +- .../test_web_server_profile_unification.py | 2 +- .../test_web_server_skills_profiles.py | 2 +- tests/hermes_cli/test_web_server_tts_lease.py | 2 +- .../plugins/image_gen/test_openai_provider.py | 4 +- .../memory/test_openviking_optional_peer.py | 2 +- tests/plugins/test_disk_cleanup_plugin.py | 4 +- tests/plugins/test_langfuse_plugin.py | 2 +- .../plugins/test_security_guidance_plugin.py | 2 +- tests/pm/test_activation_setup.py | 38 +- tests/pm/test_admission_members_locked.py | 2 + tests/pm/test_cold_runtime_e2e.py | 242 ++++++++++ tests/pm/test_custom_root_union.py | 2 +- tests/pm/test_download_cleanup.py | 4 +- tests/pm/test_extras.py | 15 +- tests/pm/test_install_download_control.py | 7 +- tests/pm/test_installed_package.py | 3 +- tests/pm/test_plugin_survival_contract.py | 6 +- tests/pm/test_plugins_state.py | 8 +- tests/pm/test_recovery.py | 39 ++ tests/pm/test_runtime.py | 85 ++++ tests/pm/test_runtime_entrypoints.py | 85 ++++ tests/pm/test_runtime_public.py | 19 + tests/pm/test_runtime_transaction.py | 2 +- tests/pm/test_runtime_wheelhouse.py | 112 +++++ tests/pm/test_update_artifact_refresh.py | 12 +- tests/pm/test_update_dependency_legs.py | 23 +- tests/pm/test_update_failure_status.py | 12 +- tests/pm/test_worker.py | 449 ++++++++++++++++++ tests/pm/test_worker_receipts.py | 26 + tests/pm/test_worker_registry.py | 218 +++++++++ tests/scripts/test_bundle_native.py | 23 +- tests/scripts/test_pm_runtime_bundle.py | 144 ++++++ tests/scripts/test_release_darwin.py | 2 +- tests/scripts/test_setup_toolchain.py | 3 + tests/scripts/test_validate_plugin_catalog.py | 4 +- tests/skills/test_actual_setup_skill.py | 2 +- tests/skills/test_authoring_standards.py | 2 +- .../test_collective_wisdom_install_skill.py | 2 +- .../test_competitor_news_monitor_skill.py | 2 +- tests/skills/test_darwinian_evolver_skill.py | 2 +- .../test_decision_questionnaire_skill.py | 2 +- .../test_document_to_action_items_skill.py | 2 +- tests/skills/test_email_inbox_triage_skill.py | 2 +- tests/skills/test_github_skill.py | 2 +- tests/skills/test_grounded_citations_skill.py | 2 +- .../test_mcp_oauth_remote_gateway_skill.py | 2 +- .../skills/test_meeting_action_items_skill.py | 2 +- tests/skills/test_office_document_skills.py | 2 +- tests/skills/test_openclaw_migration.py | 4 +- tests/skills/test_pinecone_research_skill.py | 2 +- .../test_product_price_monitor_skill.py | 2 +- .../test_setup_wizard_generator_skill.py | 2 +- ...est_social_media_content_calendar_skill.py | 2 +- .../test_weekly_review_planning_skill.py | 2 +- tests/test_atomic_replace_symlinks.py | 2 +- tests/test_fast_safe_load.py | 45 +- tests/test_gateway_streaming_nested_config.py | 40 +- tests/test_hermes_logging.py | 8 +- tests/test_hermes_yaml.py | 89 ++++ tests/test_journal_mode_config.py | 2 +- tests/test_journal_mode_upgrade_warning.py | 2 +- tests/test_session_vacuum_config.py | 2 +- tests/test_state_db_malformed_repair.py | 2 +- tests/test_transform_llm_output_hook.py | 2 +- tests/test_transform_tool_result_hook.py | 2 +- tests/test_tui_gateway_server.py | 30 +- .../test_utils_atomic_roundtrip_yaml_save.py | 2 +- tests/test_yaml_indent_consistency_31999.py | 170 +++---- tests/tools/test_allowlist_legacy_config.py | 2 +- tests/tools/test_credential_files.py | 4 +- tests/tools/test_delegate_fallback_matrix.py | 6 +- tests/tools/test_docker_config_migrate.py | 2 +- tests/tools/test_env_passthrough.py | 6 +- .../test_image_generation_image_to_image.py | 2 +- tests/tools/test_mcp_tool.py | 2 +- tests/tools/test_pre_transcription_hook.py | 2 +- .../test_terminal_output_transform_hook.py | 2 +- tests/tools/test_tts_kittentts.py | 2 +- .../test_video_generation_dynamic_schema.py | 2 +- ...st_video_generation_tool_surface_matrix.py | 2 +- tests/tools/test_voice_client_config.py | 2 +- tests/tools/test_xai_http_storage.py | 2 +- .../tui_gateway/test_config_profile_scope.py | 2 +- .../test_config_set_display_toggles.py | 2 +- tests/tui_gateway/test_mcp_profile_rpcs.py | 2 +- ...test_personality_clobbers_system_prompt.py | 2 +- .../test_profile_rebuild_commit.py | 2 +- tests/tui_gateway/test_profile_shell_hooks.py | 2 +- .../test_profiles_configure_model_guard.py | 2 +- .../test_slash_worker_mcp_discovery.py | 2 +- .../test_stale_provider_resume_live.py | 2 +- tests/website/test_extract_plugins.py | 2 +- tools/approval.py | 2 +- tools/blueprints.py | 4 +- tools/bot_mode_probe.py | 2 +- tools/computer_use/cua_backend.py | 2 +- tools/file_operations_lint.py | 11 +- tools/skill_manager_tool.py | 2 +- tools/skills_hub.py | 2 +- tools/skills_hub_models.py | 2 +- tools/website_policy.py | 4 +- trajectory_compressor.py | 2 +- tui_gateway/methods_profiles.py | 2 +- utils.py | 50 +- uv.lock | 2 - website/docs/developer-guide/plugins/index.md | 3 +- website/docs/reference/package-management.md | 26 +- ...evelopment-hermes-agent-skill-authoring.md | 5 +- .../migration/migration-openclaw-migration.md | 3 + .../optional/security/security-godmode.md | 3 + .../current/developer-guide/plugins/index.md | 3 +- .../autonomous-ai-agents-hermes-agent.md | 6 +- ...evelopment-hermes-agent-skill-authoring.md | 5 +- website/scripts/extract-plugins.py | 5 +- website/scripts/extract-skills.py | 6 +- website/scripts/generate-skill-docs.py | 6 +- website/scripts/prebuild.mjs | 4 +- 383 files changed, 3508 insertions(+), 935 deletions(-) create mode 100644 hermes_yaml.py create mode 100644 nix/pm-runtime.nix create mode 100644 pm/client.py create mode 100644 pm/launch.py create mode 100644 pm/pyproject.toml create mode 100644 pm/runtime.py create mode 100644 pm/runtime_stage.py create mode 100644 pm/uv.lock create mode 100644 pm/worker.py create mode 100644 tests/pm/test_cold_runtime_e2e.py create mode 100644 tests/pm/test_runtime.py create mode 100644 tests/pm/test_runtime_entrypoints.py create mode 100644 tests/pm/test_runtime_public.py create mode 100644 tests/pm/test_runtime_wheelhouse.py create mode 100644 tests/pm/test_worker.py create mode 100644 tests/pm/test_worker_receipts.py create mode 100644 tests/pm/test_worker_registry.py create mode 100644 tests/scripts/test_pm_runtime_bundle.py create mode 100644 tests/test_hermes_yaml.py diff --git a/.github/workflows/canary-release.yml b/.github/workflows/canary-release.yml index 4f97afef2f..85ea1f7e81 100644 --- a/.github/workflows/canary-release.yml +++ b/.github/workflows/canary-release.yml @@ -85,4 +85,4 @@ jobs: 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: uv run --no-project --with PyYAML==6.0.3 python -m scripts.releases.r2 prune-canaries --keep-days 14 + run: uv run --no-project --with ruamel.yaml==0.18.17 python -m scripts.releases.r2 prune-canaries --keep-days 14 diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index d024b7772d..fa016a9b3f 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -72,10 +72,10 @@ jobs: toolchain: all node-cache-dependency-path: website/package-lock.json - - name: Install PyYAML for skill extraction + - name: Install ruamel.yaml for skill extraction uses: ./.github/actions/retry with: - command: uv pip install --python "$HERMES_PYTHON" pyyaml==6.0.2 httpx==0.28.1 + command: uv pip install --python "$HERMES_PYTHON" ruamel.yaml==0.18.17 httpx==0.28.1 - name: Prepare skills index (unified multi-source catalog) env: diff --git a/.github/workflows/desktop-bundled-release.yml b/.github/workflows/desktop-bundled-release.yml index 219db1243a..68be4377ae 100644 --- a/.github/workflows/desktop-bundled-release.yml +++ b/.github/workflows/desktop-bundled-release.yml @@ -59,7 +59,7 @@ name: Desktop Bundled Release # 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/releases/r2.py derives the S3 endpoint from -# the account id. Upload/list operations need only Python; feed operations use PyYAML. +# the account id. Upload/list operations need only Python; feed operations use ruamel.yaml. # # Windows Store submission (publish-win32-store): MSStore CLI via # microsoft/microsoft-store-apppublisher. Credentials live in the @@ -1242,7 +1242,7 @@ jobs: # (feed-side); the pointer upload is the last write of the run. shell: bash run: | - uv run --no-project --with PyYAML==6.0.3 python -m scripts.releases.r2 finalize --tag "$HERMES_PAYLOAD_TAG" --dir staged + uv run --no-project --with ruamel.yaml==0.18.17 python -m scripts.releases.r2 finalize --tag "$HERMES_PAYLOAD_TAG" --dir staged termux-deb: name: Build + publish the termux .deb (aarch64) @@ -1816,7 +1816,7 @@ jobs: - uses: ./.github/actions/setup-pm with: cache-python: false - - run: uv run --no-project --with PyYAML==6.0.3 python -m scripts.bundles.release_artifacts promote --root verified + - run: uv run --no-project --with ruamel.yaml==0.18.17 python -m scripts.bundles.release_artifacts promote --root verified - run: python scripts/render-builds-table.py --tag "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" stable-phase-result: diff --git a/.github/workflows/docs-site-checks.yml b/.github/workflows/docs-site-checks.yml index 1e7ced3ce2..ca44f47ab2 100644 --- a/.github/workflows/docs-site-checks.yml +++ b/.github/workflows/docs-site-checks.yml @@ -28,7 +28,7 @@ jobs: - name: Install ascii-guard uses: ./.github/actions/retry with: - command: uv pip install --python "$HERMES_PYTHON" ascii-guard==2.3.0 pyyaml==6.0.3 + command: uv pip install --python "$HERMES_PYTHON" ascii-guard==2.3.0 ruamel.yaml==0.18.17 - name: Extract skill metadata for dashboard run: python3 website/scripts/extract-skills.py diff --git a/.github/workflows/plugin-catalog-ci.yml b/.github/workflows/plugin-catalog-ci.yml index 84efbc0761..204574d801 100644 --- a/.github/workflows/plugin-catalog-ci.yml +++ b/.github/workflows/plugin-catalog-ci.yml @@ -29,10 +29,10 @@ jobs: with: python-version: "3.11" - - name: Install PyYAML + - name: Install ruamel.yaml uses: ./.github/actions/retry with: - command: pip install pyyaml==6.0.2 + command: pip install ruamel.yaml==0.18.17 - name: Validate catalog files (structural) run: | @@ -93,7 +93,7 @@ jobs: import shlex import sys - import yaml + import hermes_yaml as yaml with open(sys.argv[1], encoding="utf-8") as fh: data = yaml.safe_load(fh) or {} diff --git a/.github/workflows/pm-toolchain.yml b/.github/workflows/pm-toolchain.yml index 346bdd26a8..4c07715877 100644 --- a/.github/workflows/pm-toolchain.yml +++ b/.github/workflows/pm-toolchain.yml @@ -52,8 +52,8 @@ jobs: cache-suffix: smoke-consumers-${{ github.run_id }}-${{ github.run_attempt }} - name: Install the docs tools into the command environment run: | - uv pip install --python "$HERMES_PYTHON" ascii-guard==2.3.0 pyyaml==6.0.3 httpx==0.28.1 - python3 -c 'import yaml,httpx; print(yaml.__version__, httpx.__version__)' + uv pip install --python "$HERMES_PYTHON" ascii-guard==2.3.0 ruamel.yaml==0.18.17 httpx==0.28.1 + python3 -c 'import ruamel.yaml,httpx; print(ruamel.yaml.__version__, httpx.__version__)' - name: Install the locked desktop and test workspaces run: npm ci --workspace apps/desktop --workspace tests-js --include-workspace-root --include=dev --ignore-scripts --no-audit --no-fund - name: Exercise the actual after-pack relocation hook diff --git a/.github/workflows/skills-index.yml b/.github/workflows/skills-index.yml index 119e195b6d..60c980206b 100644 --- a/.github/workflows/skills-index.yml +++ b/.github/workflows/skills-index.yml @@ -39,7 +39,7 @@ jobs: - name: Install dependencies uses: ./.github/actions/retry with: - command: uv pip install --python "$HERMES_PYTHON" httpx==0.28.1 pyyaml==6.0.2 + command: uv pip install --python "$HERMES_PYTHON" httpx==0.28.1 ruamel.yaml==0.18.17 - name: Build skills index env: diff --git a/Dockerfile b/Dockerfile index e2a8eb77b7..1199106dae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -207,8 +207,9 @@ COPY pm/ pm/ COPY hermes_constants.py hermes_constants.py # PM imports the shared stdlib runtime path and locking owners before deps exist. COPY hermes_cli/__init__.py hermes_cli/runtime_paths.py hermes_cli/runtime_state.py hermes_cli/ +COPY scripts/bundles/payload.py scripts/bundles/payload.py RUN set -eu; \ - python3 -m pm.cli install uv chromium; \ + python3 -c 'from pm.ensure import ensure; [ensure(name, explicit=True) for name in ("uv", "chromium")]'; \ ln -sf /opt/hermes/tools/uv-*/uv /usr/local/bin/uv; \ python3 -c 'from pathlib import Path; from pm.lock import Facts; from pm.registry import get_package; from pm.store import current_target; root = Path("/opt/hermes/tools"); fact = Facts(root / "facts.json").get("python"); binary = get_package("python").binary(root / fact["entry"], current_target()); Path("/usr/local/bin/python3").symlink_to(binary)'; \ uv --version; \ @@ -218,6 +219,10 @@ RUN set -eu; \ mkdir -p /etc/hermes; \ printf '%s' "$browser_bin" > /etc/hermes/agent-browser-executable-path +# PM is resident too: never borrow application libraries or create its worker +# environment under /root (unreachable to the runtime UID). +RUN python3 -c 'from pathlib import Path; from pm.runtime_stage import stage_runtime; from scripts.bundles.payload import seal_pm_runtime; root = Path("/opt/hermes"); python = Path("/usr/local/bin/python3").resolve(); stage_runtime(Path("/usr/local/bin/uv"), python, root / "pm-runtime", project=root / "pm"); seal_pm_runtime(root, python)' + # Raw uv commands must use PM's staged interpreter, not download another # under /root where the unprivileged runtime user cannot traverse it. ENV UV_PYTHON=/usr/local/bin/python3 @@ -393,6 +398,7 @@ RUN set -eu; \ printf '{"schemaVersion":2,"commit":"0000000000000000000000000000000000000000","distribution":"docker","source":"fallback","updateMechanism":"external"}\n' \ > /opt/hermes/install-stamp.json; \ fi; \ + python3 -c 'import json; from pathlib import Path; path = Path("/opt/hermes/install-stamp.json"); stamp = json.loads(path.read_text()); stamp["pmRuntime"] = "/opt/hermes/pm-runtime"; path.write_text(json.dumps(stamp) + "\n")'; \ mkdir -p /etc/hermes; \ 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)' diff --git a/agent/curator.py b/agent/curator.py index c078794698..9cd0dd8f1c 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -532,7 +532,7 @@ def _parse_structured_summary(llm_final: str) -> Dict[str, List[Dict[str, str]]] data = None if match: try: - import yaml # type: ignore + import hermes_yaml as yaml data = yaml.safe_load(match.group(1)) except Exception: pass diff --git a/agent/i18n.py b/agent/i18n.py index 66cc1f2e85..b6cec724e2 100644 --- a/agent/i18n.py +++ b/agent/i18n.py @@ -100,7 +100,7 @@ def _load_catalog(lang: str) -> dict[str, str]: logger.debug("i18n catalog missing for %s at %s", lang, path) return _cache_catalog(lang, flat) try: - import yaml + import hermes_yaml as yaml with path.open("r", encoding="utf-8-sig") as f: _flatten_into(yaml.safe_load(f) or {}, "", flat) except Exception as exc: diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 1e20bb5c13..2a293f583f 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -17,7 +17,7 @@ from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple from urllib.parse import urlparse -import yaml +import hermes_yaml as yaml from agent import model_metadata_http diff --git a/agent/onboarding.py b/agent/onboarding.py index cb290cecef..13a96c10e6 100644 --- a/agent/onboarding.py +++ b/agent/onboarding.py @@ -153,10 +153,10 @@ def is_seen(config: Mapping[str, Any], flag: str) -> bool: def mark_seen(config_path: Path, flag: str) -> bool: """Persist ``onboarding.seen. = True`` atomically; False on any error (best-effort).""" try: - import yaml + import hermes_yaml as yaml from hermes_cli.config import atomic_config_write except Exception as e: # pragma: no cover — dependency issue - logger.debug("onboarding: failed to import yaml/utils: %s", e) + logger.debug("onboarding: failed to import hermes_yaml/utils: %s", e) return False try: cfg: dict = {} diff --git a/agent/proxy_sources/iron_proxy.py b/agent/proxy_sources/iron_proxy.py index 4d19919af8..19d86d606b 100644 --- a/agent/proxy_sources/iron_proxy.py +++ b/agent/proxy_sources/iron_proxy.py @@ -411,10 +411,10 @@ def ensure_management_token(*, force: bool = False) -> str: def _yaml(): - """PyYAML module or None (it is a Hermes dep, but never a hard requirement here).""" + """Shared YAML helpers or None (not a hard requirement for proxy discovery).""" try: - import yaml + import hermes_yaml as yaml return yaml except ImportError: return None @@ -434,7 +434,7 @@ def _parse_listen(listen) -> Optional[Tuple[str, int]]: def _config_listen(section: str, *keys: str, config_path: Optional[Path] = None) -> Optional[Tuple[str, int]]: - """``(host, port)`` from the first truthy ``proxy.yaml[section][key]``, or None (also when file/PyYAML is missing).""" + """``(host, port)`` from the first truthy ``proxy.yaml[section][key]``, or None (also when file/ruamel.yaml is missing).""" yaml, data = _yaml(), {} if yaml is not None: with suppress(OSError, yaml.YAMLError): @@ -607,7 +607,7 @@ def _write_state_file_atomic(state: Path, name: str, dump) -> Path: def write_proxy_config(config: Dict) -> Path: """Serialize the config dict to ``/proxy/proxy.yaml`` (safe_dump, no Python tags).""" if (yaml := _yaml()) is None: - raise RuntimeError("PyYAML is required to write the iron-proxy config but is not installed.") + raise RuntimeError("ruamel.yaml is required to write the iron-proxy config but is not installed.") return _write_state_file_atomic(_proxy_state_dir(), "proxy.yaml", lambda f: yaml.safe_dump(config, f, default_flow_style=False, sort_keys=False)) diff --git a/agent/skill_bundles.py b/agent/skill_bundles.py index facbfe704a..2fc0290798 100644 --- a/agent/skill_bundles.py +++ b/agent/skill_bundles.py @@ -13,7 +13,7 @@ import os from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -import yaml +import hermes_yaml as yaml from hermes_constants import get_hermes_home from agent.skill_commands import command_snapshot, diff_command_snapshots, resolve_slash_key, slugify_skill_name as _slugify diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 42fabfefa6..fe783cd52a 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -115,12 +115,11 @@ _yaml_load_fn = None def yaml_load(content: str): - """Parse YAML with lazy import and CSafeLoader preference.""" + """Parse YAML with the shared safe loader, imported lazily.""" global _yaml_load_fn if _yaml_load_fn is None: - import functools - import yaml - _yaml_load_fn = functools.partial(yaml.load, Loader=getattr(yaml, "CSafeLoader", None) or yaml.SafeLoader) + from hermes_yaml import safe_load + _yaml_load_fn = safe_load return _yaml_load_fn(content) diff --git a/apps/desktop/electron/backend-probes-runtime.test.ts b/apps/desktop/electron/backend-probes-runtime.test.ts index 33119b7135..6b215ea797 100644 --- a/apps/desktop/electron/backend-probes-runtime.test.ts +++ b/apps/desktop/electron/backend-probes-runtime.test.ts @@ -8,8 +8,8 @@ import { test } from 'vitest' import { canImportHermesCli } from './backend-probes' -const REPO = path.resolve(import.meta.dirname, '../../..') -const PYTHON = process.env.HERMES_PYTHON || process.env.UV_PYTHON || (process.platform === 'win32' ? 'python' : 'python3') +const REPO: string = path.resolve(import.meta.dirname, '../../..') +const PYTHON: string = process.env.HERMES_PYTHON || process.env.UV_PYTHON || (process.platform === 'win32' ? 'python' : 'python3') interface RuntimeFixture { python: string @@ -17,11 +17,11 @@ interface RuntimeFixture { dependencies: string } -test('the real bootstrap supplies selected dependencies and rejects foreign-path rescue', () => { - const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-probe-runtime-')) - const home = path.join(temp, 'home') +test('the real bootstrap supplies ruamel-only dependencies and rejects foreign-path rescue', (): void => { + const temp: string = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-probe-runtime-')) + const home: string = path.join(temp, 'home') - const env = { + const env: NodeJS.ProcessEnv = { ...process.env, HERMES_HOME: home, HERMES_RUNTIME_DIR: path.join(temp, 'tools'), @@ -33,7 +33,7 @@ test('the real bootstrap supplies selected dependencies and rejects foreign-path PYTHONDONTWRITEBYTECODE: '1' } - const setup = ` + const setup: string = ` import json, os, re, shutil, subprocess, sys, tomllib, venv from pathlib import Path root, temp = map(Path, sys.argv[1:]) @@ -44,7 +44,7 @@ venv.EnvBuilder(with_pip=False).create(seed) dependencies = temp / 'dependencies' manifest = tomllib.loads((root / 'pyproject.toml').read_text(encoding='utf-8')) specs = [spec for spec in manifest['project']['dependencies'] - if re.split(r'[<>=;\\[]', spec, maxsplit=1)[0].lower() in ('pyyaml', 'python-dotenv')] + if re.split(r'[<>=;\\[]', spec, maxsplit=1)[0].lower() in ('ruamel.yaml', 'python-dotenv')] assert len(specs) == 2, specs subprocess.run([shutil.which('uv'), 'pip', 'install', '--python', sys.executable, '--target', str(dependencies), '--no-deps', *specs], @@ -61,7 +61,7 @@ print(json.dumps({'python': str(python), 'site': str(site), 'dependencies': str( ` try { - const fixture = JSON.parse(execFileSync(PYTHON, ['-I', '-c', setup, REPO, temp], { + const fixture: RuntimeFixture = JSON.parse(execFileSync(PYTHON, ['-I', '-c', setup, REPO, temp], { cwd: temp, env, encoding: 'utf8', timeout: 90_000, windowsHide: true })) as RuntimeFixture @@ -72,7 +72,7 @@ print(json.dumps({'python': str(python), 'site': str(site), 'dependencies': str( }), true, 'Python home overrides must be scrubbed before the interpreter starts') fs.unlinkSync(path.join(fixture.site, 'selected-dependencies.pth')) - const foreign = path.join(temp, 'foreign-packages') + const foreign: string = path.join(temp, 'foreign-packages') fs.symlinkSync(fixture.dependencies, foreign, process.platform === 'win32' ? 'junction' : 'dir') assert.equal(canImportHermesCli(fixture.python, { cwd: REPO, diff --git a/apps/desktop/electron/backend-probes.ts b/apps/desktop/electron/backend-probes.ts index 23cf7cb639..8fd3f58d1e 100644 --- a/apps/desktop/electron/backend-probes.ts +++ b/apps/desktop/electron/backend-probes.ts @@ -88,10 +88,10 @@ function canImportHermesCli(pythonPath: string, opts: { env?: NodeJS.ProcessEnv; } try { - const env = { ...process.env, ...opts.env } + const env: NodeJS.ProcessEnv = { ...process.env, ...opts.env } // Bootstrap selects the committed generation before any dependency import. - execProbeSync(pythonPath, ['-c', 'import hermes_bootstrap; import yaml; import dotenv; import hermes_cli.config'], { + execProbeSync(pythonPath, ['-c', 'import hermes_bootstrap; import hermes_yaml; import dotenv; import hermes_cli.config'], { cwd: opts.cwd, env: { ...env, ...buildDesktopBackendEnv({ currentEnv: env }) }, stdio: 'ignore', diff --git a/evals/browser_use/single_run.py b/evals/browser_use/single_run.py index 6015656fb1..bbe116f3cf 100644 --- a/evals/browser_use/single_run.py +++ b/evals/browser_use/single_run.py @@ -56,7 +56,8 @@ cfg = { "browser": browser_cfg, "display": {"quiet": True}, } -import yaml +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) +import hermes_yaml as yaml with open(os.path.join(hh, "config.yaml"), "w", encoding="utf-8") as f: yaml.safe_dump(cfg, f) diff --git a/evals/desktop_bug_campaign/hooks_live.py b/evals/desktop_bug_campaign/hooks_live.py index 74638d7325..0b722bafaa 100644 --- a/evals/desktop_bug_campaign/hooks_live.py +++ b/evals/desktop_bug_campaign/hooks_live.py @@ -10,7 +10,8 @@ import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -import yaml +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +import hermes_yaml as yaml from websockets.sync.client import connect p = argparse.ArgumentParser() diff --git a/evals/desktop_bug_campaign/linux_launcher_probe.py b/evals/desktop_bug_campaign/linux_launcher_probe.py index afc8e9f7b7..792d6a267f 100644 --- a/evals/desktop_bug_campaign/linux_launcher_probe.py +++ b/evals/desktop_bug_campaign/linux_launcher_probe.py @@ -48,8 +48,8 @@ def main(): ) rows: dict = {"source_sha": args.source_sha or subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip(), "root": str(root), "lexical_python": str(python), "base_python": str(python.resolve())} - rows["venv_import"] = run([str(python), "-I", "-c", "import yaml,hermes_cli.main; print(yaml.__version__)"], env, "/") - rows["base_import_negative"] = run([str(python.resolve()), "-I", "-c", "import yaml,hermes_cli.main"], env, "/") + rows["venv_import"] = run([str(python), "-I", "-c", "import ruamel.yaml,hermes_cli.main; print(ruamel.yaml.__version__)"], env, "/") + rows["base_import_negative"] = run([str(python.resolve()), "-I", "-c", "import ruamel.yaml,hermes_cli.main"], env, "/") rows["install"] = run([str(python), "-c", install], env, "/") assert rows["install"]["returncode"] == 0, rows["install"] installed = json.loads(rows["install"]["stdout"].splitlines()[-1]) diff --git a/evals/desktop_bug_campaign/persistence_live.py b/evals/desktop_bug_campaign/persistence_live.py index 037f09b72f..42e46024b4 100644 --- a/evals/desktop_bug_campaign/persistence_live.py +++ b/evals/desktop_bug_campaign/persistence_live.py @@ -59,7 +59,8 @@ def main(): model = ThreadingHTTPServer(('127.0.0.1', args.port + 1), Model) threading.Thread(target=model.serve_forever, daemon=True).start() config = {'model': {'default': 'persistence-fixture', 'provider': 'custom', 'base_url': f'http://127.0.0.1:{args.port+1}/v1'}, 'agent': {'max_turns': 1}, 'compression': {'enabled': False}, 'toolsets': [], 'platform_toolsets': {'gui': [], 'cli': []}, 'memory': {'memory_enabled': False, 'user_profile_enabled': False}} - import yaml + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + import hermes_yaml as yaml for p in (home, profile): (p / 'config.yaml').write_text(yaml.safe_dump(config)) (p / '.env').write_text('OPENAI_API_KEY=local-fixture\n') diff --git a/evals/gateway/session_time_persistence_controls.py b/evals/gateway/session_time_persistence_controls.py index 46652674d3..e70099d449 100644 --- a/evals/gateway/session_time_persistence_controls.py +++ b/evals/gateway/session_time_persistence_controls.py @@ -19,7 +19,7 @@ for key in list(os.environ): os.environ.pop(key, None) os.environ.update(HOME=str(home), HERMES_HOME=str(home / ".hermes"), SESSION_IDLE_MINUTES="1", SESSION_RESET_HOUR="0") sys.path.insert(0, str(repo)) -import yaml +import hermes_yaml as yaml from gateway.config import load_gateway_config, GatewayConfig, Platform from gateway.session import SessionStore, SessionSource from gateway.run import GatewayRunner diff --git a/evals/postmortem/live_ab/nested_delegate_deadline.py b/evals/postmortem/live_ab/nested_delegate_deadline.py index f9629799ef..1816cf48fe 100644 --- a/evals/postmortem/live_ab/nested_delegate_deadline.py +++ b/evals/postmortem/live_ab/nested_delegate_deadline.py @@ -9,7 +9,8 @@ sys.path.insert(0, root) # Temp HERMES_HOME with the real auth + a config that shortens the generic sequential deadline to 40 s, so the # run takes ~1.5 min instead of 8. The fix exempts delegate_task from this deadline entirely, so the shortened # value is exactly what main will hit. -import shutil, tempfile, yaml +import shutil, tempfile +import hermes_yaml as yaml home = tempfile.mkdtemp(prefix="dl_home_"); os.environ["HERMES_HOME"] = home real_home = os.environ.get("HERMES_HOME_SOURCE", os.path.expanduser("~/.hermes")) # credentials are copied from here into a temp home shutil.copy(f"{real_home}/auth.json", f"{home}/auth.json") diff --git a/evals/postmortem/review_probes/context_cap_probe.py b/evals/postmortem/review_probes/context_cap_probe.py index 57b3300f6a..144adc65ac 100644 --- a/evals/postmortem/review_probes/context_cap_probe.py +++ b/evals/postmortem/review_probes/context_cap_probe.py @@ -16,7 +16,7 @@ for k in list(os.environ): home = tempfile.mkdtemp(prefix='cap-review-') os.environ['HERMES_HOME'] = home os.environ['HERMES_DISABLE_REDACTION'] = 'true' -import yaml +import hermes_yaml as yaml cfg = {'model': {'default': 'anthropic/claude-fable-5.1', 'provider':'openai-compat', 'base_url':'http://127.0.0.1:1/v1', 'context_length':1000000}, 'compression':{'threshold':0.85}, 'delegation': {}} if len(sys.argv)>3: cfg['delegation']['compression_threshold_tokens'] = json.loads(sys.argv[3]) diff --git a/evals/provider_fallback/probe_104360.py b/evals/provider_fallback/probe_104360.py index 58f6f663a2..26338d8115 100644 --- a/evals/provider_fallback/probe_104360.py +++ b/evals/provider_fallback/probe_104360.py @@ -83,7 +83,7 @@ class Handler(BaseHTTPRequestHandler): server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) threading.Thread(target=server.serve_forever, daemon=True).start() url = f"http://127.0.0.1:{server.server_port}/serving-endpoints" -import yaml +import hermes_yaml as yaml config = { "model": {"provider": "fixture-provider", "default": "model-a"}, diff --git a/evals/provider_wire/issue_104359.py b/evals/provider_wire/issue_104359.py index 1f023ab3f7..d628b88520 100644 --- a/evals/provider_wire/issue_104359.py +++ b/evals/provider_wire/issue_104359.py @@ -121,7 +121,7 @@ config = { }, "prompt_caching": {"enabled": False}, } -import yaml +import hermes_yaml as yaml Path(os.environ["HERMES_HOME"], "config.yaml").write_text(yaml.safe_dump(config), encoding="utf-8") from hermes_state import SessionDB diff --git a/gateway/config_loader.py b/gateway/config_loader.py index fd276eb431..4a19a3567e 100644 --- a/gateway/config_loader.py +++ b/gateway/config_loader.py @@ -344,7 +344,7 @@ def read_yaml_layers(home: Path) -> dict: (``gateway.relay.relay_explicitly_disabled``) reads through here so it cannot disagree with ``load_gateway_config()`` on which files count. """ - import yaml + import hermes_yaml as yaml config_yaml_path = home / "config.yaml" yaml_cfg: dict = {} diff --git a/gateway/hooks.py b/gateway/hooks.py index 15c546b379..027dcf9023 100644 --- a/gateway/hooks.py +++ b/gateway/hooks.py @@ -15,7 +15,7 @@ import sys from pathlib import Path from typing import Any, Callable, Dict, List, Optional -import yaml +import hermes_yaml as yaml from hermes_cli.config import get_hermes_home diff --git a/gateway/profile_routing.py b/gateway/profile_routing.py index ae19d6a808..95b505140e 100644 --- a/gateway/profile_routing.py +++ b/gateway/profile_routing.py @@ -90,7 +90,7 @@ class ProfileRoute: def _coerce_route_id(value: Any) -> Optional[str]: """Normalize a route discriminator to str for strict equality matching. - PyYAML loads unquoted numeric IDs as ``int`` while ``SessionSource`` fields are ``str``. Only + YAML loads unquoted numeric IDs as ``int`` while ``SessionSource`` fields are ``str``. Only ``int`` (not ``bool``) is coerced; floats stringify to something (``"123.0"``) that can never match, so they get a load-time warning instead. diff --git a/gateway/readiness.py b/gateway/readiness.py index aa4e84405d..7b05bc1029 100644 --- a/gateway/readiness.py +++ b/gateway/readiness.py @@ -8,7 +8,7 @@ from contextlib import closing from pathlib import Path from typing import Any -import yaml +import hermes_yaml as yaml from hermes_constants import get_hermes_home diff --git a/gateway/run.py b/gateway/run.py index 9df5b884da..7e70a8dbf9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2789,7 +2789,7 @@ def _load_gateway_config(config_path: "Path | None" = None) -> dict: if not used_canonical: try: if config_path.exists(): - import yaml + import hermes_yaml as yaml with open(config_path, 'r', encoding='utf-8-sig') as f: raw = yaml.safe_load(f) or {} except Exception: @@ -5416,7 +5416,7 @@ def main(): config = None if args.config: - import yaml + import hermes_yaml as yaml with open(args.config, encoding="utf-8-sig") as f: config = GatewayConfig.from_dict(yaml.safe_load(f) or {}) diff --git a/gateway/status_phrases.py b/gateway/status_phrases.py index 659827b116..02cfe4b275 100644 --- a/gateway/status_phrases.py +++ b/gateway/status_phrases.py @@ -13,7 +13,7 @@ from collections.abc import Mapping, MutableSequence from pathlib import Path from typing import Any -import yaml +import hermes_yaml as yaml from hermes_constants import get_hermes_home diff --git a/hermes_cli/agent_import.py b/hermes_cli/agent_import.py index 14a63a2267..6a49f5ac74 100644 --- a/hermes_cli/agent_import.py +++ b/hermes_cli/agent_import.py @@ -17,7 +17,7 @@ import tomllib from pathlib import Path from typing import Any, Dict, List, Optional, Sequence, Tuple -import yaml +import hermes_yaml as yaml from utils import atomic_write_text, atomic_yaml_write diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index 9f8a77abe4..4eaadb7797 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -1696,7 +1696,7 @@ def _read_raw_yaml_dict(path: Path) -> Optional[Dict[str, Any]]: if not path.is_file(): return None try: - import yaml + import hermes_yaml as yaml with open(path, "r", encoding="utf-8-sig") as f: data = yaml.safe_load(f) diff --git a/hermes_cli/cli_info_mixin.py b/hermes_cli/cli_info_mixin.py index 693b6e1afa..f7dc079e4c 100644 --- a/hermes_cli/cli_info_mixin.py +++ b/hermes_cli/cli_info_mixin.py @@ -799,7 +799,7 @@ class CLIInfoMixin: cache** (the next message re-sends the full input prefix, expensive on long-context / high-reasoning models). See #1474. """ - import yaml as _yaml + import hermes_yaml as _yaml now = time.monotonic() if now - self._last_config_check < CONFIG_WATCH_INTERVAL: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 85b51c0078..86f656108f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -21,7 +21,7 @@ from decimal import Decimal, InvalidOperation from pathlib import Path from typing import Dict, Any, Optional, List, Tuple, Set -import yaml +import hermes_yaml as yaml from hermes_cli.cli_output import line_input from hermes_cli.colors import Colors, color diff --git a/hermes_cli/doctor_platform.py b/hermes_cli/doctor_platform.py index 1402934f41..fa26301234 100644 --- a/hermes_cli/doctor_platform.py +++ b/hermes_cli/doctor_platform.py @@ -428,7 +428,7 @@ def _check_certificates(should_fix: bool, f: Finding) -> None: # (import name, display name, optional) _PACKAGES = ( ("openai", "OpenAI SDK", False), ("rich", "Rich (terminal UI)", False), ("dotenv", "python-dotenv", False), - ("yaml", "PyYAML", False), ("httpx", "HTTPX", False), + ("ruamel.yaml", "ruamel.yaml", False), ("httpx", "HTTPX", False), ("croniter", "Croniter (cron expressions)", True), ("telegram", "python-telegram-bot", True), ("discord", "discord.py", True), ) diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index 5e1b110ee5..a3d4e86ef3 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -554,7 +554,7 @@ def _load_secrets_config(home_path: Path) -> dict: except Exception: pass try: - import yaml # type: ignore + import hermes_yaml as yaml except ImportError: return {} try: diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 2d4f80047f..e94e05d62d 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -44,7 +44,7 @@ def load_picker_context() -> ConfigContext: cfg = load_config() model_cfg = cfg.get("model", {}) if isinstance(model_cfg, dict): - # PyYAML parses unquoted scalars as int (`provider: 2070`); keep strings so picker/options + # YAML parses unquoted scalars as int (`provider: 2070`); keep strings so picker/options # paths never call `.strip()` on an int. current_model = str(model_cfg.get("default", model_cfg.get("name", "")) or "") current_provider = coerce_provider_id(model_cfg.get("provider", "")) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 739d1c52d8..a3549c213e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -215,12 +215,10 @@ def _config_default_interface_early() -> str: else: cfg_path = os.path.join(os.path.expanduser("~"), ".hermes", "config.yaml") if os.path.exists(cfg_path): - import yaml as _yaml_iface + import hermes_yaml as _yaml_iface 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 {} + raw = _yaml_iface.safe_load(_f) or {} disp = raw.get("display", {}) if isinstance(disp, dict): iface = disp.get("interface") diff --git a/hermes_cli/managed_scope.py b/hermes_cli/managed_scope.py index 34375473cf..8398c67cfb 100644 --- a/hermes_cli/managed_scope.py +++ b/hermes_cli/managed_scope.py @@ -15,7 +15,7 @@ import threading from pathlib import Path from typing import Dict, Optional -import yaml +import hermes_yaml as yaml logger = logging.getLogger(__name__) diff --git a/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py index 247a7200b7..305771b90f 100644 --- a/hermes_cli/mcp_catalog.py +++ b/hermes_cli/mcp_catalog.py @@ -13,7 +13,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional -import yaml +import hermes_yaml as yaml from hermes_constants import get_hermes_home, get_optional_mcps_dir from hermes_cli._subprocess_compat import noninteractive_git_env diff --git a/hermes_cli/model_switch_providers.py b/hermes_cli/model_switch_providers.py index f8d5ee641d..3c2245e6dc 100644 --- a/hermes_cli/model_switch_providers.py +++ b/hermes_cli/model_switch_providers.py @@ -1103,7 +1103,7 @@ def list_authenticated_providers( except Exception: pass - # PyYAML parses unquoted numeric names (`provider: 2070`) as int. + # YAML parses unquoted numeric names (`provider: 2070`) as int. # seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545) current_provider = coerce_provider_id(current_provider) current_base_url = str(current_base_url or "").strip() diff --git a/hermes_cli/plugin_catalog.py b/hermes_cli/plugin_catalog.py index dea2f32ac2..41f41fbeb1 100644 --- a/hermes_cli/plugin_catalog.py +++ b/hermes_cli/plugin_catalog.py @@ -22,7 +22,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional -import yaml +import hermes_yaml as yaml logger = logging.getLogger(__name__) diff --git a/hermes_cli/plugin_packs.py b/hermes_cli/plugin_packs.py index 7430e4a96b..83e366fd0f 100644 --- a/hermes_cli/plugin_packs.py +++ b/hermes_cli/plugin_packs.py @@ -100,7 +100,7 @@ def validate_config_seed(plugin_id: str, seed: Any) -> dict[str, Any]: def parse_pack(text: str, *, source: str = "") -> PluginPack: """Parse and validate a pack YAML document.""" - import yaml + import hermes_yaml as yaml try: raw = yaml.safe_load(text) except yaml.YAMLError as exc: @@ -419,7 +419,7 @@ def _sanitized_entry_config(plugin_id: str) -> dict[str, Any]: def export_pack(*, enabled_only: bool = False, pack_name: str = "my-hermes-pack") -> tuple[str, List[str]]: """Build pack YAML from the current install; returns ``(yaml_text, warnings)``. Plugins with unknown Git provenance (no install metadata) become warnings + YAML comments, never entries.""" - import yaml + import hermes_yaml as yaml from hermes_cli.plugins_cmd import _get_enabled_set, _plugins_dir, _read_install_metadata metadata = _read_install_metadata() enabled = _get_enabled_set() diff --git a/hermes_cli/plugin_validate.py b/hermes_cli/plugin_validate.py index 3555df2e77..e34daae90d 100644 --- a/hermes_cli/plugin_validate.py +++ b/hermes_cli/plugin_validate.py @@ -432,7 +432,7 @@ def validate_plugin_dir(plugin_dir: Path) -> ValidationReport: ) return report - import yaml + import hermes_yaml as yaml try: manifest = yaml.safe_load( diff --git a/hermes_cli/plugins_admission.py b/hermes_cli/plugins_admission.py index c1151499d3..7cdd20484b 100644 --- a/hermes_cli/plugins_admission.py +++ b/hermes_cli/plugins_admission.py @@ -90,7 +90,7 @@ def admit_plugin_set_change( …) or when the config write itself fails. The active environment and the previous config bytes are kept EXACTLY — no rollback re-resolve. """ - from pm.ensure import sync_venv + from pm.client import sync_venv extra_dirs = tuple(extra_dirs) try: diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 01ee11387d..400c1b09ec 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -271,7 +271,7 @@ def _has_portable_manifest(plugin_dir: Path) -> bool: def _load_yaml_manifest(manifest_file: Path): """``yaml.safe_load`` of *manifest_file* (``{}`` when empty); raises on any read/parse error.""" - import yaml + import hermes_yaml as yaml with open(manifest_file, encoding="utf-8-sig") as f: return yaml.safe_load(f) or {} diff --git a/hermes_cli/plugins_manifest.py b/hermes_cli/plugins_manifest.py index 184adfb75e..487d3551df 100644 --- a/hermes_cli/plugins_manifest.py +++ b/hermes_cli/plugins_manifest.py @@ -19,7 +19,7 @@ from utils import fast_safe_load from hermes_cli.plugin_capabilities import parse_declared_capabilities as _parse_declared_capabilities try: - import yaml + import hermes_yaml as yaml except ImportError: # pragma: no cover – yaml is optional at import time yaml = None # type: ignore[assignment] @@ -462,7 +462,7 @@ def parse_manifest_file( """Parse one ``plugin.yaml`` into a :class:`PluginManifest`; ``None`` (warned) on failure.""" try: if yaml is None: - logger.warning("PyYAML not installed – cannot load %s", manifest_file) + logger.warning("ruamel.yaml not installed – cannot load %s", manifest_file) return None data = fast_safe_load(manifest_file.read_text(encoding="utf-8-sig")) or {} name = data.get("name", plugin_dir.name) diff --git a/hermes_cli/plugins_transaction.py b/hermes_cli/plugins_transaction.py index cacdc563b0..7a70ddac16 100644 --- a/hermes_cli/plugins_transaction.py +++ b/hermes_cli/plugins_transaction.py @@ -86,7 +86,7 @@ def publish_plugin(staged: Path, target: Path, old_metadata: dict, new_metadata: from hermes_cli import plugins_cmd from hermes_cli.runtime_state import recover_publication, runtime_lock from pm import paths - from pm.ensure import sync_venv + from pm.client import sync_venv from pm.workspace import _is_member_candidate, enabled_plugin_dirs, member_sources project = paths.repo_root() diff --git a/hermes_cli/plugins_updates.py b/hermes_cli/plugins_updates.py index aeebb570aa..9bdfe84277 100644 --- a/hermes_cli/plugins_updates.py +++ b/hermes_cli/plugins_updates.py @@ -70,7 +70,7 @@ class CheckResult: def _read_manifest_field(plugin_dir: Path, key: str) -> Optional[str]: """One field from the installed plugin.yaml (claims, not provenance).""" - import yaml + import hermes_yaml as yaml manifest = plugin_dir / "plugin.yaml" if not manifest.is_file(): @@ -232,7 +232,7 @@ def check_provenanced( def parse_feed_yml(text: str) -> dict: """The electron-updater-derived feed shape: version, released, min_hermes, artifacts{git,bundle,bundle_sha256}, notes_url.""" - import yaml + import hermes_yaml as yaml try: data = yaml.safe_load(text) diff --git a/hermes_cli/profile_distribution.py b/hermes_cli/profile_distribution.py index bb9799d161..78e7654187 100644 --- a/hermes_cli/profile_distribution.py +++ b/hermes_cli/profile_distribution.py @@ -17,7 +17,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -import yaml +import hermes_yaml as yaml from hermes_cli._subprocess_compat import noninteractive_git_env from hermes_cli.archive_safe import normalize_archive_parts diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 04f91106a5..a74121f0e5 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -523,7 +523,7 @@ def _load_yaml_dict(path: Path) -> Optional[dict]: if not path.is_file(): return None try: - import yaml + import hermes_yaml as yaml data = yaml.safe_load(path.read_text(encoding="utf-8-sig")) or {} except Exception: return None @@ -563,7 +563,7 @@ def _seed_model_config(profile_dir: Path) -> None: if config_path.exists(): return with contextlib.suppress(Exception): # creation must not fail over this; `hermes model` sets it later - import yaml + import hermes_yaml as yaml from hermes_constants import get_hermes_home from hermes_cli.config import read_user_config_raw source = get_hermes_home() / "config.yaml" diff --git a/hermes_cli/runtime_repair.py b/hermes_cli/runtime_repair.py index 1ac3081358..c095fd542f 100644 --- a/hermes_cli/runtime_repair.py +++ b/hermes_cli/runtime_repair.py @@ -1018,7 +1018,7 @@ def repair_vulnerable_runtime( if not (root / "pyproject.toml").is_file() or not live_python.is_file(): return RuntimeRepairResult("not-applicable") - from pm.ensure import uv as pm_uv + from pm.client import uv as pm_uv uv_bin, _uv_env = pm_uv() if not uv_bin: diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 0306cb99c7..a42ca6ee57 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -1106,7 +1106,7 @@ def do_tap(action: str, repo: str = "", console: Optional[Console] = None) -> No def _read_frontmatter(skill_md: str) -> dict: """YAML frontmatter of a SKILL.md body ({} when absent/invalid).""" - import yaml + import hermes_yaml as yaml match = re.search(r'\n---\s*\n', skill_md[3:]) if skill_md.startswith("---") else None try: return (yaml.safe_load(skill_md[3:match.start() + 3]) or {}) if match else {} diff --git a/hermes_cli/skin_cmd.py b/hermes_cli/skin_cmd.py index f7eb2abe3e..6f098a66e2 100644 --- a/hermes_cli/skin_cmd.py +++ b/hermes_cli/skin_cmd.py @@ -34,7 +34,7 @@ def _use(name: str) -> None: def _skin_set(key: str, value: str, skin: str | None) -> int: - import yaml + import hermes_yaml as yaml if not _HEX_RE.match(value): print(f"✗ {value!r} is not a #rrggbb hex color", file=sys.stderr) return 1 diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index f93cd951d7..5150c69eeb 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -351,7 +351,7 @@ def _skins_dir() -> Path: def _load_skin_from_yaml(path: Path) -> Optional[Dict[str, Any]]: """Load a skin definition from a YAML file; None on any failure.""" try: - import yaml + import hermes_yaml as yaml with open(path, "r", encoding="utf-8-sig") as f: data = yaml.safe_load(f) if isinstance(data, dict) and "name" in data: diff --git a/hermes_cli/tools_config_cua.py b/hermes_cli/tools_config_cua.py index f7f28e3a5e..9ec6f6a807 100644 --- a/hermes_cli/tools_config_cua.py +++ b/hermes_cli/tools_config_cua.py @@ -155,7 +155,7 @@ def _pip_install(args: List[str], *, timeout: int = 300, capture_output: bool = install_flags = _post_setup_no_window_flags(streams_to_console=not capture_output) # Resolve uv and its target environment through PM, not ambient PATH. - from pm.ensure import uv as pm_uv + from pm.client import uv as pm_uv from pm.package import InstallError try: diff --git a/hermes_cli/update_cmd_deps.py b/hermes_cli/update_cmd_deps.py index 42089fb1f2..b1d67966a1 100644 --- a/hermes_cli/update_cmd_deps.py +++ b/hermes_cli/update_cmd_deps.py @@ -176,7 +176,7 @@ def _refresh_active_lazy_features(features: list[str] | None = None) -> bool: the locked versions of everything enabled. Never raises. """ try: - from pm.ensure import sync_venv + from pm.client import sync_venv sync_venv(features, explicit=True) return True diff --git a/hermes_cli/venv_sync.py b/hermes_cli/venv_sync.py index dc742f6097..5b119fee16 100644 --- a/hermes_cli/venv_sync.py +++ b/hermes_cli/venv_sync.py @@ -139,7 +139,7 @@ def _managed_uv() -> tuple: keeps this module's bare-import surface stdlib-pure. """ try: - from pm.ensure import uv as pm_uv + from pm.client import uv as pm_uv uv_bin, env = pm_uv(realize=True) except Exception: diff --git a/hermes_cli/web_routers/analytics.py b/hermes_cli/web_routers/analytics.py index 81f82576f4..4906d039dc 100644 --- a/hermes_cli/web_routers/analytics.py +++ b/hermes_cli/web_routers/analytics.py @@ -8,7 +8,7 @@ import asyncio import time from typing import Any, Dict, List, Optional -import yaml +import hermes_yaml as yaml from fastapi import APIRouter, HTTPException, Query from hermes_cli.config import get_config_path, read_raw_config diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 1375ac8cc7..e7e7c84a9e 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1494,7 +1494,7 @@ import shutil # noqa: F401,E402 import stat # noqa: F401,E402 import tempfile # noqa: F401,E402 from datetime import timezone # noqa: F401,E402 -import yaml # noqa: F401,E402 +import hermes_yaml as yaml # noqa: F401,E402 import zipfile # noqa: F401,E402 diff --git a/hermes_cli/web_server_dashboard.py b/hermes_cli/web_server_dashboard.py index 23d3627d2f..cfd7d61571 100644 --- a/hermes_cli/web_server_dashboard.py +++ b/hermes_cli/web_server_dashboard.py @@ -8,7 +8,7 @@ import os import sys import threading import time -import yaml +import hermes_yaml as yaml from fastapi import FastAPI, Request from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles diff --git a/hermes_cli/web_server_memory.py b/hermes_cli/web_server_memory.py index 9cd4afaf45..7f5ec6213b 100644 --- a/hermes_cli/web_server_memory.py +++ b/hermes_cli/web_server_memory.py @@ -7,7 +7,7 @@ import os import re import shlex import subprocess -import yaml +import hermes_yaml as yaml from fastapi import HTTPException from pathlib import Path from typing import Any, Dict, List, Optional diff --git a/hermes_time.py b/hermes_time.py index 26bcea13de..96b0a00459 100644 --- a/hermes_time.py +++ b/hermes_time.py @@ -43,7 +43,7 @@ def _resolve_timezone_name() -> str: from hermes_cli.config import read_raw_config cfg = read_raw_config() or {} except Exception: - import yaml + import hermes_yaml as yaml config_path = get_config_path() cfg = (yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}) if config_path.exists() else {} if cfg: diff --git a/hermes_wisdom/agent_led/share_flow.py b/hermes_wisdom/agent_led/share_flow.py index bb3b058027..0d8bca5aa0 100644 --- a/hermes_wisdom/agent_led/share_flow.py +++ b/hermes_wisdom/agent_led/share_flow.py @@ -372,7 +372,7 @@ class ShareFlow: def normalize_generated_package(package: SharePackage) -> SharePackage: """Materialize the generated metadata as part of the reviewable package.""" - import yaml + import hermes_yaml as yaml from .setup_document import SETUP_PATH, render_setup_document files = [] diff --git a/hermes_yaml.py b/hermes_yaml.py new file mode 100644 index 0000000000..291728aef2 --- /dev/null +++ b/hermes_yaml.py @@ -0,0 +1,77 @@ +"""Shared YAML 1.1 policy for config, manifests, and frontmatter. + +Ruamel's native schema includes bare y/n booleans and rejects duplicate keys. +Every operation owns its parser/emitter; instances must not be shared by threads. +""" + +from io import StringIO +from typing import Any, IO, overload + +from ruamel.yaml import YAML +from ruamel.yaml.error import YAMLError as YAMLError +from ruamel.yaml.resolver import VersionedResolver + + +class _Yaml11Resolver(VersionedResolver): + # Quote strings like "off" without adding a %YAML directive to every config/snippet. + @property + def processing_version(self) -> tuple[int, int]: + return (1, 1) + + +def safe_load(stream: str | bytes | IO[str] | IO[bytes]) -> Any: + """Read standard YAML data; existing configs use YAML 1.1 booleans.""" + yaml = YAML(typ="safe") + yaml.version = (1, 1) + return yaml.load(stream) + + +@overload +def safe_dump( + data: Any, stream: None = None, *, default_flow_style: bool = False, + sort_keys: bool = True, allow_unicode: bool = True, width: int = 80, +) -> str: ... + + +@overload +def safe_dump( + data: Any, stream: IO[str], *, default_flow_style: bool = False, + sort_keys: bool = True, allow_unicode: bool = True, width: int = 80, +) -> None: ... + + +def safe_dump( + data: Any, + stream: IO[str] | None = None, + *, + default_flow_style: bool = False, + sort_keys: bool = True, + allow_unicode: bool = True, + width: int = 80, +) -> str | None: + """Write standard YAML data with readable Unicode and indented block lists.""" + # The C emitter ignores sequence offsets and escapes astral Unicode. + yaml = YAML(typ="safe", pure=True) + yaml.Resolver = _Yaml11Resolver + yaml.default_flow_style = default_flow_style + yaml.allow_unicode = allow_unicode + yaml.width = width + yaml.sort_base_mapping_type_on_output = sort_keys + yaml.indent(mapping=2, sequence=4, offset=2) + if stream is not None: + yaml.dump(data, stream) + return None + output = StringIO() + yaml.dump(data, output) + return output.getvalue() + + +def roundtrip_yaml() -> YAML: + """Create a fresh comment/quote-preserving editor for user-authored YAML.""" + yaml = YAML(typ="rt") + yaml.Resolver = _Yaml11Resolver + yaml.preserve_quotes = True + yaml.allow_unicode = True + yaml.default_flow_style = False + yaml.indent(mapping=2, sequence=4, offset=2) + return yaml diff --git a/nix/configMergeScript.nix b/nix/configMergeScript.nix index bea2d61611..5f6a741c07 100644 --- a/nix/configMergeScript.nix +++ b/nix/configMergeScript.nix @@ -4,9 +4,16 @@ # Nix keys override; user-added keys (skills, streaming, etc.) are preserved. { pkgs }: pkgs.writeScript "hermes-config-merge" '' - #!${pkgs.python3.withPackages (ps: [ ps.pyyaml ])}/bin/python3 - import json, yaml, sys + #!${pkgs.python3.withPackages (ps: [ ps.ruamel-yaml ])}/bin/python3 + import json, sys from pathlib import Path + from ruamel.yaml import YAML + + yaml = YAML(typ="safe", pure=True) + # Existing configs use YAML 1.1 booleans such as yes and off. + yaml.version = (1, 1) + yaml.default_flow_style = False + yaml.sort_base_mapping_type_on_output = False nix_json, config_path = sys.argv[1], Path(sys.argv[2]) @@ -16,7 +23,7 @@ pkgs.writeScript "hermes-config-merge" '' existing = {} if config_path.exists(): with open(config_path) as f: - existing = yaml.safe_load(f) or {} + existing = yaml.load(f) or {} def deep_merge(base, override): result = dict(base) @@ -29,5 +36,5 @@ pkgs.writeScript "hermes-config-merge" '' merged = deep_merge(existing, nix) with open(config_path, "w") as f: - yaml.dump(merged, f, default_flow_style=False, sort_keys=False) + yaml.dump(merged, f) '' diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix index a406d56733..032af00240 100644 --- a/nix/hermes-agent.nix +++ b/nix/hermes-agent.nix @@ -8,6 +8,7 @@ lib, stdenv, makeWrapper, + writeText, callPackage, electron, ripgrep, @@ -72,7 +73,7 @@ let version; # CLI and Electron consume the same provenance and update owner. - installStampFile = builtins.toFile "hermes-install-stamp.json" (builtins.toJSON { + installStampFile = writeText "hermes-install-stamp.json" (builtins.toJSON { schemaVersion = 2; commit = rev; commitDate = lastModified; @@ -83,6 +84,7 @@ let distance = stampDistance; source = "nix"; distribution = "nix"; + pmRuntime = toString pmRuntime; updateMechanism = "external"; payload = "bootstrap"; tag = null; @@ -98,6 +100,10 @@ let hermesVenv = (mkHermesVenv extraDependencyGroups).venv; + pmRuntime = callPackage ./pm-runtime.nix { + inherit uv2nix pyproject-nix pyproject-build-systems; + }; + generatedIcons = callPackage ./icons.nix { inherit (mkHermesVenv [ ]) iconBuildVenv; }; @@ -293,6 +299,8 @@ stdenv.mkDerivation (finalAttrs: { hermesWeb hermesNpmLib hermesVenv + installStampFile + pmRuntime python ; diff --git a/nix/pm-runtime.nix b/nix/pm-runtime.nix new file mode 100644 index 0000000000..362ba1c0e7 --- /dev/null +++ b/nix/pm-runtime.nix @@ -0,0 +1,28 @@ +# PM's dependency graph must remain independent of the application closure. +{ + lib, + callPackage, + uv2nix, + pyproject-nix, + pyproject-build-systems, +}: +let + python = (callPackage ./pythonLock.nix { }).interpreter; + workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ../pm; }; + pythonSet = (callPackage pyproject-nix.build.packages { inherit python; }).overrideScope ( + lib.composeManyExtensions [ + pyproject-build-systems.overlays.default + (workspace.mkPyprojectOverlay { sourcePreference = "wheel"; }) + ] + ); + environment = pythonSet.mkVirtualEnv "hermes-pm-runtime" workspace.deps.default; +in +# Nix owns this environment; no runtime download or uv resolution is needed. +environment.overrideAttrs (old: { + postInstall = (old.postInstall or "") + '' + printf '%s\n' '${builtins.toJSON { + python = "${python}/bin/python3"; + sitePackages = python.sitePackages; + }}' > "$out/pm-runtime.json" + ''; +}) diff --git a/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl b/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl index c6a95848c6..dcf375b529 100644 --- a/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl +++ b/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl @@ -66,7 +66,7 @@ configure_profile() { local toolsets_json="$2" # JSON array string, e.g. '["kanban","terminal","file"]' local skills_json="$3" # JSON array string, e.g. '["ascii-video"]' python3 - "$profile" "$toolsets_json" "$skills_json" "$WORKSPACE" <<'PY' -"""Patch a Hermes profile config.yaml using PyYAML so we don't depend on the +"""Patch a Hermes profile config.yaml using ruamel.yaml so we don't depend on the exact default-config string format. Validates the patch took effect and exits non-zero if anything's off.""" import json @@ -74,11 +74,16 @@ import os import sys try: - import yaml + from ruamel.yaml import YAML except ImportError: - print("ERROR: PyYAML required. pip install pyyaml", file=sys.stderr) + print("ERROR: ruamel.yaml required. pip install ruamel.yaml==0.18.17", file=sys.stderr) sys.exit(1) +yaml = YAML(typ="safe", pure=True) +yaml.version = (1, 1) +yaml.default_flow_style = False +yaml.sort_base_mapping_type_on_output = False + profile, toolsets_json, skills_json, workspace = sys.argv[1:5] toolsets = json.loads(toolsets_json) skills = json.loads(skills_json) @@ -89,7 +94,7 @@ if not os.path.exists(p): sys.exit(1) with open(p) as f: - cfg = yaml.safe_load(f) or {} + cfg = yaml.load(f) or {} # Apply our changes — only the keys we actually want to set. cfg["toolsets"] = toolsets @@ -102,11 +107,11 @@ cfg["skills"]["always_load"] = skills # don't need to mutate cfg["terminal"]["cwd"] either. with open(p, "w") as f: - yaml.safe_dump(cfg, f, sort_keys=False) + yaml.dump(cfg, f) # Validate with open(p) as f: - after = yaml.safe_load(f) + after = yaml.load(f) errors = [] if after.get("toolsets") != toolsets: errors.append(f"toolsets mismatch: {after.get('toolsets')!r}") diff --git a/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md b/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md index 0a85164e07..822b4e67da 100644 --- a/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md +++ b/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md @@ -13,7 +13,7 @@ JSON. > are adapted from alt-glitch's original multi-agent video pipeline: > [NousResearch/kanban-video-pipeline](https://github.com/NousResearch/kanban-video-pipeline). > This skill generalizes those patterns across video styles and replaces the -> string-replacement config patcher with a PyYAML-based one. +> string-replacement config patcher with a ruamel.yaml-based one. ## Project workspace structure @@ -94,7 +94,7 @@ modify `terminal.cwd` — the kanban dispatcher overrides cwd per-task via `--workspace dir:`, so the profile's cwd is irrelevant to the kanban work and changing it could break the user's interactive use of the profile. -Use **PyYAML**, not string replacement, so the patch is robust against +Use **ruamel.yaml**, not string replacement, so the patch is robust against default-config schema drift: ```bash @@ -103,21 +103,26 @@ configure_profile() { local toolsets_json="$2" # JSON array, e.g. '["kanban","terminal","file"]' local skills_json="$3" # JSON array, e.g. '["ascii-video"]' python3 - "$profile" "$toolsets_json" "$skills_json" <<'PY' -import json, os, sys, yaml +import json, os, sys +from ruamel.yaml import YAML +yaml = YAML(typ="safe", pure=True) +yaml.version = (1, 1) +yaml.default_flow_style = False +yaml.sort_base_mapping_type_on_output = False profile, ts_json, sk_json = sys.argv[1:4] p = os.path.expanduser(f"~/.hermes/profiles/{profile}/config.yaml") with open(p) as f: - cfg = yaml.safe_load(f) or {} + cfg = yaml.load(f) or {} cfg["toolsets"] = json.loads(ts_json) cfg.setdefault("skills", {})["always_load"] = json.loads(sk_json) with open(p, "w") as f: - yaml.safe_dump(cfg, f, sort_keys=False) + yaml.dump(cfg, f) PY } ``` -PyYAML must be installed in the user's Python (it ships with most Hermes -installs). If absent: `pip install pyyaml`. +ruamel.yaml must be installed in the user's Python (it ships with Hermes). +If absent: `pip install ruamel.yaml==0.18.17`. The setup script should also **validate** the patch by re-reading the file and comparing — see `assets/setup.sh.tmpl` for the validation pattern. diff --git a/optional-skills/creative/touchdesigner-mcp/scripts/setup.sh b/optional-skills/creative/touchdesigner-mcp/scripts/setup.sh index 15dc662c1c..9f96e05185 100644 --- a/optional-skills/creative/touchdesigner-mcp/scripts/setup.sh +++ b/optional-skills/creative/touchdesigner-mcp/scripts/setup.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # setup.sh — Automated setup for twozero MCP plugin for TouchDesigner # Idempotent: safe to run multiple times. +# Config editing requires ruamel.yaml in python3: pip install ruamel.yaml==0.18.17 set -euo pipefail GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' @@ -52,11 +53,16 @@ elif grep -q 'twozero_td' "$HERMES_CFG" 2>/dev/null; then else echo -e " ${WARN} Adding twozero_td MCP entry to Hermes config..." python3 -c " -import yaml, sys, copy +from ruamel.yaml import YAML + +yaml = YAML(typ='safe', pure=True) +yaml.version = (1, 1) +yaml.default_flow_style = False +yaml.sort_base_mapping_type_on_output = False cfg_path = '$HERMES_CFG' with open(cfg_path, 'r') as f: - cfg = yaml.safe_load(f) or {} + cfg = yaml.load(f) or {} if 'mcp_servers' not in cfg: cfg['mcp_servers'] = {} @@ -68,9 +74,9 @@ if 'twozero_td' not in cfg['mcp_servers']: 'connect_timeout': 60 } with open(cfg_path, 'w') as f: - yaml.dump(cfg, f, default_flow_style=False, sort_keys=False) + yaml.dump(cfg, f) " 2>/dev/null && echo -e " ${OK} twozero_td MCP entry added to config" \ - || { echo -e " ${FAIL} Could not update config (is PyYAML installed?)"; \ + || { echo -e " ${FAIL} Could not update config (is ruamel.yaml installed?)"; \ manual_steps+=("Add twozero_td MCP entry to ${HERMES_CFG} manually"); } manual_steps+=("Restart Hermes session to pick up config change") fi diff --git a/optional-skills/migration/openclaw-migration/SKILL.md b/optional-skills/migration/openclaw-migration/SKILL.md index 500a49a50b..d9e69ac339 100644 --- a/optional-skills/migration/openclaw-migration/SKILL.md +++ b/optional-skills/migration/openclaw-migration/SKILL.md @@ -226,6 +226,9 @@ The helper script still supports category-level `--include` / `--exclude`, but t ## Commands +Run the helper with Hermes' Python environment, which includes `ruamel.yaml`. +For a standalone Python environment, install `ruamel.yaml==0.18.17` first. + Dry run with full discovery: ```bash diff --git a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py index bad94b186b..2c900c09aa 100644 --- a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +++ b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py @@ -22,7 +22,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Sequence, Tuple try: - import yaml + from ruamel import yaml except Exception: # pragma: no cover - handled at runtime yaml = None @@ -371,7 +371,7 @@ def load_yaml_file(path: Path) -> Dict[str, Any]: :class:`ConfigReadError` so the caller refuses and leaves the file byte-identical. - ``yaml is None`` (PyYAML not installed) still yields ``{}``: nothing can be + ``yaml is None`` (ruamel.yaml not installed) still yields ``{}``: nothing can be written in that state either, since :func:`dump_yaml_file` raises. """ if yaml is None or not path.exists(): @@ -386,7 +386,9 @@ def load_yaml_file(path: Path) -> Dict[str, Any]: f"({exc}). Fix the file permissions or move it aside first." ) from exc try: - data = yaml.safe_load(raw) + reader = yaml.YAML(typ="safe") + reader.version = (1, 1) # Match Hermes' existing config scalar semantics. + data = reader.load(raw) except yaml.YAMLError as exc: raise ConfigReadError( f"Refusing to overwrite {path}: the existing file is not valid YAML " @@ -421,7 +423,7 @@ def dump_yaml_file(path: Path, data: Dict[str, Any]) -> None: ``~/.hermes/config.yaml`` into a dotfiles repo or profile package. """ if yaml is None: - raise RuntimeError("PyYAML is required to update Hermes config.yaml") + raise RuntimeError("ruamel.yaml is required to update Hermes config.yaml") ensure_parent(path) target = os.path.realpath(str(path)) if os.path.islink(str(path)) else str(path) fd, tmp_path = tempfile.mkstemp( @@ -429,7 +431,13 @@ def dump_yaml_file(path: Path, data: Dict[str, Any]) -> None: ) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: - handle.write(yaml.safe_dump(data, sort_keys=False, allow_unicode=False)) + # The C emitter leaves YAML 1.1 boolean-like strings unquoted. + writer = yaml.YAML(typ="safe", pure=True) + writer.version = (1, 1) + writer.default_flow_style = False + writer.sort_base_mapping_type_on_output = False + writer.allow_unicode = False + writer.dump(data, handle) handle.flush() os.fsync(handle.fileno()) try: @@ -1341,7 +1349,7 @@ class Migrator: self.record("command-allowlist", None, destination, "skipped", "No OpenClaw exec approvals file found") return if yaml is None: - self.record("command-allowlist", source, destination, "error", "PyYAML is not available") + self.record("command-allowlist", source, destination, "error", "ruamel.yaml is not available") return try: @@ -1839,7 +1847,7 @@ class Migrator: break if yaml is None: - self.record("model-config", source_path, destination, "error", "PyYAML is not available") + self.record("model-config", source_path, destination, "error", "ruamel.yaml is not available") return hermes_config = load_yaml_file(destination) @@ -1874,7 +1882,7 @@ class Migrator: return if yaml is None: - self.record("tts-config", source_path, destination, "error", "PyYAML is not available") + self.record("tts-config", source_path, destination, "error", "ruamel.yaml is not available") return tts_data: Dict[str, Any] = {} diff --git a/optional-skills/productivity/telephony/scripts/telephony.py b/optional-skills/productivity/telephony/scripts/telephony.py index 291fd8629a..c445ec6b84 100644 --- a/optional-skills/productivity/telephony/scripts/telephony.py +++ b/optional-skills/productivity/telephony/scripts/telephony.py @@ -11,7 +11,8 @@ Capabilities: - Make outbound AI voice calls via Bland.ai or Vapi This file intentionally uses Python stdlib HTTP clients so the skill can run in a -minimal environment with no extra pip installs. +minimal environment with no extra pip installs. Reading Hermes config.yaml is +optional and requires ruamel.yaml (standalone install: ruamel.yaml==0.18.17). """ from __future__ import annotations @@ -89,12 +90,14 @@ def _load_root_config() -> dict[str, Any]: if not path.exists(): return {} try: - import yaml # optional dependency; Hermes already ships PyYAML + from ruamel.yaml import YAML # optional dependency; Hermes ships ruamel.yaml except Exception: return {} try: + reader = YAML(typ="safe") + reader.version = (1, 1) with path.open("r", encoding="utf-8") as handle: - data = yaml.safe_load(handle) or {} + data = reader.load(handle) or {} return data if isinstance(data, dict) else {} except Exception: return {} diff --git a/optional-skills/research/research-paper-writing/references/experiment-patterns.md b/optional-skills/research/research-paper-writing/references/experiment-patterns.md index f9fb243fe5..0bda821e6d 100644 --- a/optional-skills/research/research-paper-writing/references/experiment-patterns.md +++ b/optional-skills/research/research-paper-writing/references/experiment-patterns.md @@ -82,7 +82,7 @@ def save_pass_artifacts(output_dir, pass_num, artifacts): **3. Configuration Management** -Use YAML configs for reproducibility: +Use YAML configs for reproducibility (install `ruamel.yaml==0.18.17` in the experiment environment): ```yaml # config.yaml @@ -96,10 +96,10 @@ convergence_k: 2 ``` ```python -import yaml +from ruamel.yaml import YAML with open("config.yaml") as f: - config = yaml.safe_load(f) + config = YAML(typ="safe").load(f) ``` **4. Separation of Concerns** diff --git a/optional-skills/security/godmode/SKILL.md b/optional-skills/security/godmode/SKILL.md index e7d221465b..c2fffbb41c 100644 --- a/optional-skills/security/godmode/SKILL.md +++ b/optional-skills/security/godmode/SKILL.md @@ -55,6 +55,9 @@ See `scripts/godmode_race.py` for the implementation. ## Step 0: Auto-Jailbreak (Recommended) +The helper requires `ruamel.yaml` (included with Hermes). In a standalone +Python environment, install `ruamel.yaml==0.18.17` before loading it. + The fastest path — auto-detect the model, test strategies, and lock in the winner: ```python diff --git a/optional-skills/security/godmode/scripts/auto_jailbreak.py b/optional-skills/security/godmode/scripts/auto_jailbreak.py index 5c7055a99b..bb0a96affc 100644 --- a/optional-skills/security/godmode/scripts/auto_jailbreak.py +++ b/optional-skills/security/godmode/scripts/auto_jailbreak.py @@ -18,9 +18,16 @@ Usage in execute_code: import os import json import time -import yaml +from ruamel.yaml import YAML from pathlib import Path +yaml = YAML(typ="safe", pure=True) +yaml.version = (1, 1) +yaml.default_flow_style = False +yaml.allow_unicode = True +yaml.width = 120 +yaml.sort_base_mapping_type_on_output = False + try: from openai import OpenAI except ImportError: @@ -325,7 +332,7 @@ def _get_current_model() -> tuple: return None, None try: with open(CONFIG_PATH) as f: - cfg = yaml.safe_load(f) or {} + cfg = yaml.load(f) or {} model_cfg = cfg.get("model", {}) if isinstance(model_cfg, str): return model_cfg, "https://openrouter.ai/api/v1" @@ -386,7 +393,7 @@ def _write_config(system_prompt: str = None, prefill_file: str = None): if CONFIG_PATH.exists(): try: with open(CONFIG_PATH) as f: - cfg = yaml.safe_load(f) or {} + cfg = yaml.load(f) or {} except Exception: cfg = {} @@ -401,8 +408,7 @@ def _write_config(system_prompt: str = None, prefill_file: str = None): cfg["agent"].pop("prefill_messages_file", None) with open(CONFIG_PATH, "w") as f: - yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True, - width=120, sort_keys=False) + yaml.dump(cfg, f) return str(CONFIG_PATH) @@ -718,14 +724,13 @@ def undo_jailbreak(verbose=True): if CONFIG_PATH.exists(): try: with open(CONFIG_PATH) as f: - cfg = yaml.safe_load(f) or {} + cfg = yaml.load(f) or {} if "agent" in cfg: cfg["agent"].pop("system_prompt", None) cfg["agent"].pop("prefill_messages_file", None) cfg.pop("prefill_messages_file", None) with open(CONFIG_PATH, "w") as f: - yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True, - width=120, sort_keys=False) + yaml.dump(cfg, f) if verbose: print(f"[UNDO] Cleared system_prompt and prefill_messages_file from {CONFIG_PATH}") except Exception as e: diff --git a/plugins/memory/hindsight/embedded_runtime.py b/plugins/memory/hindsight/embedded_runtime.py index 2808cdec32..0d7c162bce 100644 --- a/plugins/memory/hindsight/embedded_runtime.py +++ b/plugins/memory/hindsight/embedded_runtime.py @@ -128,7 +128,7 @@ def _generation_current(generation: Path) -> bool: def _uv_bridge(venv: Path) -> tuple[str, dict[str, str]]: """The sanctioned pm bridge: pinned uv binary + sanitized env for *venv*.""" - from pm.ensure import uv as pm_uv + from pm.client import uv as pm_uv uv_bin, env = pm_uv(venv=venv) if not uv_bin: diff --git a/plugins/memory/holographic/__init__.py b/plugins/memory/holographic/__init__.py index 8ff1d0ce9a..62d11cc899 100644 --- a/plugins/memory/holographic/__init__.py +++ b/plugins/memory/holographic/__init__.py @@ -115,12 +115,12 @@ class HolographicMemoryProvider(MemoryProvider): """Write config to config.yaml under plugins.hermes-memory-store.""" config_path = Path(hermes_home) / "config.yaml" try: - import yaml + import hermes_yaml as yaml from hermes_cli.config import read_user_config_raw # raw read: merged defaults must not be persisted existing = read_user_config_raw(config_path) existing.setdefault("plugins", {})["hermes-memory-store"] = values with open(config_path, "w", encoding="utf-8") as f: - yaml.dump(existing, f, default_flow_style=False) + yaml.safe_dump(existing, f, default_flow_style=False) except Exception: pass diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 279b277c9d..a68e6fa339 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -46,7 +46,7 @@ def iter_plugin_dirs(root: Path) -> List[Path]: def read_plugin_description(plugin_dir: Path) -> str: """Return ``description`` from ``plugin.yaml`` (empty string if absent/unreadable).""" try: - import yaml + import hermes_yaml as yaml with open(plugin_dir / "plugin.yaml", encoding="utf-8-sig") as f: meta = yaml.safe_load(f) or {} return meta.get("description", "") diff --git a/pm/__init__.py b/pm/__init__.py index d05aca8660..76630203f3 100644 --- a/pm/__init__.py +++ b/pm/__init__.py @@ -16,14 +16,12 @@ from pm.ensure import ( adopt, check, enabled_extras, - ensure, env_for, is_installed, installed_package, lazy_installs_allowed, - sync_venv, - uv, ) +from pm.client import ensure, 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 diff --git a/pm/cli.py b/pm/cli.py index 08da8a3654..68bcc79acc 100644 --- a/pm/cli.py +++ b/pm/cli.py @@ -502,7 +502,15 @@ def main(argv=None) -> int: p.set_defaults(func=cmd_update) args = parser.parse_args(argv) - return args.func(args) + from pm.runtime import is_runtime, run_cli + + try: + if not is_runtime(): + return run_cli(list(sys.argv[1:] if argv is None else argv)) + return args.func(args) + except InstallError as exc: + print(f"✗ {exc}", file=sys.stderr) + return 1 if __name__ == "__main__": diff --git a/pm/client.py b/pm/client.py new file mode 100644 index 0000000000..07ff403ac2 --- /dev/null +++ b/pm/client.py @@ -0,0 +1,224 @@ +"""Synchronous PM mutations in an isolated interpreter, never the app's imports.""" +from __future__ import annotations + +from collections.abc import Mapping +import json +import os +from pathlib import Path +import subprocess +import threading +import uuid + +from pm import paths +from pm.package import InstallError, Runner, StatePackage +from pm.runtime import is_runtime, runtime_command, runtime_environment + + +def _members(value): + if value is None: + return None + if isinstance(value, Mapping): + return {"sources": [[str(Path(key).absolute()), str(Path(source).absolute())] + for key, source in value.items()]} + return {"paths": [str(Path(path).absolute()) for path in value]} + + +def _missing_or_refuse(name): + from pm.ensure import _refuse_lazy, is_installed, lazy_installs_allowed + from pm.registry import walk + + missing = [package.name for package in walk([name]) if not is_installed(package.name)] + if missing and not lazy_installs_allowed(): + raise _refuse_lazy(name, ", ".join(missing)) + return missing + + +def _request(operation, arguments, *, callbacks=None, pause_event=None): + from pm import receipt + from pm.ensure import lazy_installs_allowed + from pm.registry import get_package, package_definitions + + request_id = uuid.uuid4().hex + update_id = receipt._ambient_update_id() + callbacks = callbacks or {} + names = ([arguments["name"]] if operation in ("ensure", "stage_only") else + {"uv": ["uv"], "sync_venv": ["venv"]}.get(operation, [])) + message = { + "id": request_id, "operation": operation, "arguments": arguments, + "update_id": update_id, + "callbacks": list(callbacks), + "packages": package_definitions(names), + "context": {"repo": str(paths.repo_root()), "lockfile": str(paths.lockfile_path())}, + } + worker = Path(__file__).with_name("worker.py").resolve() + environment = runtime_environment() + state_sync = operation == "sync_venv" or ( + operation == "ensure" and isinstance(get_package(arguments["name"]), StatePackage)) + if (state_sync and not arguments.get("explicit") and not arguments.get("repair") + and not lazy_installs_allowed()): + # A ready PM still decides no-op/refusal under its install lock. A cold + # PM is itself a missing prerequisite, not permission to bootstrap tools. + try: + command = runtime_command(worker, bootstrap=False) + except InstallError as exc: + token = receipt.begin("sync") + try: + receipt.record_refusal("lazy-install", str(exc)) + receipt.record_step("dependency-sync", False, f"{type(exc).__name__}: {exc}") + finally: + receipt.finalize("failed", 1, token=token) + raise + environment["HERMES_DISABLE_LAZY_INSTALLS"] = "1" + else: + command = runtime_command(worker) + callback_error = None + stopped = threading.Event() + write_lock = threading.Lock() + monitor = None + with subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + text=True, encoding="utf-8", env=environment) as process: + assert process.stdin is not None and process.stdout is not None + writer = process.stdin + + def send(data): + with write_lock: + cancelled = pause_event is not None and pause_event.is_set() + writer.write(json.dumps({"id": request_id, "cancel": cancelled, **data}) + "\n") + writer.flush() + + def watch_pause(): + assert pause_event is not None + while not stopped.wait(0.05): + if pause_event.is_set(): + try: + send({"type": "cancel"}) + except (OSError, ValueError): + return # The final response may already be on its way. + return + + try: + send(message) + if pause_event is not None: + monitor = threading.Thread(target=watch_pause, daemon=True) + monitor.start() + while True: + line = process.stdout.readline() + if not line: + raise InstallError("pm", "worker exited without a result", "check the worker diagnostics on stderr") + response = json.loads(line) + if response["id"] != request_id: + raise InstallError("pm", "worker returned a different request id") + if response["type"] == "result": + break + name = response["callback"] + try: + value = callbacks[name](*response.get("args", [])) + if name not in ("plugin_dirs", "before_publish"): + value = None + send({"type": "callback_result", "call": response["call"], "result": value}) + except BaseException as exc: + if callback_error is None: + callback_error = exc + send({"type": "callback_result", "call": response["call"], + "error": f"{type(exc).__name__}: {exc}"}) + stopped.set() + if monitor is not None: + monitor.join() + writer.close() + if process.wait(timeout=5): + raise InstallError("pm", "worker exited unsuccessfully") + receipt.accept_worker_receipt(response.get("receipt"), update_id) + if callback_error is not None: + raise callback_error + if "error" in response: + error = response["error"] + if "package" in error: + from pm.workspace import ResolutionConflict + kind = ResolutionConflict if error["type"] == "ResolutionConflict" else InstallError + raise kind(error["package"], error["cause"], error["remedy"]) + if error["type"] == "DownloadPaused": + from pm.downloader import DownloadPaused + raise DownloadPaused(error["message"]) + kind = {"ValueError": ValueError, "TypeError": TypeError, + "KeyError": KeyError, "OSError": OSError}.get(error["type"], RuntimeError) + raise kind(error["message"]) + return response["result"] + finally: + stopped.set() + if monitor is not None: + monitor.join() + if process.poll() is None: + process.terminate() + process.wait(timeout=5) + + +def ensure(name, *, base_env=None, explicit=False, progress=None, pause_event=None, download_progress=None) -> Runner: + from pm.ensure import env_for + from pm.registry import get_package + + if is_runtime(): + from pm.ensure import ensure as direct + return direct(name, base_env=base_env, explicit=explicit, progress=progress, + pause_event=pause_event, download_progress=download_progress) + if not explicit and not isinstance(get_package(name), StatePackage): + if not _missing_or_refuse(name): + return Runner(name, env_for(name, base_env=base_env)) + if pause_event is not None and pause_event.is_set(): + from pm.downloader import DownloadPaused + raise DownloadPaused("install paused") + callbacks = {} + if progress is not None: + callbacks["progress"] = progress + if download_progress is not None: + callbacks["download_progress"] = lambda done, total, ranges: download_progress( + done, total, {key: [tuple(row) for row in rows] for key, rows in ranges.items()}) + _request("ensure", {"name": name, "explicit": explicit}, callbacks=callbacks, pause_event=pause_event) + return Runner(name, env_for(name, base_env=base_env)) + + +def sync_venv(extras=None, *, explicit=False, plugin_dirs=None, before_publish=None, repair=False) -> None: + if is_runtime(): + from pm.ensure import sync_venv as direct + return direct(extras, explicit=explicit, plugin_dirs=plugin_dirs, + before_publish=before_publish, repair=repair) + callbacks = {} + if callable(plugin_dirs): + callbacks["plugin_dirs"] = lambda: _members(plugin_dirs()) + members = None + else: + members = _members(plugin_dirs) + if before_publish is not None: + def publish(): + publication = before_publish() + if publication is not None: + callbacks["undo"] = publication + if hasattr(publication, "finish"): + callbacks["finish"] = publication.finish + return {"undo": publication is not None, "finish": hasattr(publication, "finish")} + callbacks["before_publish"] = publish + _request("sync_venv", {"extras": extras, "explicit": explicit, "repair": repair, + "plugin_dirs": members}, callbacks=callbacks) + + +def stage_only(name, target, *, progress=None) -> Path: + if is_runtime(): + from pm.ensure import stage_only as direct + return direct(name, target, progress=progress) + callbacks = {"progress": progress} if progress is not None else {} + return Path(_request("stage_only", {"name": name, "target": target}, callbacks=callbacks)) + + +def uv(command="uv", *, venv=None, realize=True, explicit=False, base_env=None): + if command not in ("uv", "uvx"): + raise ValueError(f"unknown uv executable: {command}") + if not realize or is_runtime(): + from pm.ensure import uv as direct + return direct(command, venv=venv, realize=realize, explicit=explicit, base_env=base_env) + if not explicit: + _missing_or_refuse("uv") + binary, environment = _request("uv", { + "command": command, "venv": str(venv) if venv is not None else None, + "realize": realize, "explicit": explicit, + "base_env": dict(os.environ if base_env is None else base_env), + }) + return binary, environment diff --git a/pm/extras.py b/pm/extras.py index c86d11a084..ae69950f0a 100644 --- a/pm/extras.py +++ b/pm/extras.py @@ -164,7 +164,7 @@ def ensure_import(extra: str) -> None: f"extra {extra!r} is not supported on this platform " f"(gate: {marker!r}); the adapter degrades without it", ) - from pm.ensure import sync_venv + from pm.client import sync_venv sync_venv([extra]) # Activation is a process-boot operation. Never mix a newly resolved diff --git a/pm/launch.py b/pm/launch.py new file mode 100644 index 0000000000..256fc0a887 --- /dev/null +++ b/pm/launch.py @@ -0,0 +1,10 @@ +"""CLI entry after the isolated interpreter has been selected.""" +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from pm.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pm/plugins_state.py b/pm/plugins_state.py index 8344d1c7b2..fffca081ca 100644 --- a/pm/plugins_state.py +++ b/pm/plugins_state.py @@ -37,7 +37,7 @@ def _read_home_config(home: Path) -> Optional[dict[str, Any]]: # Missing YAML support is a broken runtime, not an empty plugin selection. import utils - from yaml import YAMLError + from ruamel.yaml.error import YAMLError try: config = utils.fast_safe_load(text) diff --git a/pm/pyproject.toml b/pm/pyproject.toml new file mode 100644 index 0000000000..d7f83bd724 --- /dev/null +++ b/pm/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "hermes-pm-runtime" +version = "0.0.0" +requires-python = ">=3.14,<3.15" +dependencies = [ + "packaging==26.0", + "tomli-w==1.2.0", + "ruamel.yaml==0.18.17", +] + +[tool.uv] +package = false + +# Deliberately independent: repairing the app cannot require resolving it. +[tool.uv.workspace] +members = [] diff --git a/pm/receipt.py b/pm/receipt.py index 2ad08eabc3..1a96f2bb55 100644 --- a/pm/receipt.py +++ b/pm/receipt.py @@ -37,6 +37,7 @@ sync never displaces the outer update's entry. from __future__ import annotations import contextvars +from contextlib import contextmanager import copy import json import os @@ -65,11 +66,49 @@ _completed_by_update: contextvars.ContextVar[Optional[dict[str, dict[str, Any]]] ) +_worker_update: contextvars.ContextVar[tuple[Optional[str]] | None] = contextvars.ContextVar( + "pm_worker_update", default=None +) +_last_completed: contextvars.ContextVar[Optional[dict[str, Any]]] = contextvars.ContextVar( + "pm_last_completed", default=None +) + + +@contextmanager +def worker_context(update_id: Optional[str]): + """Carry correlation across the worker seam without consulting disk state.""" + token = _worker_update.set((update_id,)) + completed = _last_completed.set(None) + try: + yield + finally: + _last_completed.reset(completed) + _worker_update.reset(token) + + +def last_completed() -> Optional[dict[str, Any]]: + return copy.deepcopy(_last_completed.get()) + + +def accept_worker_receipt(data: Optional[dict[str, Any]], update_id: Optional[str]) -> None: + if data is None: + return + if data.get("update_id") != update_id: + raise ValueError("PM worker receipt correlation mismatch") + if update_id: + completed = dict(_completed_by_update.get() or {}) + completed[update_id] = copy.deepcopy(data) + _completed_by_update.set(completed) + + def _ambient_update_id() -> Optional[str]: """The update correlation id in force in this context, or None. Lazy import: hermes_cli.update_receipt imports pm.receipt at embed time, so this direction must stay function-scoped. Never raises.""" + worker = _worker_update.get() + if worker is not None: + return worker[0] try: from hermes_cli.update_receipt import current_correlation_id @@ -209,6 +248,7 @@ def finalize( current["outcome"] = outcome current["exit_code"] = exit_code current["finished_at"] = _utc_now_iso() + _last_completed.set(copy.deepcopy(current)) # Correlation: file this completion under its update id (copy-on-write # — a deep-copied entry in a freshly copied map, never a shared dict). update_id = current.get("update_id") diff --git a/pm/recovery.py b/pm/recovery.py index 110cfd81db..a4dfac4ad1 100644 --- a/pm/recovery.py +++ b/pm/recovery.py @@ -10,7 +10,7 @@ from pm.package import InstallError STARTUP_IMPORTS = ( - ("PyYAML", "yaml", "SafeDumper"), + ("ruamel.yaml", "ruamel.yaml", "YAML"), ("python-dotenv", "dotenv", "load_dotenv"), ("click", "click", "Command"), ("certifi", "certifi", "contents"), @@ -49,7 +49,7 @@ def validate_environment(python: Path, *, env: dict, cwd: Path) -> None: def repair_dependencies(project_root: Path) -> None: """Restore this installation's recorded set; never repair a foreign tree.""" - from pm.ensure import sync_venv + from pm.client import sync_venv from pm.paths import repo_root if Path(project_root).resolve() != repo_root().resolve(): diff --git a/pm/registry.py b/pm/registry.py index b75270b7ac..ae93e6e195 100644 --- a/pm/registry.py +++ b/pm/registry.py @@ -2,7 +2,14 @@ from __future__ import annotations -from pm.package import Package +import importlib +import importlib.util +from pathlib import Path +import sys +from types import ModuleType +from typing import Any + +from pm.package import InstallError, Package _packages: dict[str, Package] = {} @@ -25,6 +32,94 @@ def all_packages() -> list[str]: return sorted(_packages) +def package_definitions(names: list[str] | None = None) -> list[dict[str, Any]]: + """Declarations for a fresh worker; built-ins already load with pm. + + Only import identities and file locations cross the wire, never instances, + source bodies, or the application's import path. ``names`` selects a closure. + """ + definitions = [] + for package in walk(names) if names is not None else list(_packages.values()): + cls = type(package) + if cls.__module__ == "pm.packages": + continue + definition = {"name": package.name, "module": cls.__module__, "qualname": cls.__qualname__} + module = sys.modules.get(cls.__module__) + resolved = module + for part in cls.__qualname__.split("."): + resolved = getattr(resolved, part, None) + if cls.__module__ == "__main__" or "" in cls.__qualname__ or resolved is not cls: + raise InstallError( + package.name, f"package definition {cls.__module__}.{cls.__qualname__} is not importable", + "define and register a module-level Package subclass in an importable file", + ) + source = getattr(module, "__file__", None) + if source: + definition["path"] = str(Path(source).resolve()) + namespaces = {} + parent = cls.__module__.rpartition(".")[0] + while parent: + loaded = sys.modules.get(parent) + if loaded is not None and not getattr(loaded, "__file__", None) and hasattr(loaded, "__path__"): + namespaces[parent] = [str(Path(path).resolve()) for path in loaded.__path__] + parent = parent.rpartition(".")[0] + if namespaces: + definition["namespaces"] = namespaces + definitions.append(definition) + return definitions + + +def load_package_definitions(definitions: list[dict[str, Any]]) -> None: + """Restore explicit registrations after built-ins, without plugin discovery.""" + restored = {} + for definition in definitions: + module_name = definition["module"] + try: + # Directory plugins use synthetic package parents. Their explicit + # search paths preserve relative imports without exposing app deps. + for name, locations in definition.get("namespaces", {}).items(): + if name not in sys.modules: + namespace = ModuleType(name) + namespace.__path__ = locations + sys.modules[name] = namespace + try: + module = importlib.import_module(module_name) + except ModuleNotFoundError as exc: + # Missing dependencies inside the definition are not evidence + # that its module is absent; never execute such a module twice. + if not exc.name or not (module_name == exc.name or module_name.startswith(exc.name + ".")): + raise + spec = importlib.util.spec_from_file_location(module_name, definition["path"]) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load {module_name}") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + except BaseException: + sys.modules.pop(module_name, None) + raise + source = definition.get("path") + if source and Path(getattr(module, "__file__", "") or "").resolve() != Path(source).resolve(): + raise ImportError(f"{module_name} resolved to a different source") + cls = module + for part in definition["qualname"].split("."): + cls = getattr(cls, part) + if not isinstance(cls, type) or not issubclass(cls, Package): + raise TypeError("definition is not a Package subclass") + instance = cls() + if instance.name != definition["name"]: + raise ValueError(f"definition now registers {instance.name!r}") + restored[instance.name] = instance + except Exception as exc: + raise InstallError( + definition["name"], f"cannot load package definition {module_name}.{definition['qualname']}: {exc}", + "keep the package definition and its dependencies importable in the isolated PM runtime", + ) from exc + # Import-time decorators must not override the caller's final selection. + _packages.update(restored) + + def walk(names: list[str]) -> list[Package]: """Deps-first topological order over the requested packages.""" seen: dict[str, Package] = {} diff --git a/pm/runtime.py b/pm/runtime.py new file mode 100644 index 0000000000..55f24dbbbe --- /dev/null +++ b/pm/runtime.py @@ -0,0 +1,188 @@ +"""The dependency manager's runtime, independent of the application graph. + +Only the bootstrap below runs in the caller's interpreter. It never imports +application dependencies or adds the manager's dependencies to that process. +""" +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import uuid + +from pm.package import InstallError + + +def runtime_environment() -> dict[str, str]: + """Do not let an activated application or a uv caller select PM's imports.""" + from hermes_constants import get_hermes_home + from pm.paths import store_root + + env = {key: value for key, value in os.environ.items() + if not key.startswith("PYTHON") and not key.startswith("UV_") + and key != "VIRTUAL_ENV"} + env["HERMES_HOME"] = str(get_hermes_home()) + env["HERMES_RUNTIME_DIR"] = str(store_root()) + env["UV_PYTHON_DOWNLOADS"] = "never" + return env + + +def _python(environment: Path) -> Path: + return environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + + +def _inputs(project: Path, python: Path) -> str: + digest = hashlib.sha256() + for name in ("pyproject.toml", "uv.lock"): + digest.update((project / name).read_bytes()) + digest.update(b"\0") + # A different interpreter must not reuse a venv pointing at the old one. + digest.update(str(python.absolute()).encode()) + return digest.hexdigest() + + +def is_runtime() -> bool: + if (Path(sys.prefix) / "pm-runtime.json").is_file(): + return True + resident = _resident_runtime() + return resident is not None and str(resident[1]) in sys.path + + +def _resident_runtime() -> tuple[Path, Path] | None: + from pm.paths import repo_root + + project = repo_root() + payload = project.parent if (project.parent / "manifest.json").is_file() else None + if payload is not None: + runtime = payload / "pm-runtime" + else: + install_root = Path(os.environ.get("HERMES_INSTALL_ROOT") or project) + stamp_path = install_root / "install-stamp.json" + try: + stamp = json.loads(stamp_path.read_text(encoding="utf-8-sig")) + except FileNotFoundError: + return None + except (OSError, ValueError) as exc: + raise InstallError("pm-runtime", "invalid package install stamp", "reinstall this application") from exc + if stamp.get("distribution") not in ("nix", "docker"): + return None + value = stamp.get("pmRuntime") + if not isinstance(value, str) or not Path(value).is_absolute(): + raise InstallError("pm-runtime", "packaged PM runtime is missing", "reinstall this application") + runtime = Path(value) + try: + marker = json.loads((runtime / "pm-runtime.json").read_text(encoding="utf-8")) + python = (runtime / marker["python"]).resolve() + site = (runtime / marker["sitePackages"]).resolve() + except (OSError, ValueError, KeyError, TypeError) as exc: + raise InstallError("pm-runtime", "packaged PM runtime is missing or invalid", "reinstall this application") from exc + # Native payloads must remain self-contained. Nix's independent derivation + # instead refers to its immutable interpreter/dependencies in /nix/store. + if (not python.is_file() or not site.is_dir() + or (payload is not None and (not python.is_relative_to(payload) or not site.is_relative_to(runtime)))): + raise InstallError("pm-runtime", "packaged PM paths are missing or escape the payload", "reinstall this application") + return python, site + + +def _validate(python: Path, env: dict[str, str]) -> str: + try: + checked = subprocess.run( + [str(python), "-I", "-B", "-c", + "import packaging, tomli_w; from ruamel.yaml import YAML"], + env=env, capture_output=True, text=True, timeout=30, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return str(exc) + return checked.stderr.strip() or f"exit {checked.returncode}" if checked.returncode else "" + + +def prepare_runtime(uv: Path, python: Path, root: Path, *, offline: bool = False, + project: Path | None = None, bootstrap: bool = True) -> Path: + """Publish a locked PM environment without resolving the application. + + Generations are immutable after publication. Failed preparation leaves the + previous generation intact, including when an old worker is still running. + """ + from hermes_cli.runtime_state import _lock + from pm.lock import _write + from pm.runtime_stage import stage_runtime + + project = project or Path(__file__).resolve().parent + identity = _inputs(project, python) + env = runtime_environment() + root.mkdir(parents=True, exist_ok=True) + with (root / ".prepare.lock").open("a+b") as lock: + _lock(lock.fileno(), wait=True) + selected = root / "selected.json" + try: + fact = json.loads(selected.read_text(encoding="utf-8")) + except FileNotFoundError: + fact = {} + if fact.get("inputs") == identity: + environment = root / fact["generation"] + if (environment / "pm-runtime.json").is_file() and not _validate(_python(environment), env): + return _python(environment) + if not bootstrap: + raise InstallError("pm-runtime", "not installed or outdated and lazy installs are disabled", + "run `hermes pm install` to prepare the independent PM runtime") + generation = Path("generations") / uuid.uuid4().hex + environment = root / generation + try: + print("Preparing the isolated PM runtime…", file=sys.stderr, flush=True) + executable = stage_runtime(uv, python, environment, project=project, offline=offline) + _write(environment / "pm-runtime.json", {"inputs": identity}) + _write(selected, {"inputs": identity, "generation": generation.as_posix()}) + return executable + except BaseException: + shutil.rmtree(environment, ignore_errors=True) + raise + + + +def runtime_python(*, bootstrap: bool = True) -> Path: + """Resolve PM without selecting, repairing, or importing the app environment.""" + if is_runtime(): + return Path(sys.executable) + from hermes_cli.runtime_paths import install_state_dir + from pm.ensure import uv as managed_uv + from pm.paths import repo_root + + project = repo_root() + resident = _resident_runtime() + if resident is not None: + return resident[0] + uv, env = managed_uv(realize=False) + if uv is None: + if not bootstrap: + raise InstallError("pm-runtime", "not installed and lazy installs are disabled", + "run `hermes pm install` to prepare the independent PM runtime") + # This closure is deliberately stdlib-only: uv + Python, never Venv. + uv, env = managed_uv(explicit=True) + if uv is None: + raise InstallError("pm-runtime", "pinned uv and Python are unavailable") + return prepare_runtime(Path(uv), Path(env["UV_PYTHON"]), install_state_dir(project) / "pm-runtime", + bootstrap=bootstrap) + + +def runtime_command(script: Path, args: tuple[str, ...] | list[str] = (), *, bootstrap: bool = True) -> list[str]: + """One launch contract for mutable venvs and resident signed payloads.""" + resident = _resident_runtime() + if resident is None: + python = runtime_python() if bootstrap else runtime_python(bootstrap=False) + return [str(python), "-I", "-B", str(script), *args] + python, site = resident + launcher = ( + "import runpy,sys; sys.path.insert(0,sys.argv.pop(1)); " + "script=sys.argv.pop(1); sys.argv[0]=script; runpy.run_path(script,run_name='__main__')" + ) + return [str(python), "-I", "-S", "-B", "-c", launcher, str(site), str(script), *args] + + +def run_cli(argv: list[str]) -> int: + result = subprocess.run(runtime_command(Path(__file__).with_name("launch.py"), argv), + env=runtime_environment()) + return result.returncode diff --git a/pm/runtime_stage.py b/pm/runtime_stage.py new file mode 100644 index 0000000000..ab2b3aa761 --- /dev/null +++ b/pm/runtime_stage.py @@ -0,0 +1,71 @@ +"""One locked dependency builder for PM workers and packaged runtimes.""" +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +from pm.package import InstallError + + +def stage_runtime(uv: Path, python: Path, destination: Path, *, + project: Path | None = None, offline: bool = False, + wheelhouse: Path | None = None) -> Path: + """Build at the final path; the caller owns publication and its marker. + + The scratch project prevents uv from discovering the application's workspace. + No project install, application extra, or application lock enters this graph. + """ + from pm.packages import uv_cache_dir + from pm.runtime import runtime_environment + + project = project or Path(__file__).resolve().parent + destination = destination.absolute() + executable = destination / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + env = runtime_environment() + env["UV_CACHE_DIR"] = str(uv_cache_dir()) + env["UV_PROJECT_ENVIRONMENT"] = str(destination) + env["UV_PYTHON"] = str(python) + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="pm-project-", dir=destination.parent) as temp: + snapshot = Path(temp) + for name in ("pyproject.toml", "uv.lock"): + shutil.copyfile(project / name, snapshot / name) + commands = [ + [str(uv), "venv", "--relocatable", "--python", str(python), str(destination)], + [str(uv), "sync", "--project", str(snapshot), "--locked", + "--no-default-groups", "--no-install-project", "--python", str(python)], + ] + if wheelhouse is not None: + # Locally rebuilt wheels are verified by the caller's wheelhouse + # manifest, not the upstream wheel hashes in the PM lock. + offline = True + requirements = snapshot / "requirements.txt" + commands[1:] = [ + [str(uv), "export", "--project", str(snapshot), "--frozen", + "--python", str(python), "--no-default-groups", "--no-emit-project", + "--no-hashes", "--output-file", str(requirements)], + [str(uv), "pip", "install", "--python", str(executable), + "--no-index", "--only-binary", ":all:", + "--find-links", str(wheelhouse.absolute()), "-r", str(requirements)], + [str(uv), "pip", "check", "--python", str(executable)], + ] + for command in commands: + # Neither the caller's uv.toml nor user-wide settings may choose + # PM's indexes, required uv version, or environment policy. + command.append("--no-config") + if offline: + command.append("--offline") + result = subprocess.run(command, cwd=snapshot, env=env, stdout=sys.stderr, stderr=sys.stderr, timeout=600) + if result.returncode: + raise InstallError("pm-runtime", f"{command[1]} exited {result.returncode}") + checked = subprocess.run( + [str(executable), "-I", "-B", "-c", "import packaging, tomli_w; from ruamel.yaml import YAML"], + env=env, capture_output=True, text=True, timeout=30, + ) + if checked.returncode: + raise InstallError("pm-runtime", f"dependency validation failed: {checked.stderr.strip()}") + return executable diff --git a/pm/uv.lock b/pm/uv.lock new file mode 100644 index 0000000000..2837f5fbbb --- /dev/null +++ b/pm/uv.lock @@ -0,0 +1,68 @@ +version = 1 +revision = 3 +requires-python = "==3.14.*" + +[[package]] +name = "hermes-pm-runtime" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "packaging" }, + { name = "ruamel-yaml" }, + { name = "tomli-w" }, +] + +[package.metadata] +requires-dist = [ + { name = "packaging", specifier = "==26.0" }, + { name = "ruamel-yaml", specifier = "==0.18.17" }, + { name = "tomli-w", specifier = "==1.2.0" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.18.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/2b/7a1f1ebcd6b3f14febdc003e658778d81e76b40df2267904ee6b13f0c5c6/ruamel_yaml-0.18.17.tar.gz", hash = "sha256:9091cd6e2d93a3a4b157ddb8fabf348c3de7f1fb1381346d985b6b247dcd8d3c", size = 149602, upload-time = "2025-12-17T20:02:55.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/fe/b6045c782f1fd1ae317d2a6ca1884857ce5c20f59befe6ab25a8603c43a7/ruamel_yaml-0.18.17-py3-none-any.whl", hash = "sha256:9c8ba9eb3e793efdf924b60d521820869d5bf0cb9c6f1b82d82de8295e290b9d", size = 121594, upload-time = "2025-12-17T20:02:07.657Z" }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/97/60fda20e2fb54b83a61ae14648b0817c8f5d84a3821e40bfbdae1437026a/ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600", size = 225794, upload-time = "2025-11-16T16:12:59.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/bd/ab8459c8bb759c14a146990bf07f632c1cbec0910d4853feeee4be2ab8bb/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef", size = 147248, upload-time = "2025-11-16T16:13:42.872Z" }, + { url = "https://files.pythonhosted.org/packages/69/f2/c4cec0a30f1955510fde498aac451d2e52b24afdbcb00204d3a951b772c3/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf", size = 133764, upload-time = "2025-11-16T16:13:43.932Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/2480d062281385a2ea4f7cc9476712446e0c548cd74090bff92b4b49e898/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000", size = 730537, upload-time = "2025-11-16T20:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/75/08/e365ee305367559f57ba6179d836ecc3d31c7d3fdff2a40ebf6c32823a1f/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4", size = 746944, upload-time = "2025-11-16T16:13:45.338Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/8b56b08db91e569d0a4fbfa3e492ed2026081bdd7e892f63ba1c88a2f548/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c", size = 778249, upload-time = "2025-11-16T16:13:46.871Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1d/70dbda370bd0e1a92942754c873bd28f513da6198127d1736fa98bb2a16f/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043", size = 737140, upload-time = "2025-11-16T16:13:48.349Z" }, + { url = "https://files.pythonhosted.org/packages/5b/87/822d95874216922e1120afb9d3fafa795a18fdd0c444f5c4c382f6dac761/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524", size = 741070, upload-time = "2025-11-16T20:22:54.151Z" }, + { url = "https://files.pythonhosted.org/packages/b9/17/4e01a602693b572149f92c983c1f25bd608df02c3f5cf50fd1f94e124a59/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e", size = 765882, upload-time = "2025-11-16T16:13:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/9f/17/7999399081d39ebb79e807314de6b611e1d1374458924eb2a489c01fc5ad/ruamel_yaml_clib-0.2.15-cp314-cp314-win32.whl", hash = "sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa", size = 102567, upload-time = "2025-11-16T16:13:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/67/be582a7370fdc9e6846c5be4888a530dcadd055eef5b932e0e85c33c7d73/ruamel_yaml_clib-0.2.15-cp314-cp314-win_amd64.whl", hash = "sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467", size = 122847, upload-time = "2025-11-16T16:13:51.807Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] diff --git a/pm/worker.py b/pm/worker.py new file mode 100644 index 0000000000..5bd51dd328 --- /dev/null +++ b/pm/worker.py @@ -0,0 +1,138 @@ +"""One stdlib JSON-line PM request per isolated process.""" +from __future__ import annotations + +import importlib +import json +import os +from pathlib import Path +import queue +import sys +import threading + + +def _members(value): + if value is None: + return None + if "sources" in value: + return {Path(identity): Path(source) for identity, source in value["sources"]} + return [Path(path) for path in value["paths"]] + + +def _read_controls(messages, pause): + # Raw reads avoid a daemon thread holding sys.stdin's buffered lock at exit. + pending = b"" + request_id = None + try: + while block := os.read(0, 65536): + pending += block + while b"\n" in pending: + line, pending = pending.split(b"\n", 1) + message = json.loads(line) + if request_id is None: + request_id = message["id"] + if message["id"] != request_id: + raise ValueError("unexpected PM control request id") + if message.get("cancel"): + pause.set() + if message.get("type") != "cancel": + messages.put(message) + except (OSError, ValueError, KeyError) as exc: + messages.put(exc) + finally: + pause.set() + messages.put(None) + + +def main(): + # Capture the protocol FD before redirecting even native/subprocess stdout. + wire = os.fdopen(os.dup(sys.stdout.fileno()), "w", encoding="utf-8", buffering=1) + os.dup2(sys.stderr.fileno(), sys.stdout.fileno()) + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + messages = queue.Queue() + pause = threading.Event() + threading.Thread(target=_read_controls, args=(messages, pause), daemon=True).start() + + def receive(): + message = messages.get() + if message is None: + raise RuntimeError("PM client disconnected") + if isinstance(message, Exception): + raise message + return message + + request = receive() + from pm import paths, receipt + from pm.package import InstallError + from pm.registry import load_package_definitions + context = request["context"] + paths.repo_root = lambda: Path(context["repo"]) + paths.lockfile_path = lambda: Path(context["lockfile"]) + engine = importlib.import_module("pm.ensure") + call = 0 + callback_lock = threading.Lock() + + def send(data): + wire.write(json.dumps({"id": request["id"], **data}) + "\n") + + def callback(name, *args): + with callback_lock: + return exchange(name, args) + + def exchange(name, args): + nonlocal call + call += 1 + send({"type": "callback", "callback": name, "call": call, "args": args}) + reply = receive() + if reply["id"] != request["id"] or reply["call"] != call: + raise RuntimeError("unexpected PM callback response") + if "error" in reply: + raise RuntimeError(reply["error"]) + return reply["result"] + + def sync_venv(**arguments): + if "plugin_dirs" in request["callbacks"]: + arguments["plugin_dirs"] = lambda: _members(callback("plugin_dirs")) + else: + arguments["plugin_dirs"] = _members(arguments["plugin_dirs"]) + if "before_publish" in request["callbacks"]: + def publish(): + hooks = callback("before_publish") + if not any(hooks.values()): + return None + def undo(): + if hooks["undo"]: + callback("undo") + if hooks["finish"]: + undo.finish = lambda: callback("finish") + return undo + arguments["before_publish"] = publish + return engine.sync_venv(**arguments) + + with receipt.worker_context(request.get("update_id")): + try: + load_package_definitions(request.get("packages", [])) + operations = {"ensure": engine.ensure, "sync_venv": sync_venv, + "stage_only": engine.stage_only, "uv": engine.uv} + arguments = request["arguments"] + if request["operation"] == "ensure": + arguments["pause_event"] = pause + for name in ("progress", "download_progress"): + if name in request["callbacks"]: + arguments[name] = lambda *args, name=name: callback(name, *args) + result = operations[request["operation"]](**arguments) + if request["operation"] == "ensure": + result = None # Runner is reconstructed from the caller's base env. + if isinstance(result, Path): + result = str(result) + response = {"result": result} + except BaseException as exc: + error = {"type": type(exc).__name__, "message": str(exc)} + if isinstance(exc, InstallError): + error.update(package=exc.package, cause=exc.cause, remedy=exc.remedy) + response = {"error": error} + response["receipt"] = receipt.last_completed() + send({"type": "result", **response}) + + +if __name__ == "__main__": + main() diff --git a/providers/__init__.py b/providers/__init__.py index 59d1c75de8..68c98a622d 100644 --- a/providers/__init__.py +++ b/providers/__init__.py @@ -134,7 +134,7 @@ def _declares_model_provider_kind(plugin_dir: Path) -> bool: Only that kind is imported from the flat install directory — every other plugin there belongs to ``PluginManager``, which owns its lifecycle and - consent flow. Parsed with PyYAML when available, falling back to a line + consent flow. Parsed with ruamel.yaml when available, falling back to a line scan so provider discovery never hard-depends on it. """ for filename in ("plugin.yaml", "plugin.yml"): @@ -146,7 +146,7 @@ def _declares_model_provider_kind(plugin_dir: Path) -> bool: except Exception: return False try: - import yaml + import hermes_yaml as yaml data = yaml.safe_load(text) if isinstance(data, dict): diff --git a/pyproject.toml b/pyproject.toml index 167e4dccc0..e64e5cb002 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,6 @@ dependencies = [ "httpx[socks]==0.28.1", "rich==14.3.3", "tenacity==9.1.4", - "pyyaml==6.0.3", "tomli-w==1.2.0", # Preserve relative dependency paths in staged plugin metadata. "ruamel.yaml==0.18.17", "requests==2.33.0", # CVE-2026-25645 @@ -674,7 +673,7 @@ plugins = ["**/plugin.yaml", "**/plugin.yml"] # pm/lock.json is the runtime pin table (uv/python/tool versions + sha256s). # Without it a sealed wheel installs the pm package but has no pins to # realize — the pm store would be empty. -pm = ["lock.json", "artifact-mirror.json"] +pm = ["lock.json", "artifact-mirror.json", "pyproject.toml", "uv.lock"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/scripts/bundles/native.py b/scripts/bundles/native.py index a6b781156a..0053f40727 100644 --- a/scripts/bundles/native.py +++ b/scripts/bundles/native.py @@ -61,6 +61,18 @@ def stage_uv_cache(source: Path, destination: Path) -> None: wheel.unlink() +def stage_pm_runtime(root: Path, uv: Path, python: Path, repo: Path, *, offline: bool = False) -> None: + """Publish the same PM dependency graph as source installs, ready offline.""" + from pm.runtime_stage import stage_runtime + from scripts.bundles.payload import seal_pm_runtime + + destination = root / "pm-runtime" + if destination.exists(): + shutil.rmtree(destination) + stage_runtime(uv, python, destination, project=repo / "pm", offline=offline) + seal_pm_runtime(root, python) + + def stage_native(args) -> int: previous = os.environ.get("HERMES_RUNTIME_DIR") try: @@ -134,6 +146,9 @@ def _stage_native(args) -> int: _store().entry(python_fact["entry"]), current_target() ) + stage_pm_runtime(out, Path(uv_bin), python_bin, repo_dir) + print("✓ pm-runtime (independent locked dependencies)", flush=True) + # 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" diff --git a/scripts/bundles/payload.py b/scripts/bundles/payload.py index fc35129f8c..1f0812aa22 100644 --- a/scripts/bundles/payload.py +++ b/scripts/bundles/payload.py @@ -84,10 +84,48 @@ def plant_surfaces(repo: Path, source: Path, *, dashboard: bool = True) -> None: shutil.copytree(web, repo / "hermes_cli/web_dist") +def seal_pm_runtime(root: Path, python: Path) -> dict: + """Record a resident PM runtime without Windows' CWD-bound redirector. + + Sealed workers execute the base interpreter with -I -S and add only the + recorded site directory. Its paths remain valid after the payload moves. + """ + root, python = root.resolve(), python.resolve() + if not python.is_relative_to(root) or not python.is_file(): + raise ValueError(f"PM interpreter must belong to the payload: {python}") + runtime = root / "pm-runtime" + sites = list(runtime.glob("lib/python*/site-packages")) + list(runtime.glob("Lib/site-packages")) + if len(sites) != 1: + raise ValueError(f"PM dependency directory missing or ambiguous: {runtime}") + marker = { + "python": Path(os.path.relpath(python, runtime)).as_posix(), + "sitePackages": sites[0].relative_to(runtime).as_posix(), + } + cfg = runtime / "pyvenv.cfg" + lines = cfg.read_text(encoding="utf-8").splitlines() + lines = [line for line in lines if line.partition("=")[0].strip() not in + {"home", "executable", "base-executable", "base-prefix", "base-exec-prefix", "command"}] + lines.insert(0, f"home = {os.path.relpath(python.parent, runtime)}") + cfg.write_text("\n".join(lines) + "\n", encoding="utf-8") + # A sealed runtime is not activated, and copied Windows redirectors cannot + # follow its relative home from arbitrary working directories. + bindir = runtime / ("Scripts" if os.name == "nt" else "bin") + for entry in bindir.iterdir(): + if os.name == "nt" or not entry.is_symlink(): + if entry.is_file(): + entry.unlink() + _relativize_bin_links(root, runtime / "bin") + (runtime / "pm-runtime.json").write_text(json.dumps(marker, indent=2) + "\n", encoding="utf-8") + return marker + + def relativize_links(root: Path) -> int: """Only dependency-venv links move; framework links belong to codesign.""" root = root.resolve() - directory = root / "venv/bin" + return sum(_relativize_bin_links(root, root / name / "bin") for name in ("venv", "pm-runtime")) + + +def _relativize_bin_links(root: Path, directory: Path) -> int: count = 0 if not directory.is_dir(): return count diff --git a/scripts/ci/setup_toolchain.py b/scripts/ci/setup_toolchain.py index 34f1b4cd40..d6f71a1191 100644 --- a/scripts/ci/setup_toolchain.py +++ b/scripts/ci/setup_toolchain.py @@ -173,6 +173,17 @@ def dependencies(args) -> None: import subprocess import tomllib + from pm.runtime import is_runtime, runtime_command, runtime_environment + + if not is_runtime(): + subprocess.run( + runtime_command(Path(__file__).resolve(), + ["dependencies", "--home", str(args.home.resolve()), + "--toolchain", args.toolchain, "--extras", json.dumps(args.extras)]), + env=runtime_environment(), check=True, + ) + return + from hermes_cli.runtime_paths import selected_venv from pm.ensure import sync_venv, uv from pm.paths import repo_root diff --git a/scripts/ci/verify_toolchain.py b/scripts/ci/verify_toolchain.py index 9b0d77081b..3055ebfa37 100644 --- a/scripts/ci/verify_toolchain.py +++ b/scripts/ci/verify_toolchain.py @@ -54,9 +54,9 @@ def main() -> None: if Path(sys.prefix).resolve() != Path(selected["environment"]).resolve(): raise RuntimeError("PATH Python did not select the PM dependency environment") import pytest - import yaml + import ruamel.yaml - rows["dependencies"] = {"pytest": pytest.__version__, "pyyaml": yaml.__version__} + rows["dependencies"] = {"pytest": pytest.__version__, "ruamel.yaml": ruamel.yaml.__version__} code = "import sys,pytest; print(sys.prefix); print(pytest.__version__)" probe = subprocess.check_output([shutil.which("python3"), "-c", code], text=True, encoding="utf-8", timeout=60) if pytest.__version__ not in probe: diff --git a/scripts/discord-voice-doctor.py b/scripts/discord-voice-doctor.py index dd552a87b7..dcfad1f0a6 100755 --- a/scripts/discord-voice-doctor.py +++ b/scripts/discord-voice-doctor.py @@ -241,7 +241,7 @@ def check_config(groq_key, eleven_key): config_path = HERMES_HOME / "config.yaml" if config_path.exists(): try: - import yaml + import hermes_yaml as yaml with open(config_path, encoding="utf-8-sig") as f: cfg = yaml.safe_load(f) or {} diff --git a/scripts/e2e_shared_metrics_staging.py b/scripts/e2e_shared_metrics_staging.py index 9fe836e85d..f4aa9281e0 100644 --- a/scripts/e2e_shared_metrics_staging.py +++ b/scripts/e2e_shared_metrics_staging.py @@ -47,7 +47,7 @@ def main() -> int: from hermes_cli.observability.shared_metrics_sender import SharedMetricsSender # Resolve through the real config path so this exercises what a user gets. - import yaml + import hermes_yaml as yaml resolved = resolve_send_config( yaml.safe_load((scratch / "config.yaml").read_text(encoding="utf-8-sig")) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 82d8ae8dd0..8335515181 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -530,6 +530,8 @@ function Stage-Venv { # tool store — all hash-verified against pm/lock.json + uv.lock. install.ps1 # no longer runs `uv sync` directly; pm is the single install authority # (the run_locked_uv_sync contract moved into pm/packages.py::uv_env). +# This tool-only bootstrap runs before PM's own dependencies exist. pm.cli +# prepares and enters its independently locked runtime before installing apps. function Get-BootstrapPython { $uv = Get-Uv $lock = Get-Content (Join-Path $InstallDir "pm\lock.json") -Raw | ConvertFrom-Json diff --git a/scripts/install.sh b/scripts/install.sh index bfa88626ad..618f01f4d3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -324,7 +324,8 @@ stage_venv() { (cd "$INSTALL_DIR" && "$UV_CMD" venv --allow-existing venv) || fail "uv venv failed" } -# Resolve the bootstrap interpreter without assuming a checkout-local venv. +# Tool-only bootstrap: acquire uv and Python before PM's own dependencies exist. +# The application dependency graph is never installed in this interpreter. bootstrap_python() { ensure_uv local _py @@ -337,7 +338,8 @@ bootstrap_python() { boot_py="${boot_py%$'\r'}" } -# uv exits before PM can replace its tool entry. +# uv exits before PM can replace its tool entry. pm.cli then prepares and +# enters its independently locked runtime before mutating application deps. bootstrap_pm() { local boot_py bootstrap_python diff --git a/scripts/iso-certify.py b/scripts/iso-certify.py index c01f009c6b..acc8fea962 100755 --- a/scripts/iso-certify.py +++ b/scripts/iso-certify.py @@ -62,6 +62,7 @@ except Exception as exc: # pragma: no cover - dependency guard raise REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) _READY_RE = re.compile(r"HERMES_(?:DASHBOARD|BACKEND)_READY port=(\d+)") _STALL_LOG_RE = re.compile(r"event loop stalled|ws write slow \(loop stalled") @@ -120,7 +121,7 @@ def seed_scratch_home(home: Path, *, isolation: str, heartbeat_secs: int, respaw "memory": {"enabled": False}, } # config.yaml is the canonical config; write it directly. - import yaml # provided by the runtime venv + import hermes_yaml as yaml (home / "config.yaml").write_text(yaml.safe_dump(cfg, sort_keys=True), encoding="utf-8") # A stub .env so credential resolution doesn't spelunk the real home. diff --git a/scripts/lib/wisdom-demo-env.sh b/scripts/lib/wisdom-demo-env.sh index 6124708df1..7afb5bdadb 100644 --- a/scripts/lib/wisdom-demo-env.sh +++ b/scripts/lib/wisdom-demo-env.sh @@ -19,7 +19,7 @@ wisdom_demo_repo_root() { } wisdom_demo_python_is_ready() { - "$1" -c 'import dotenv, pydantic, requests, yaml' >/dev/null 2>&1 + "$1" -c 'import dotenv, pydantic, requests, ruamel.yaml' >/dev/null 2>&1 } wisdom_demo_pick_python() { diff --git a/scripts/releases/darwin.py b/scripts/releases/darwin.py index d27b543395..8507a1b216 100644 --- a/scripts/releases/darwin.py +++ b/scripts/releases/darwin.py @@ -28,7 +28,7 @@ def _darwin_feed(channel: str, light: bool = False) -> dict[str, Any]: # --------------------------------------------------------------------------- def parse_mac_feed(text: str) -> dict[str, Any]: - import yaml # lazy: PyYAML loads only on the feed path + import hermes_yaml as yaml # lazy: YAML support loads only on the feed path feed = yaml.safe_load(text) if ( @@ -81,7 +81,7 @@ def mac_feed_references(text: str) -> list[str]: def merge_mac_feeds(legs: dict[str, str], tag: str, light: bool = False) -> dict[str, Any]: """Validate both native legs, merge them, and rewrite artifact URLs into the immutable per-release tag namespace.""" - import yaml # lazy + import hermes_yaml as yaml # lazy version = tag[1:] if isinstance(tag, str) and tag.startswith("v") else "" if not is_valid_version(version) or not _TAG_PATTERN.match(tag): diff --git a/scripts/termux/build_deb.sh b/scripts/termux/build_deb.sh index 86c036edb9..fdbef23a5c 100644 --- a/scripts/termux/build_deb.sh +++ b/scripts/termux/build_deb.sh @@ -18,7 +18,7 @@ # # Staged payload layout: python/ and node/ are pm-staged termux .deb # trees ($PREFIX-shaped: data/data/com.termux/files/usr/...). The -# installed layout is $PREFIX/lib/hermes-agent/{python,node,app,venv,bin} with +# installed layout is $PREFIX/lib/hermes-agent/{tools,app,venv,pm-runtime,bin} with # exactly one leak: $PREFIX/bin/hermes -> lib/hermes-agent/bin/hermes. set -Eeuo pipefail @@ -114,8 +114,9 @@ OUT_ABS="$(mkdir -p "$OUT" && cd "$OUT" && pwd)" # The container sees the payload at its real PREFIX path; the venv is built # staged trees so the shipped venv's absolute shebangs point at the REAL # $PREFIX path they will occupy on-device ($PREFIX is contractual). -log "Creating venv with the bundled CPython (inside the container)" +log "Creating application and PM environments with the bundled CPython (inside the container)" if [ -d "$PAYLOAD_ABS/venv" ]; then rm -rf "$PAYLOAD_ABS/venv"; fi +if [ -d "$PAYLOAD_ABS/pm-runtime" ]; then rm -rf "$PAYLOAD_ABS/pm-runtime"; fi # The venv's dep list: the resolved graph with markers intact (the installer # evaluates them on bionic) and documented android build misses skipped -- # uv pip check tolerates the app importing without them (its relay exporter @@ -131,8 +132,8 @@ write_reqs_file(Path(sys.argv[2]), Path(sys.argv[3])) PYREQS # The bind mount is runner-owned: the container (any uid) can only write # into a dir the HOST pre-created with open perms (same as the wheelhouse). -mkdir -p "$PAYLOAD_ABS/venv" -chmod 0777 "$PAYLOAD_ABS/venv" +mkdir -p "$PAYLOAD_ABS/venv" "$PAYLOAD_ABS/pm-runtime" +chmod 0777 "$PAYLOAD_ABS/venv" "$PAYLOAD_ABS/pm-runtime" # Mount the payload at its REAL on-device path: the venv records # absolute paths (interpreter symlink, pyvenv.cfg) that must be correct # on-device from birth -- a /payload alias would bake container paths in. @@ -145,6 +146,8 @@ docker run --rm --platform linux/arm64 \ -v "$PAYLOAD_ABS/wheelhouse:/data/data/com.termux/files/usr/lib/hermes-agent/wheelhouse" \ -v "$PAYLOAD_ABS/.work:/data/data/com.termux/files/usr/lib/hermes-agent/.work" \ -v "$PAYLOAD_ABS/venv:/data/data/com.termux/files/usr/lib/hermes-agent/venv" \ + -v "$PAYLOAD_ABS/pm-runtime:/data/data/com.termux/files/usr/lib/hermes-agent/pm-runtime" \ + -v "$PAYLOAD_ABS/app:/data/data/com.termux/files/usr/lib/hermes-agent/app:ro" \ "$IMAGE" bash -c ' set -euo pipefail export PREFIX=/data/data/com.termux/files/usr @@ -174,6 +177,20 @@ docker run --rm --platform linux/arm64 \ --offline --no-index --only-binary :all: --find-links "$PREFIX/lib/hermes-agent/wheelhouse" \ -r "$PREFIX/lib/hermes-agent/.work/resolved-reqs.txt" "$UV" pip check --python "$PREFIX/lib/hermes-agent/venv/bin/python" + + # PM uses the shared locked builder with the verified bionic wheelhouse. + ROOT="$PREFIX/lib/hermes-agent" + "$PY" -I -B -c " +import sys +from pathlib import Path +sys.path.insert(0, sys.argv[1]) +from pm.runtime_stage import stage_runtime +from scripts.bundles.payload import seal_pm_runtime +root, python, uv = map(Path, sys.argv[2:]) +stage_runtime(uv, python, root / \"pm-runtime\", project=root / \"app/pm\", + wheelhouse=root / \"wheelhouse\", offline=True) +seal_pm_runtime(root, python) +" "$ROOT/app" "$ROOT" "$PY" "$UV" ' || fail "venv assembly failed inside the container (offline wheelhouse install)" # The install-method stamp (code-scoped, next to hermes_cli/): the deb IS @@ -212,7 +229,7 @@ mkdir -p "$STAGE/DEBIAN" "$DEST/tools" for tool in python node uv npm ffmpeg ripgrep; do cp -a "$PAYLOAD_ABS/$tool" "$DEST/tools/" done -cp -a "$PAYLOAD_ABS/runtime-libs" "$PAYLOAD_ABS/app" "$PAYLOAD_ABS/venv" "$PAYLOAD_ABS/bin" "$DEST/" +cp -a "$PAYLOAD_ABS/runtime-libs" "$PAYLOAD_ABS/app" "$PAYLOAD_ABS/venv" "$PAYLOAD_ABS/pm-runtime" "$PAYLOAD_ABS/bin" "$DEST/" python3 "$HERE/payload_facts.py" "$DEST" "$PAYLOAD_ABS/.work/build_set.txt" python3 "$HERE/launchers.py" --payload "$DEST" --control "$STAGE/DEBIAN" @@ -243,7 +260,8 @@ docker run --rm --platform linux/arm64 \ -v "$DEB:/tmp/pkg.deb:ro" \ -v "$HERE/check_deb.sh:/tmp/check.sh:ro" \ -v "$HERE/validate_installed.py:/tmp/validate_installed.py:ro" \ - "termux/termux-docker@$DIGEST" bash /tmp/check.sh \ + "termux/termux-docker@$DIGEST" bash -c \ + 'source /tmp/check.sh; "$root/venv/bin/python" -m pm.cli status' \ || fail "container validation failed" log "Built $DEB (validated)" diff --git a/scripts/termux/termux_pkg_build.sh b/scripts/termux/termux_pkg_build.sh index bc9db1e578..1ea297841a 100755 --- a/scripts/termux/termux_pkg_build.sh +++ b/scripts/termux/termux_pkg_build.sh @@ -29,7 +29,8 @@ TARGET="linux-arm64-bionic" # Stage (idempotent: an already-published verifying entry is a no-op) and # print the entry path -- pm's own resolvers, no layout guessing here. # Resolve a real interpreter: some hosts alias python3 to a Store stub -# (Windows App Execution Aliases). pm runs on any stdlib python >= 3.11. +# (Windows App Execution Aliases). The stdlib client bootstraps PM's separate +# locked runtime; staging never imports PM's dependencies into this process. PM_PY="$(command -v python3 || true)" if [ -n "$PM_PY" ] && "$PM_PY" -c "import sys; assert sys.version_info >= (3, 11)" 2>/dev/null; then : @@ -40,7 +41,7 @@ fi ENTRY_DIR="$("$PM_PY" - "$PKG" "$TARGET" <<'PYEOF' import sys sys.path.insert(0, ".") -from pm.ensure import stage_only +from pm.client import stage_only # stage_only already returns the published entry path -- no re-derivation. print(stage_only(sys.argv[1], sys.argv[2])) PYEOF diff --git a/scripts/tool_search_livetest.py b/scripts/tool_search_livetest.py index 8cd775dddf..99b8e417bb 100644 --- a/scripts/tool_search_livetest.py +++ b/scripts/tool_search_livetest.py @@ -300,7 +300,7 @@ def setup_isolated_home(enabled: bool, listing: str = "off", def _yaml_dump(obj: Any) -> str: try: - import yaml + import hermes_yaml as yaml return yaml.safe_dump(obj, sort_keys=False) except ImportError: return json.dumps(obj, indent=2) diff --git a/scripts/validate_plugin_catalog.py b/scripts/validate_plugin_catalog.py index eb0078c7c0..d7fda110aa 100644 --- a/scripts/validate_plugin_catalog.py +++ b/scripts/validate_plugin_catalog.py @@ -3,7 +3,7 @@ Validates ``plugin-catalog/*.yaml`` catalog entries and ``plugin-catalog/removed.yaml`` against the catalog contract schema, using -only stdlib + PyYAML so the admission CI (and third-party repos) can run it +only stdlib + ruamel.yaml so the admission CI (and third-party repos) can run it WITHOUT installing hermes-agent. NOTE: this script intentionally duplicates the schema rules instead of @@ -31,10 +31,10 @@ import sys from pathlib import Path try: - import yaml + from ruamel.yaml import YAML, YAMLError except ImportError: # pragma: no cover - dependency guidance only print( - "ERROR: PyYAML is required (pip install pyyaml)", + "ERROR: ruamel.yaml is required (pip install ruamel.yaml==0.18.17)", file=sys.stderr, ) sys.exit(2) @@ -190,11 +190,13 @@ def validate_removed(data: object) -> tuple[list[str], list[str]]: def validate_file(path: Path) -> tuple[list[str], list[str]]: """Validate one YAML file (dispatching on filename). Returns (errors, warnings).""" try: + reader = YAML(typ="safe") + reader.version = (1, 1) with open(path, encoding="utf-8") as fh: - data = yaml.safe_load(fh) + data = reader.load(fh) except OSError as exc: return [f"cannot read file: {exc}"], [] - except yaml.YAMLError as exc: + except YAMLError as exc: return [f"invalid YAML: {exc}"], [] if path.name == "removed.yaml": diff --git a/skills/autonomous-ai-agents/hermes-agent/references/windows-quirks.md b/skills/autonomous-ai-agents/hermes-agent/references/windows-quirks.md index fbd3711b3e..56a40d329c 100644 --- a/skills/autonomous-ai-agents/hermes-agent/references/windows-quirks.md +++ b/skills/autonomous-ai-agents/hermes-agent/references/windows-quirks.md @@ -38,7 +38,7 @@ uses pytest-xdist; the canonical runner does per-file subprocess isolation, which the POSIX-only wrapper handles): ```bash -"/c/Program Files/Python311/python" -m pip install --user pytest pyyaml +"/c/Program Files/Python311/python" -m pip install --user pytest ruamel.yaml==0.18.17 export PYTHONPATH="$(pwd)" "/c/Program Files/Python311/python" -m pytest tests/foo/test_bar.py -v --tb=short ``` diff --git a/skills/software-development/hermes-agent-skill-authoring/SKILL.md b/skills/software-development/hermes-agent-skill-authoring/SKILL.md index 35dbb1c186..d46823b3c2 100644 --- a/skills/software-development/hermes-agent-skill-authoring/SKILL.md +++ b/skills/software-development/hermes-agent-skill-authoring/SKILL.md @@ -160,11 +160,12 @@ A skill exists to make the agent's process more predictable — the agent reliab 3. **Draft** with `write_file` to `skills///SKILL.md` (or `optional-skills/...`). 4. **Validate locally**: ```python - import yaml, re, pathlib + import re, pathlib + from ruamel.yaml import YAML content = pathlib.Path("skills///SKILL.md").read_text() assert content.startswith("---") m = re.search(r'\n---\s*\n', content[3:]) - fm = yaml.safe_load(content[3:m.start()+3]) + fm = YAML(typ="safe").load(content[3:m.start()+3]) assert "name" in fm and "description" in fm assert len(fm["description"]) <= 60, f"description {len(fm['description'])} chars — hardline is 60" assert fm["description"].endswith(".") diff --git a/tests/agent/test_actual_auxiliary_routing.py b/tests/agent/test_actual_auxiliary_routing.py index dfa2c9544d..12d6a16b24 100644 --- a/tests/agent/test_actual_auxiliary_routing.py +++ b/tests/agent/test_actual_auxiliary_routing.py @@ -8,7 +8,7 @@ import threading import time import pytest -import yaml +import hermes_yaml as yaml @pytest.fixture diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index ecd99c70a3..73acab527e 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -275,7 +275,7 @@ class TestMoaAggregatorSharedResolution: @staticmethod def _write_moa_config(tmp_path, monkeypatch, default_preset="opus-gpt"): - import yaml + import hermes_yaml as yaml home = tmp_path / ".hermes" home.mkdir(exist_ok=True) @@ -316,7 +316,7 @@ class TestMoaAggregatorSharedResolution: def test_real_config_explicit_task_provider_moa(self, tmp_path, monkeypatch): """auxiliary..provider: moa in a REAL config.yaml resolves to the aggregator through the genuine load_config()/resolve_moa_preset() path.""" - import yaml + import hermes_yaml as yaml home = self._write_moa_config(tmp_path, monkeypatch) cfg = yaml.safe_load((home / "config.yaml").read_text()) diff --git a/tests/agent/test_auxiliary_client_base_url_host_validation_52608.py b/tests/agent/test_auxiliary_client_base_url_host_validation_52608.py index a55d01a07d..164697e5b2 100644 --- a/tests/agent/test_auxiliary_client_base_url_host_validation_52608.py +++ b/tests/agent/test_auxiliary_client_base_url_host_validation_52608.py @@ -23,7 +23,7 @@ class TestTryAnthropicBaseUrlHostValidation: def test_openrouter_base_url_does_not_leak_into_auxiliary(self, tmp_path, monkeypatch): """cfg.model.base_url=https://openrouter.ai/api/v1 must NOT override aux base_url.""" - import yaml + import hermes_yaml as yaml from agent.auxiliary_client import _try_anthropic monkeypatch.setenv("HERMES_HOME", str(tmp_path)) (tmp_path / "config.yaml").write_text(yaml.safe_dump({ @@ -58,7 +58,7 @@ class TestTryAnthropicBaseUrlHostValidation: def test_anthropic_default_host_is_preserved(self, tmp_path, monkeypatch): """The common case (operator sets model.base_url to api.anthropic.com) must still apply.""" - import yaml + import hermes_yaml as yaml from agent.auxiliary_client import _try_anthropic monkeypatch.setenv("HERMES_HOME", str(tmp_path)) (tmp_path / "config.yaml").write_text(yaml.safe_dump({ @@ -90,7 +90,7 @@ class TestTryAnthropicBaseUrlHostValidation: def test_openai_base_url_does_not_leak(self, tmp_path, monkeypatch): """Generic non-Anthropic host must not be applied as auxiliary base_url.""" - import yaml + import hermes_yaml as yaml from agent.auxiliary_client import _try_anthropic monkeypatch.setenv("HERMES_HOME", str(tmp_path)) (tmp_path / "config.yaml").write_text(yaml.safe_dump({ @@ -125,7 +125,7 @@ class TestTryAnthropicBaseUrlHostValidation: def test_empty_base_url_falls_back_to_default(self, tmp_path, monkeypatch): """Empty model.base_url must not crash and must fall back to default.""" - import yaml + import hermes_yaml as yaml from agent.auxiliary_client import _try_anthropic monkeypatch.setenv("HERMES_HOME", str(tmp_path)) (tmp_path / "config.yaml").write_text(yaml.safe_dump({ @@ -159,7 +159,7 @@ class TestTryAnthropicBaseUrlHostValidation: """A gateway exposing the Messages protocol under a ``/anthropic`` suffix must be honored — the same convention the primary path already trusts — so auxiliary/fallback calls hit the configured endpoint, not the default.""" - import yaml + import hermes_yaml as yaml from agent.auxiliary_client import _try_anthropic monkeypatch.setenv("HERMES_HOME", str(tmp_path)) (tmp_path / "config.yaml").write_text(yaml.safe_dump({ @@ -204,7 +204,7 @@ class TestTryAnthropicBaseUrlHostValidation: def test_anthropic_host_with_path_is_preserved(self, tmp_path, monkeypatch): """api.anthropic.com with a path suffix must still pass the host check.""" - import yaml + import hermes_yaml as yaml from agent.auxiliary_client import _try_anthropic monkeypatch.setenv("HERMES_HOME", str(tmp_path)) (tmp_path / "config.yaml").write_text(yaml.safe_dump({ diff --git a/tests/agent/test_auxiliary_main_first.py b/tests/agent/test_auxiliary_main_first.py index 47923aedff..7068c015d0 100644 --- a/tests/agent/test_auxiliary_main_first.py +++ b/tests/agent/test_auxiliary_main_first.py @@ -94,7 +94,7 @@ class TestResolveAutoMainFirst: acting model). The virtual moa://local base_url + placeholder key must be dropped so the aggregator resolves via its own provider credentials. """ - import yaml + import hermes_yaml as yaml home = tmp_path / ".hermes" home.mkdir() diff --git a/tests/agent/test_auxiliary_named_custom_providers.py b/tests/agent/test_auxiliary_named_custom_providers.py index 40336b720c..dd46315b65 100644 --- a/tests/agent/test_auxiliary_named_custom_providers.py +++ b/tests/agent/test_auxiliary_named_custom_providers.py @@ -18,9 +18,9 @@ def _isolate(tmp_path, monkeypatch): def _write_config(tmp_path, config_dict): """Write a config.yaml to the test HERMES_HOME.""" - import yaml + import hermes_yaml as yaml config_path = tmp_path / ".hermes" / "config.yaml" - config_path.write_text(yaml.dump(config_dict)) + config_path.write_text(yaml.safe_dump(config_dict)) class TestNormalizeVisionProvider: diff --git a/tests/agent/test_auxiliary_user_default_headers.py b/tests/agent/test_auxiliary_user_default_headers.py index 2e9fafdb16..5d0aa4a1b1 100644 --- a/tests/agent/test_auxiliary_user_default_headers.py +++ b/tests/agent/test_auxiliary_user_default_headers.py @@ -24,8 +24,8 @@ def _isolate(tmp_path, monkeypatch): def _write_config(tmp_path, config_dict): - import yaml - (tmp_path / ".hermes" / "config.yaml").write_text(yaml.dump(config_dict)) + import hermes_yaml as yaml + (tmp_path / ".hermes" / "config.yaml").write_text(yaml.safe_dump(config_dict)) class TestApplyUserDefaultHeadersHelper: diff --git a/tests/agent/test_builtin_memory_disabled_surface.py b/tests/agent/test_builtin_memory_disabled_surface.py index 7d6c00a697..869031008f 100644 --- a/tests/agent/test_builtin_memory_disabled_surface.py +++ b/tests/agent/test_builtin_memory_disabled_surface.py @@ -16,7 +16,7 @@ import json from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml from model_tools import get_tool_definitions diff --git a/tests/agent/test_codex_usage_attribution.py b/tests/agent/test_codex_usage_attribution.py index 08d9e02aca..22473e71fd 100644 --- a/tests/agent/test_codex_usage_attribution.py +++ b/tests/agent/test_codex_usage_attribution.py @@ -10,7 +10,7 @@ from types import SimpleNamespace import httpx import pytest -import yaml +import hermes_yaml as yaml from hermes_cli import __version__ from hermes_constants import reset_hermes_home_override, set_hermes_home_override diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index 4213bb611f..eb66725656 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -1291,8 +1291,8 @@ def test_custom_endpoint_pool_seeds_from_config(tmp_path, monkeypatch): # Write config.yaml with a custom_providers entry config_path = tmp_path / "hermes" / "config.yaml" - import yaml - config_path.write_text(yaml.dump({ + import hermes_yaml as yaml + config_path.write_text(yaml.safe_dump({ "custom_providers": [ { "name": "Together.ai", @@ -1317,9 +1317,9 @@ def test_custom_endpoint_pool_seeds_from_model_config(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) _write_auth_store(tmp_path, {"version": 1}) - import yaml + import hermes_yaml as yaml config_path = tmp_path / "hermes" / "config.yaml" - config_path.write_text(yaml.dump({ + config_path.write_text(yaml.safe_dump({ "custom_providers": [ { "name": "Together.ai", diff --git a/tests/agent/test_i18n.py b/tests/agent/test_i18n.py index 57ed20300f..8906d46dcb 100644 --- a/tests/agent/test_i18n.py +++ b/tests/agent/test_i18n.py @@ -5,7 +5,7 @@ from __future__ import annotations from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml from agent import i18n diff --git a/tests/agent/test_image_gen_registry.py b/tests/agent/test_image_gen_registry.py index c8eb14991f..a64b8079ef 100644 --- a/tests/agent/test_image_gen_registry.py +++ b/tests/agent/test_image_gen_registry.py @@ -58,7 +58,7 @@ class TestGetActiveProvider: def test_explicit_config_wins(self, tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml monkeypatch.setenv("HERMES_HOME", str(tmp_path)) (tmp_path / "config.yaml").write_text( diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index 531cff0068..8229c7e8a6 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -14,7 +14,7 @@ import time from contextlib import contextmanager import pytest -import yaml +import hermes_yaml as yaml from unittest.mock import patch, MagicMock from agent.model_metadata import ( @@ -500,8 +500,8 @@ class TestCodexOAuthContextLength: base_url = "https://chatgpt.com/backend-api/codex" stale_key = f"gpt-5.5@{base_url}" other_key = "other-model@https://api.openai.com/v1/" - import yaml as _yaml - cache_file.write_text(_yaml.dump({"context_lengths": { + import hermes_yaml as _yaml + cache_file.write_text(_yaml.safe_dump({"context_lengths": { stale_key: stale_context, other_key: 128_000, }})) @@ -966,7 +966,7 @@ class TestNousPortalContextResolution: base_url = "https://inference-api.nousresearch.com/v1" stale_key = f"qwen3.6-plus@{base_url}" other_key = "other-model@https://api.openai.com/v1" - cache_file.write_text(yaml.dump({"context_lengths": { + cache_file.write_text(yaml.safe_dump({"context_lengths": { stale_key: 1_000_000, # pre-fix OR-derived value other_key: 128_000, # unrelated, must survive }})) diff --git a/tests/agent/test_onboarding.py b/tests/agent/test_onboarding.py index 514ada7f1e..c2f5f3fada 100644 --- a/tests/agent/test_onboarding.py +++ b/tests/agent/test_onboarding.py @@ -2,7 +2,7 @@ from __future__ import annotations -import yaml +import hermes_yaml as yaml from agent.onboarding import ( BUSY_INPUT_FLAG, diff --git a/tests/agent/test_probe_cache_followups.py b/tests/agent/test_probe_cache_followups.py index f108c0de4c..57e9a938e4 100644 --- a/tests/agent/test_probe_cache_followups.py +++ b/tests/agent/test_probe_cache_followups.py @@ -278,12 +278,12 @@ class TestContextCacheKeyNormalization: def test_invalidate_clears_both_key_shapes(self, tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml from agent import model_metadata path = tmp_path / "context_lengths.yaml" monkeypatch.setattr(model_metadata, "_get_context_cache_path", lambda: path) - path.write_text(yaml.dump({"context_lengths": { + path.write_text(yaml.safe_dump({"context_lengths": { "m1@http://host/v1": 128_000, "m1@http://host/v1/": 64_000, }})) diff --git a/tests/agent/test_skill_session_platform_gate.py b/tests/agent/test_skill_session_platform_gate.py index 9672e8f95b..b17b6f0ee0 100644 --- a/tests/agent/test_skill_session_platform_gate.py +++ b/tests/agent/test_skill_session_platform_gate.py @@ -39,7 +39,7 @@ class TestSessionPlatformGate: def test_teams_meeting_pipeline_carries_the_gate(self): from pathlib import Path - import re, yaml + import re, hermes_yaml as yaml p = Path(__file__).resolve().parents[2] / "skills" / "productivity" / "teams-meeting-pipeline" / "SKILL.md" content = p.read_text(encoding="utf-8") diff --git a/tests/agent/test_video_gen_registry.py b/tests/agent/test_video_gen_registry.py index f8e63c9d85..a776a3c575 100644 --- a/tests/agent/test_video_gen_registry.py +++ b/tests/agent/test_video_gen_registry.py @@ -74,7 +74,7 @@ class TestGetActiveProvider: def test_unknown_explicit_config_fails_closed(self, tmp_path, monkeypatch): """A typo must not silently route a paid request to another backend.""" - import yaml + import hermes_yaml as yaml monkeypatch.setenv("HERMES_HOME", str(tmp_path)) (tmp_path / "config.yaml").write_text( diff --git a/tests/ci/test_classify_changes.py b/tests/ci/test_classify_changes.py index 2217febd07..7a6a89b297 100644 --- a/tests/ci/test_classify_changes.py +++ b/tests/ci/test_classify_changes.py @@ -302,7 +302,7 @@ _REPO = Path(__file__).resolve().parents[2] def _yaml(rel: str) -> dict: - yaml = pytest.importorskip("yaml") + yaml = pytest.importorskip("hermes_yaml") return yaml.safe_load((_REPO / rel).read_text(encoding="utf-8")) diff --git a/tests/ci/test_desktop_release_tag_admission.py b/tests/ci/test_desktop_release_tag_admission.py index 9aec958937..cb6e7c319b 100644 --- a/tests/ci/test_desktop_release_tag_admission.py +++ b/tests/ci/test_desktop_release_tag_admission.py @@ -27,7 +27,7 @@ _SIGNING_ENV = "release-signing" def _workflow() -> dict: - yaml = pytest.importorskip("yaml") + yaml = pytest.importorskip("hermes_yaml") return yaml.safe_load(_WORKFLOW.read_text(encoding="utf-8")) diff --git a/tests/ci/test_live_comment.py b/tests/ci/test_live_comment.py index b4c1329eb0..f4540e0544 100644 --- a/tests/ci/test_live_comment.py +++ b/tests/ci/test_live_comment.py @@ -94,7 +94,7 @@ def test_workflow_watch_list_names_a_workflow_that_exists(): A name that matches nothing makes the poller silently drop that run from the comment, which no unit test on its own would notice. """ - yaml = pytest.importorskip("yaml") + yaml = pytest.importorskip("hermes_yaml") root = Path(__file__).resolve().parents[2] caller = yaml.safe_load( (root / ".github/workflows/ci-review-comment.yml").read_text(encoding="utf-8") @@ -123,7 +123,7 @@ def test_poller_never_watches_its_own_workflow(): itself would make the loop wait for itself and only ever exit on timeout. """ - yaml = pytest.importorskip("yaml") + yaml = pytest.importorskip("hermes_yaml") root = Path(__file__).resolve().parents[2] doc = yaml.safe_load( (root / ".github/workflows/ci-review-comment.yml").read_text(encoding="utf-8") diff --git a/tests/ci/test_stable_release_graph.py b/tests/ci/test_stable_release_graph.py index 6741370ebc..ff6ff91f04 100644 --- a/tests/ci/test_stable_release_graph.py +++ b/tests/ci/test_stable_release_graph.py @@ -1,13 +1,13 @@ """The release workflow's dependency graph enforces publication ordering.""" from pathlib import Path -import yaml +from ruamel.yaml import YAML ROOT = Path(__file__).resolve().parents[2] def workflow(name): - return yaml.load((ROOT / ".github/workflows" / name).read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + return YAML(typ="base").load((ROOT / ".github/workflows" / name).read_text(encoding="utf-8")) def ancestors(jobs, name): diff --git a/tests/ci/test_termux_input_archive.py b/tests/ci/test_termux_input_archive.py index e9be5d6019..beb99b8ff2 100644 --- a/tests/ci/test_termux_input_archive.py +++ b/tests/ci/test_termux_input_archive.py @@ -2,14 +2,14 @@ from pathlib import Path import shlex -import yaml +from ruamel.yaml import YAML ROOT = Path(__file__).resolve().parents[2] R2_ENV = {"CLOUDFLARE_R2_ACCOUNT_ID", "CLOUDFLARE_R2_ACCESS_KEY_ID", "CLOUDFLARE_R2_SECRET_ACCESS_KEY", "CLOUDFLARE_R2_BUCKET"} def load(name): - return yaml.load((ROOT / ".github/workflows" / name).read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + return YAML(typ="base").load((ROOT / ".github/workflows" / name).read_text(encoding="utf-8")) def test_termux_and_desktop_stagers_consume_archive_seeds(): @@ -54,7 +54,7 @@ def test_archive_gate_uses_bootstrap_python_and_trusted_exact_revision(): for name in ("build-win32", "build-darwin", "termux-deb"): assert "archive-inputs" in release[name]["needs"] - action = yaml.load((ROOT / ".github/actions/setup-pm/action.yml").read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + action = YAML(typ="base").load((ROOT / ".github/actions/setup-pm/action.yml").read_text(encoding="utf-8")) assert action["inputs"]["archive-inputs"]["default"] == "false" steps = action["runs"]["steps"] archive_index, archive = next((i, s) for i, s in enumerate(steps) if "setup_toolchain.py\" archive-inputs" in s.get("run", "")) diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index e53e7fd74f..b5838532b2 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -492,7 +492,7 @@ class TestRootLevelProviderOverride: def test_model_provider_wins_over_root_provider(self, tmp_path, monkeypatch): """model.provider takes priority — root-level provider is only a fallback.""" - import yaml + import hermes_yaml as yaml hermes_home = tmp_path / ".hermes" hermes_home.mkdir() @@ -515,7 +515,7 @@ class TestRootLevelProviderOverride: def test_root_provider_used_as_fallback_when_model_provider_missing(self, tmp_path, monkeypatch): """Legacy root-level provider still populates model.provider in the CLI loader.""" - import yaml + import hermes_yaml as yaml hermes_home = tmp_path / ".hermes" hermes_home.mkdir() @@ -538,7 +538,7 @@ class TestRootLevelProviderOverride: def test_root_base_url_used_as_fallback_when_model_base_url_missing(self, tmp_path, monkeypatch): """Legacy root-level base_url still populates model.base_url in the CLI loader.""" - import yaml + import hermes_yaml as yaml hermes_home = tmp_path / ".hermes" hermes_home.mkdir() @@ -560,7 +560,7 @@ class TestRootLevelProviderOverride: def test_terminal_vercel_runtime_bridged_to_env(self, tmp_path, monkeypatch): """Classic CLI must expose terminal.vercel_runtime to terminal_tool.py.""" - import yaml + import hermes_yaml as yaml hermes_home = tmp_path / ".hermes" hermes_home.mkdir() diff --git a/tests/cli/test_cli_mcp_config_watch.py b/tests/cli/test_cli_mcp_config_watch.py index acfc7a3473..29dae5c768 100644 --- a/tests/cli/test_cli_mcp_config_watch.py +++ b/tests/cli/test_cli_mcp_config_watch.py @@ -35,11 +35,11 @@ class TestMCPConfigWatch: def test_new_mcp_server_triggers_reload(self, tmp_path): """Adding a new MCP server to config triggers auto-reload.""" - import yaml + import hermes_yaml as yaml obj, cfg_file = _make_cli(tmp_path, mcp_servers={}) # Simulate user adding a new MCP server to config.yaml - cfg_file.write_text(yaml.dump({"mcp_servers": {"github": {"url": "https://mcp.github.com"}}})) + cfg_file.write_text(yaml.safe_dump({"mcp_servers": {"github": {"url": "https://mcp.github.com"}}})) obj._config_mtime = 0.0 # force stale mtime with patch("hermes_cli.config.get_config_path", return_value=cfg_file): @@ -49,11 +49,11 @@ class TestMCPConfigWatch: def test_removed_mcp_server_triggers_reload(self, tmp_path): """Removing an MCP server from config triggers auto-reload.""" - import yaml + import hermes_yaml as yaml obj, cfg_file = _make_cli(tmp_path, mcp_servers={"github": {"url": "https://mcp.github.com"}}) # Simulate user removing the server - cfg_file.write_text(yaml.dump({"mcp_servers": {}})) + cfg_file.write_text(yaml.safe_dump({"mcp_servers": {}})) obj._config_mtime = 0.0 with patch("hermes_cli.config.get_config_path", return_value=cfg_file): @@ -76,14 +76,14 @@ class TestMCPConfigWatch: flipping the toggle and editing mcp_servers in one edit behaves correctly. """ - import yaml + import hermes_yaml as yaml obj, cfg_file = _make_cli( tmp_path, mcp_servers={}, ) # Simulate a changed mcp_servers section with auto-reload opted out. - cfg_file.write_text(yaml.dump({ + cfg_file.write_text(yaml.safe_dump({ "mcp": {"auto_reload_on_config_change": False}, "mcp_servers": {"github": {"url": "https://mcp.github.com"}}, })) @@ -103,10 +103,10 @@ class TestMCPConfigWatch: """After an opted-out change, the watcher must not re-notify every tick: the snapshot is updated so the same content compares equal on the next pass.""" - import yaml + import hermes_yaml as yaml obj, cfg_file = _make_cli(tmp_path, mcp_servers={}) - cfg_file.write_text(yaml.dump({ + cfg_file.write_text(yaml.safe_dump({ "mcp": {"auto_reload_on_config_change": False}, "mcp_servers": {"github": {"url": "https://mcp.github.com"}}, })) @@ -129,13 +129,13 @@ class TestMCPConfigWatch: A config that sets ONLY ``auxiliary.mcp.auto_reload_on_config_change: false`` must NOT disable the reload.""" - import yaml + import hermes_yaml as yaml obj, cfg_file = _make_cli( tmp_path, mcp_servers={}, ) - cfg_file.write_text(yaml.dump({ + cfg_file.write_text(yaml.safe_dump({ "auxiliary": {"mcp": {"auto_reload_on_config_change": False}}, "mcp_servers": {"github": {"url": "https://mcp.github.com"}}, })) @@ -160,7 +160,7 @@ class TestMCPConfigWatch: save_config_value('agent.reasoning_effort', ...) from /reasoning) fired a full MCP reconnect. """ - import yaml + import hermes_yaml as yaml monkeypatch.setenv("MCP_GH_API_KEY", "sekrit-token") raw_servers = { @@ -180,7 +180,7 @@ class TestMCPConfigWatch: # Unrelated-key save: mcp_servers content identical (raw templates), # only reasoning_effort changed — mtime moves. - cfg_file.write_text(yaml.dump({ + cfg_file.write_text(yaml.safe_dump({ "agent": {"reasoning_effort": "high"}, "mcp_servers": raw_servers, })) diff --git a/tests/cli/test_cli_provider_resolution.py b/tests/cli/test_cli_provider_resolution.py index fefd8c0b3b..c7457bbbd2 100644 --- a/tests/cli/test_cli_provider_resolution.py +++ b/tests/cli/test_cli_provider_resolution.py @@ -288,7 +288,7 @@ def test_cli_turn_routing_uses_primary_when_disabled(monkeypatch): def test_model_flow_nous_does_not_restore_stale_custom_api_key(tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml config_home = tmp_path / "hermes" config_home.mkdir() @@ -358,7 +358,7 @@ def test_model_flow_nous_does_not_restore_stale_custom_api_key(tmp_path, monkeyp def _seed_stale_custom_model(tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml config_home = tmp_path / "hermes" config_home.mkdir() @@ -619,11 +619,11 @@ def test_auto_provider_name_localhost(): def test_save_custom_provider_uses_provided_name(monkeypatch, tmp_path): """When a display name is passed, it should appear in the saved entry.""" - import yaml + import hermes_yaml as yaml from hermes_cli.main_provider_setup import _save_custom_provider cfg_path = tmp_path / "config.yaml" - cfg_path.write_text(yaml.dump({})) + cfg_path.write_text(yaml.safe_dump({})) monkeypatch.setattr( "hermes_cli.config.load_config", lambda: yaml.safe_load(cfg_path.read_text()) or {}, @@ -641,11 +641,11 @@ def test_save_custom_provider_uses_provided_name(monkeypatch, tmp_path): def test_save_custom_provider_references_the_key_instead_of_inlining_it(monkeypatch, tmp_path): """With key_env set the entry must not carry the secret (#69449).""" - import yaml + import hermes_yaml as yaml from hermes_cli.main_provider_setup import _save_custom_provider cfg_path = tmp_path / "config.yaml" - cfg_path.write_text(yaml.dump({})) + cfg_path.write_text(yaml.safe_dump({})) monkeypatch.setattr( "hermes_cli.config.load_config", lambda: yaml.safe_load(cfg_path.read_text()) or {}, ) diff --git a/tests/cli/test_cli_save_config_value.py b/tests/cli/test_cli_save_config_value.py index 75039d9355..af8272421f 100644 --- a/tests/cli/test_cli_save_config_value.py +++ b/tests/cli/test_cli_save_config_value.py @@ -3,7 +3,7 @@ from pathlib import Path from unittest.mock import MagicMock -import yaml +import hermes_yaml as yaml import pytest @@ -17,7 +17,7 @@ class TestSaveConfigValueAtomic: hermes_home = tmp_path / ".hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" - config_path.write_text(yaml.dump({ + config_path.write_text(yaml.safe_dump({ "model": {"default": "test-model", "provider": "openrouter"}, "display": {"skin": "default"}, })) diff --git a/tests/cli/test_personality_none.py b/tests/cli/test_personality_none.py index 2d94015dfb..3dece68f2c 100644 --- a/tests/cli/test_personality_none.py +++ b/tests/cli/test_personality_none.py @@ -7,7 +7,7 @@ persistence flows exclusively through persist_personality(). import os import pytest from unittest.mock import MagicMock, patch -import yaml +import hermes_yaml as yaml # ── CLI tests ────────────────────────────────────────────────────────────── @@ -114,7 +114,7 @@ class TestGatewayPersonalityNone: "display": {"personality": "helpful"}, } config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump(config_data)) + config_file.write_text(yaml.safe_dump(config_data)) p1, p2 = self._gateway_env(tmp_path) with p1, p2: @@ -138,7 +138,7 @@ class TestGatewayPersonalityNone: } } config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump(config_data)) + config_file.write_text(yaml.safe_dump(config_data)) p1, p2 = self._gateway_env(tmp_path) with p1, p2: @@ -157,7 +157,7 @@ class TestGatewayPersonalityNone: runner = self._make_runner() config_data = {"agent": {"personalities": {"helpful": "You are helpful."}}} config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump(config_data)) + config_file.write_text(yaml.safe_dump(config_data)) p1, p2 = self._gateway_env(tmp_path) with p1, p2: @@ -171,7 +171,7 @@ class TestGatewayPersonalityNone: # Built-ins are always available — an empty agent.personalities no # longer means "no personalities configured". runner = self._make_runner(personalities={}) - (tmp_path / "config.yaml").write_text(yaml.dump({"agent": {"personalities": {}}})) + (tmp_path / "config.yaml").write_text(yaml.safe_dump({"agent": {"personalities": {}}})) p1, p2 = self._gateway_env(tmp_path) with p1, p2: diff --git a/tests/cron/test_cron_multiplex_profile_route_preflight.py b/tests/cron/test_cron_multiplex_profile_route_preflight.py index 780648debf..703a40bf73 100644 --- a/tests/cron/test_cron_multiplex_profile_route_preflight.py +++ b/tests/cron/test_cron_multiplex_profile_route_preflight.py @@ -15,7 +15,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch import pytest -import yaml +import hermes_yaml as yaml from cron.scheduler_preflight import ( _delivery_platform_routed_from_primary_gateway, diff --git a/tests/cron/test_cron_multiplex_shared_route_delivery.py b/tests/cron/test_cron_multiplex_shared_route_delivery.py index 6802d22119..ea4bab0742 100644 --- a/tests/cron/test_cron_multiplex_shared_route_delivery.py +++ b/tests/cron/test_cron_multiplex_shared_route_delivery.py @@ -10,7 +10,7 @@ import asyncio from concurrent.futures import Future from unittest.mock import MagicMock, patch -import yaml +import hermes_yaml as yaml from cron.scheduler import _deliver_result from cron.scheduler_preflight import SharedRouteAdapters, _primary_profile_routes_for_current_home diff --git a/tests/cron/test_sessiondb_init_hang.py b/tests/cron/test_sessiondb_init_hang.py index b2f0f46465..da9e4bf563 100644 --- a/tests/cron/test_sessiondb_init_hang.py +++ b/tests/cron/test_sessiondb_init_hang.py @@ -195,7 +195,7 @@ class TestSessionDbInitTimeout: def test_timeout_resolved_from_config_yaml(self, tmp_path, monkeypatch): """cron.session_db_timeout_seconds in config.yaml is respected when the env var is not set — the canonical config-first resolution path.""" - import yaml + import hermes_yaml as yaml monkeypatch.delenv("HERMES_CRON_SESSION_DB_TIMEOUT", raising=False) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) diff --git a/tests/docker/test_image_payload.py b/tests/docker/test_image_payload.py index 105e033820..1775374154 100644 --- a/tests/docker/test_image_payload.py +++ b/tests/docker/test_image_payload.py @@ -17,7 +17,7 @@ assert not list(store.glob('fetch-*')), 'completed download archives must not sh fact = Facts(store / 'facts.json').get('python') expected = get_package('python').binary(store / fact['entry'], current_target()) assert Path(sys._base_executable).resolve() == expected.resolve() -import yaml +import hermes_yaml as yaml print('PM interpreter and application dependencies load as hermes') """ result = subprocess.run( diff --git a/tests/gateway/relay/test_cold_opt_out.py b/tests/gateway/relay/test_cold_opt_out.py index ac6705e5db..76603734c4 100644 --- a/tests/gateway/relay/test_cold_opt_out.py +++ b/tests/gateway/relay/test_cold_opt_out.py @@ -7,7 +7,7 @@ import sys import textwrap import pytest -import yaml +import hermes_yaml as yaml @pytest.mark.parametrize("case", ["native", "url-only", "disabled", "managed", "managed-only", "scoped"]) diff --git a/tests/gateway/relay/test_explicit_disable.py b/tests/gateway/relay/test_explicit_disable.py index fb3114b264..0eb3025900 100644 --- a/tests/gateway/relay/test_explicit_disable.py +++ b/tests/gateway/relay/test_explicit_disable.py @@ -8,7 +8,7 @@ import json import os import pytest -import yaml +import hermes_yaml as yaml import gateway.relay as relay from gateway.config import Platform, load_gateway_config diff --git a/tests/gateway/test_25107_stale_base_url_api_mode.py b/tests/gateway/test_25107_stale_base_url_api_mode.py index 91e68df554..c624f30f3f 100644 --- a/tests/gateway/test_25107_stale_base_url_api_mode.py +++ b/tests/gateway/test_25107_stale_base_url_api_mode.py @@ -25,7 +25,7 @@ resolved result. import types -import yaml +import hermes_yaml as yaml import pytest from gateway.config import Platform diff --git a/tests/gateway/test_approvals_command.py b/tests/gateway/test_approvals_command.py index 6978ae06c1..73001374e4 100644 --- a/tests/gateway/test_approvals_command.py +++ b/tests/gateway/test_approvals_command.py @@ -4,7 +4,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest -import yaml +import hermes_yaml as yaml import gateway.run as gateway_run from gateway.config import Platform diff --git a/tests/gateway/test_busy_session_ack.py b/tests/gateway/test_busy_session_ack.py index f28d847a9e..f9e6a554e1 100644 --- a/tests/gateway/test_busy_session_ack.py +++ b/tests/gateway/test_busy_session_ack.py @@ -493,7 +493,7 @@ class TestBusySessionOnboardingHint: assert "/busy queue" in content # The flag is now persisted to tmp_path/config.yaml - import yaml + import hermes_yaml as yaml cfg = yaml.safe_load((tmp_path / "config.yaml").read_text()) assert cfg["onboarding"]["seen"]["busy_input_prompt"] is True diff --git a/tests/gateway/test_buzz_adapter.py b/tests/gateway/test_buzz_adapter.py index 11d39610e9..3c747e1d8d 100644 --- a/tests/gateway/test_buzz_adapter.py +++ b/tests/gateway/test_buzz_adapter.py @@ -285,7 +285,7 @@ class TestMultiplexProfileScope: ): """The gate must consult the profile's own config.yaml + secret scope, not the default profile's env values.""" - import yaml + import hermes_yaml as yaml from hermes_constants import ( reset_hermes_home_override, set_hermes_home_override, diff --git a/tests/gateway/test_choice_picker.py b/tests/gateway/test_choice_picker.py index 5de1359beb..701c6416d5 100644 --- a/tests/gateway/test_choice_picker.py +++ b/tests/gateway/test_choice_picker.py @@ -11,7 +11,7 @@ import asyncio from unittest.mock import AsyncMock, MagicMock import pytest -import yaml +import hermes_yaml as yaml import gateway.run as gateway_run from gateway.config import Platform diff --git a/tests/gateway/test_cjk_fts_config_bridge.py b/tests/gateway/test_cjk_fts_config_bridge.py index 54c738675f..a8181e52d9 100644 --- a/tests/gateway/test_cjk_fts_config_bridge.py +++ b/tests/gateway/test_cjk_fts_config_bridge.py @@ -8,7 +8,7 @@ from __future__ import annotations import os from pathlib import Path -import yaml +import hermes_yaml as yaml import gateway.run as gateway_run diff --git a/tests/gateway/test_config_env_bridge_authority.py b/tests/gateway/test_config_env_bridge_authority.py index 594f5182ae..83751e1e22 100644 --- a/tests/gateway/test_config_env_bridge_authority.py +++ b/tests/gateway/test_config_env_bridge_authority.py @@ -100,7 +100,7 @@ def _run_gateway_import(hermes_home: Path, initial_env: dict[str, str]) -> dict[ def _write_config(home: Path, agent_cfg: dict | None = None, display_cfg: dict | None = None, timezone: str | None = None, gateway_cfg: dict | None = None) -> None: - import yaml + import hermes_yaml as yaml cfg: dict = {} if agent_cfg: cfg["agent"] = agent_cfg diff --git a/tests/gateway/test_discord_channel_controls.py b/tests/gateway/test_discord_channel_controls.py index 03a210360b..a173b14941 100644 --- a/tests/gateway/test_discord_channel_controls.py +++ b/tests/gateway/test_discord_channel_controls.py @@ -221,9 +221,9 @@ async def test_auto_thread_failure_skips_agent_and_notifies_user(adapter, monkey def test_config_bridges_ignored_channels(monkeypatch, tmp_path): """gateway/config.py bridges discord.ignored_channels to env var.""" - import yaml + import hermes_yaml as yaml config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump({ + config_file.write_text(yaml.safe_dump({ "discord": { "ignored_channels": ["111", "222"], }, diff --git a/tests/gateway/test_display_config.py b/tests/gateway/test_display_config.py index 3a74058978..e17f380356 100644 --- a/tests/gateway/test_display_config.py +++ b/tests/gateway/test_display_config.py @@ -178,7 +178,7 @@ class TestConfigMigration: def test_migration_creates_platforms_entries(self, tmp_path, monkeypatch): """Old overrides are migrated into display.platforms..tool_progress.""" - import yaml + import hermes_yaml as yaml config_path = tmp_path / "config.yaml" config = { @@ -190,7 +190,7 @@ class TestConfigMigration: }, }, } - config_path.write_text(yaml.dump(config), encoding="utf-8") + config_path.write_text(yaml.safe_dump(config), encoding="utf-8") monkeypatch.setenv("HERMES_HOME", str(tmp_path)) # Re-import to pick up the new HERMES_HOME diff --git a/tests/gateway/test_dm_topics.py b/tests/gateway/test_dm_topics.py index d1cc996101..7c5c7ab894 100644 --- a/tests/gateway/test_dm_topics.py +++ b/tests/gateway/test_dm_topics.py @@ -170,7 +170,7 @@ async def test_ensure_dm_topic_creates_on_demand_and_persists(): def test_persist_dm_topic_thread_id_writes_config(tmp_path): """Should write thread_id into the correct topic in config.yaml.""" - import yaml + import hermes_yaml as yaml config_data = { "platforms": { @@ -193,7 +193,7 @@ def test_persist_dm_topic_thread_id_writes_config(tmp_path): config_file = tmp_path / ".hermes" / "config.yaml" config_file.parent.mkdir(parents=True) with open(config_file, "w") as f: - yaml.dump(config_data, f) + yaml.safe_dump(config_data, f) adapter = _make_adapter() @@ -214,7 +214,7 @@ def test_persist_dm_topic_thread_id_writes_config(tmp_path): def test_persist_dm_topic_thread_id_preserves_config_on_write_failure(tmp_path): """Failed writes should leave the original config.yaml intact.""" - import yaml + import hermes_yaml as yaml config_data = { "platforms": { @@ -235,7 +235,7 @@ def test_persist_dm_topic_thread_id_preserves_config_on_write_failure(tmp_path): config_file = tmp_path / ".hermes" / "config.yaml" config_file.parent.mkdir(parents=True) - original_text = yaml.dump(config_data) + original_text = yaml.safe_dump(config_data) config_file.write_text(original_text, encoding="utf-8") adapter = _make_adapter() @@ -245,7 +245,7 @@ def test_persist_dm_topic_thread_id_preserves_config_on_write_failure(tmp_path): with patch.object(Path, "home", return_value=tmp_path), \ patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}), \ - patch("yaml.dump", side_effect=fail_dump): + patch("hermes_yaml.safe_dump", side_effect=fail_dump): adapter._persist_dm_topic_thread_id(111, "General", 999) assert config_file.read_text(encoding="utf-8") == original_text @@ -275,7 +275,7 @@ def test_get_dm_topic_info_finds_cached_topic(): def test_get_dm_topic_info_hot_reloads_from_config(tmp_path): """Should find a topic added to config after startup (hot-reload).""" - import yaml + import hermes_yaml as yaml # Start with empty topics adapter = _make_adapter([ @@ -302,7 +302,7 @@ def test_get_dm_topic_info_hot_reloads_from_config(tmp_path): config_file = tmp_path / ".hermes" / "config.yaml" config_file.parent.mkdir(parents=True) with open(config_file, "w") as f: - yaml.dump(config_data, f) + yaml.safe_dump(config_data, f) with patch.object(Path, "home", return_value=tmp_path), \ patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}): diff --git a/tests/gateway/test_fast_command.py b/tests/gateway/test_fast_command.py index 1d9a63514f..8ba4c73f19 100644 --- a/tests/gateway/test_fast_command.py +++ b/tests/gateway/test_fast_command.py @@ -7,7 +7,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest -import yaml +import hermes_yaml as yaml import gateway.run as gateway_run from gateway.config import Platform diff --git a/tests/gateway/test_matrix_mention.py b/tests/gateway/test_matrix_mention.py index 12a9d54963..54fe03ef30 100644 --- a/tests/gateway/test_matrix_mention.py +++ b/tests/gateway/test_matrix_mention.py @@ -351,10 +351,10 @@ class TestMatrixConfigBridge: import os - import yaml + import hermes_yaml as yaml config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump(yaml_content)) + config_file.write_text(yaml.safe_dump(yaml_content)) # Simulate the bridge logic from gateway/config.py yaml_cfg = yaml.safe_load(config_file.read_text()) diff --git a/tests/gateway/test_model_command_context_offload.py b/tests/gateway/test_model_command_context_offload.py index 1591c2b2e6..4978fb0657 100644 --- a/tests/gateway/test_model_command_context_offload.py +++ b/tests/gateway/test_model_command_context_offload.py @@ -43,7 +43,7 @@ def _event(text: str) -> MessageEvent: def _runner_with_store(tmp_path, monkeypatch): """Minimal GatewayRunner harness driving the real /model handler.""" - import yaml as _yaml + import hermes_yaml as _yaml import gateway.run as gateway_run from gateway.run import GatewayRunner diff --git a/tests/gateway/test_model_command_custom_providers.py b/tests/gateway/test_model_command_custom_providers.py index 5b32ab56d8..1b1f4302f8 100644 --- a/tests/gateway/test_model_command_custom_providers.py +++ b/tests/gateway/test_model_command_custom_providers.py @@ -1,6 +1,6 @@ """Regression tests for gateway /model support of config.yaml custom_providers.""" -import yaml +import hermes_yaml as yaml import pytest from gateway.config import Platform diff --git a/tests/gateway/test_model_command_expensive_confirm.py b/tests/gateway/test_model_command_expensive_confirm.py index 18fa58ce92..75b8295676 100644 --- a/tests/gateway/test_model_command_expensive_confirm.py +++ b/tests/gateway/test_model_command_expensive_confirm.py @@ -16,7 +16,7 @@ These tests pin the typed path: from types import SimpleNamespace import pytest -import yaml +import hermes_yaml as yaml from gateway.config import Platform from gateway.platforms.event import MessageEvent, MessageType diff --git a/tests/gateway/test_model_command_flat_string_config.py b/tests/gateway/test_model_command_flat_string_config.py index 3dc1d9b871..81fbbb9988 100644 --- a/tests/gateway/test_model_command_flat_string_config.py +++ b/tests/gateway/test_model_command_flat_string_config.py @@ -11,7 +11,7 @@ before mutation, so ``--global`` succeeds and the config is rewritten in the proper ``model: {default: ..., provider: ...}`` form. """ -import yaml +import hermes_yaml as yaml import pytest from gateway.config import Platform diff --git a/tests/gateway/test_model_picker_persist.py b/tests/gateway/test_model_picker_persist.py index bcc24263c4..e034480d1e 100644 --- a/tests/gateway/test_model_picker_persist.py +++ b/tests/gateway/test_model_picker_persist.py @@ -21,7 +21,7 @@ closure the PR changed, against a real temp ``HERMES_HOME``. import types -import yaml +import hermes_yaml as yaml import pytest from gateway.config import Platform diff --git a/tests/gateway/test_model_switch_persistence.py b/tests/gateway/test_model_switch_persistence.py index a668518a3e..6389a25b93 100644 --- a/tests/gateway/test_model_switch_persistence.py +++ b/tests/gateway/test_model_switch_persistence.py @@ -203,7 +203,7 @@ class TestOneTurnNeverPersisted: @staticmethod def _runner_with_store(tmp_path, monkeypatch): - import yaml as _yaml + import hermes_yaml as _yaml import gateway.run as gateway_run from gateway.run import GatewayRunner diff --git a/tests/gateway/test_multiplex_phase0.py b/tests/gateway/test_multiplex_phase0.py index 6558d67f54..0ea1168d89 100644 --- a/tests/gateway/test_multiplex_phase0.py +++ b/tests/gateway/test_multiplex_phase0.py @@ -11,7 +11,7 @@ Covers the three Phase 0 deliverables: import pytest from datetime import datetime from unittest.mock import patch -import yaml +import hermes_yaml as yaml from hermes_constants import reset_hermes_home_override, set_hermes_home_override from gateway.config import GatewayConfig, Platform diff --git a/tests/gateway/test_plugin_message_injection.py b/tests/gateway/test_plugin_message_injection.py index ed990550ed..6093c9c97b 100644 --- a/tests/gateway/test_plugin_message_injection.py +++ b/tests/gateway/test_plugin_message_injection.py @@ -7,7 +7,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest -import yaml +import hermes_yaml as yaml from gateway.config import GatewayConfig, Platform from gateway.platforms.base import ( diff --git a/tests/gateway/test_profile_routing.py b/tests/gateway/test_profile_routing.py index 37ebb8f69a..5bfa86d200 100644 --- a/tests/gateway/test_profile_routing.py +++ b/tests/gateway/test_profile_routing.py @@ -53,7 +53,7 @@ class TestParseProfileRoutes: assert parse_profile_routes([]) == [] def test_coerces_yaml_native_int_ids_to_str(self): - # PyYAML loads unquoted snowflakes / negative Telegram ids as int; + # YAML loads unquoted snowflakes / negative Telegram ids as int; # inbound SessionSource ids are str, so un-coerced routes never match. routes = parse_profile_routes([ {"name": "server", "platform": "discord", "profile": "p", diff --git a/tests/gateway/test_reasoning_command.py b/tests/gateway/test_reasoning_command.py index de83c96566..b4744f2b99 100644 --- a/tests/gateway/test_reasoning_command.py +++ b/tests/gateway/test_reasoning_command.py @@ -7,7 +7,7 @@ import types from unittest.mock import AsyncMock, MagicMock import pytest -import yaml +import hermes_yaml as yaml import gateway.run as gateway_run from gateway.config import Platform diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index 804b6274ec..279b5cecb9 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -489,9 +489,9 @@ async def test_run_agent_progress_uses_event_message_id_for_slack_dm(monkeypatch # Since PR #8006, Slack's built-in display tier sets tool_progress="off" # by default. Override via config so this test still exercises the # progress-callback path the Slack DM event_message_id threading depends on. - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( - yaml.dump({"display": {"platforms": {"slack": {"tool_progress": "all"}}}}), + yaml.safe_dump({"display": {"platforms": {"slack": {"tool_progress": "all"}}}}), encoding="utf-8", ) @@ -545,9 +545,9 @@ async def test_progress_carries_anchor_for_relay_discord_auto_thread(monkeypatch SAME auto-thread as the final reply — otherwise the search-status updates leak into the parent channel (staging repro 2026-08-02).""" monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( - yaml.dump({"display": {"platforms": {"discord": {"tool_progress": "all"}}}}), + yaml.safe_dump({"display": {"platforms": {"discord": {"tool_progress": "all"}}}}), encoding="utf-8", ) @@ -604,9 +604,9 @@ async def test_progress_no_anchor_for_native_discord_thread_event(monkeypatch, t auto-thread lane) must NOT get the synthetic prospective anchor — it already routes by its real thread. Guards against over-broadening the relay fix.""" monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( - yaml.dump({"display": {"platforms": {"discord": {"tool_progress": "all"}}}}), + yaml.safe_dump({"display": {"platforms": {"discord": {"tool_progress": "all"}}}}), encoding="utf-8", ) @@ -687,7 +687,7 @@ def _run_long_preview_helper(monkeypatch, tmp_path, preview_length=0): that _run_agent reads — so the gateway picks it up the same way production does. """ import asyncio - import yaml + import hermes_yaml as yaml monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") @@ -701,7 +701,7 @@ def _run_long_preview_helper(monkeypatch, tmp_path, preview_length=0): # Write config.yaml so _run_agent picks up tool_preview_length config = {"display": {"tool_preview_length": preview_length}} - (tmp_path / "config.yaml").write_text(yaml.dump(config), encoding="utf-8") + (tmp_path / "config.yaml").write_text(yaml.safe_dump(config), encoding="utf-8") adapter = ProgressCaptureAdapter() runner = _make_runner(adapter) @@ -746,7 +746,7 @@ def test_all_mode_respects_custom_preview_length(monkeypatch, tmp_path): def test_discord_truncated_tool_url_links_to_full_destination(monkeypatch, tmp_path): """The real gateway path must retain the URL beyond its visible cap.""" - import yaml + import hermes_yaml as yaml monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") @@ -759,7 +759,7 @@ def test_discord_truncated_tool_url_links_to_full_destination(monkeypatch, tmp_p monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) (tmp_path / "config.yaml").write_text( - yaml.dump({"display": {"tool_preview_length": 0}}), + yaml.safe_dump({"display": {"tool_preview_length": 0}}), encoding="utf-8", ) @@ -1010,9 +1010,9 @@ async def _run_with_agent( scope_id=None, ): if config_data: - import yaml + import hermes_yaml as yaml - (tmp_path / "config.yaml").write_text(yaml.dump(config_data), encoding="utf-8") + (tmp_path / "config.yaml").write_text(yaml.safe_dump(config_data), encoding="utf-8") fake_dotenv = types.ModuleType("dotenv") fake_dotenv.load_dotenv = lambda *args, **kwargs: None @@ -1501,10 +1501,10 @@ async def test_base_processing_stops_typing_before_hung_post_delivery_callback( @pytest.mark.asyncio async def test_run_agent_drops_tool_progress_after_generation_invalidation(monkeypatch, tmp_path): - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( - yaml.dump({"display": {"tool_progress": "all"}}), + yaml.safe_dump({"display": {"tool_progress": "all"}}), encoding="utf-8", ) @@ -1563,10 +1563,10 @@ async def test_run_agent_drops_tool_progress_after_generation_invalidation(monke @pytest.mark.asyncio async def test_run_agent_drops_interim_commentary_after_generation_invalidation(monkeypatch, tmp_path): - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( - yaml.dump({"display": {"tool_progress": "off", "interim_assistant_messages": True}}), + yaml.safe_dump({"display": {"tool_progress": "off", "interim_assistant_messages": True}}), encoding="utf-8", ) diff --git a/tests/gateway/test_runtime_env_reload_config_authority.py b/tests/gateway/test_runtime_env_reload_config_authority.py index 0ed73e587c..d0f017abca 100644 --- a/tests/gateway/test_runtime_env_reload_config_authority.py +++ b/tests/gateway/test_runtime_env_reload_config_authority.py @@ -10,7 +10,7 @@ from __future__ import annotations import os from pathlib import Path -import yaml +import hermes_yaml as yaml from gateway import run as gateway_run diff --git a/tests/gateway/test_silent_partial_delivery_95382.py b/tests/gateway/test_silent_partial_delivery_95382.py index 1f41063771..d352ec90dd 100644 --- a/tests/gateway/test_silent_partial_delivery_95382.py +++ b/tests/gateway/test_silent_partial_delivery_95382.py @@ -274,10 +274,10 @@ def _make_runner(adapter): async def _run_turn(monkeypatch, tmp_path, *, consumer_cls=None, session_id): - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( - yaml.dump( + yaml.safe_dump( { "display": {"tool_progress": "off", "interim_assistant_messages": False}, "streaming": { diff --git a/tests/gateway/test_slack_model_picker.py b/tests/gateway/test_slack_model_picker.py index 9a0d27ff78..ee07b5e69f 100644 --- a/tests/gateway/test_slack_model_picker.py +++ b/tests/gateway/test_slack_model_picker.py @@ -626,7 +626,7 @@ class TestSlackModelPickerGatewayIntegration: async def test_bare_model_triggers_picker(self, tmp_path, monkeypatch): import types - import yaml + import hermes_yaml as yaml from gateway.platforms.event import MessageEvent, MessageType from gateway.session import SessionSource @@ -682,7 +682,7 @@ class TestSlackModelPickerGatewayIntegration: @pytest.mark.asyncio async def test_text_fallback_when_no_picker(self, tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml from gateway.platforms.event import MessageEvent, MessageType from gateway.session import SessionSource diff --git a/tests/gateway/test_slash_config_writes_routed_profile.py b/tests/gateway/test_slash_config_writes_routed_profile.py index c1893156f5..18c5a119d2 100644 --- a/tests/gateway/test_slash_config_writes_routed_profile.py +++ b/tests/gateway/test_slash_config_writes_routed_profile.py @@ -12,7 +12,7 @@ through ``_gateway_config_home()`` like the reads do. from __future__ import annotations import pytest -import yaml +import hermes_yaml as yaml import gateway.run as gateway_run from gateway.run import GatewayRunner, _profile_runtime_scope diff --git a/tests/gateway/test_stale_finalize_suppression.py b/tests/gateway/test_stale_finalize_suppression.py index 679e4f7e3e..aba2d4b5a9 100644 --- a/tests/gateway/test_stale_finalize_suppression.py +++ b/tests/gateway/test_stale_finalize_suppression.py @@ -159,10 +159,10 @@ def _make_runner(adapter): async def _run_streaming_turn(monkeypatch, tmp_path, agent_cls, session_id): - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( - yaml.dump( + yaml.safe_dump( { "display": {"tool_progress": "off", "interim_assistant_messages": False}, "streaming": { @@ -289,10 +289,10 @@ async def test_payload_less_split_does_not_suppress_complete_response( monkeypatch, tmp_path ): """#78541 — payload-less split-delivery flags must not swallow the reply.""" - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( - yaml.dump( + yaml.safe_dump( { "display": {"tool_progress": "off", "interim_assistant_messages": False}, "streaming": { diff --git a/tests/gateway/test_stt_config.py b/tests/gateway/test_stt_config.py index 4f72c58c4d..bf9cef67d6 100644 --- a/tests/gateway/test_stt_config.py +++ b/tests/gateway/test_stt_config.py @@ -4,7 +4,7 @@ from pathlib import Path from unittest.mock import AsyncMock, patch import pytest -import yaml +import hermes_yaml as yaml from gateway.config import GatewayConfig, Platform, load_gateway_config from gateway.platforms.event import MessageEvent, MessageType @@ -20,7 +20,7 @@ def test_load_gateway_config_bridges_stt_enabled_from_config_yaml(tmp_path, monk hermes_home = tmp_path / ".hermes" hermes_home.mkdir() (hermes_home / "config.yaml").write_text( - yaml.dump({"stt": {"enabled": False}}), + yaml.safe_dump({"stt": {"enabled": False}}), encoding="utf-8", ) diff --git a/tests/gateway/test_telegram_reactions.py b/tests/gateway/test_telegram_reactions.py index 4c2377db37..ff950a7666 100644 --- a/tests/gateway/test_telegram_reactions.py +++ b/tests/gateway/test_telegram_reactions.py @@ -135,9 +135,9 @@ async def test_clear_reactions_handles_api_error_gracefully(monkeypatch): def test_config_bridges_telegram_reactions(monkeypatch, tmp_path): """gateway/config.py bridges telegram.reactions to TELEGRAM_REACTIONS env var.""" - import yaml + import hermes_yaml as yaml config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump({ + config_file.write_text(yaml.safe_dump({ "telegram": { "reactions": True, }, diff --git a/tests/gateway/test_verbose_command.py b/tests/gateway/test_verbose_command.py index d9cc47969a..83f6745a21 100644 --- a/tests/gateway/test_verbose_command.py +++ b/tests/gateway/test_verbose_command.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest -import yaml +import hermes_yaml as yaml import gateway.run as gateway_run from gateway.config import Platform diff --git a/tests/hermes_cli/test_agent_import.py b/tests/hermes_cli/test_agent_import.py index 1f910d37dd..5b9e99942f 100644 --- a/tests/hermes_cli/test_agent_import.py +++ b/tests/hermes_cli/test_agent_import.py @@ -14,7 +14,7 @@ import json from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.agent_import import ( ENTRY_DELIMITER, diff --git a/tests/hermes_cli/test_agent_plugins.py b/tests/hermes_cli/test_agent_plugins.py index bed441915e..cd8a65646b 100644 --- a/tests/hermes_cli/test_agent_plugins.py +++ b/tests/hermes_cli/test_agent_plugins.py @@ -31,7 +31,7 @@ def _write_skill(root: Path, directory: str = "summarize", **fields: object) -> skill_dir.mkdir(parents=True) metadata = {"name": directory, "description": "Summarizes reports."} metadata.update(fields) - import yaml + import hermes_yaml as yaml (skill_dir / "SKILL.md").write_text( f"---\n{yaml.safe_dump(metadata, sort_keys=False)}---\nInstructions.\n", diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index 1dd8abb4dd..2f104ea488 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -547,12 +547,12 @@ class TestHasAnyProviderConfigured: def test_config_provider_counts(self, monkeypatch, tmp_path): """config.yaml with model.provider set should count as configured.""" - import yaml + import hermes_yaml as yaml from hermes_cli import config as config_module hermes_home = tmp_path / ".hermes" hermes_home.mkdir() config_file = hermes_home / "config.yaml" - config_file.write_text(yaml.dump({ + config_file.write_text(yaml.safe_dump({ "model": {"default": "anthropic/claude-opus-4.6", "provider": "openrouter"}, })) monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env") @@ -596,9 +596,9 @@ class TestHasAnyProviderConfigured: loop in ``except Exception``, so we also record every call — any recorded call proves the sweep ran even if the raise was swallowed. """ - import yaml + import hermes_yaml as yaml hermes_home = self._setup_home(monkeypatch, tmp_path) - (hermes_home / "config.yaml").write_text(yaml.dump({ + (hermes_home / "config.yaml").write_text(yaml.safe_dump({ "model": {"default": "anthropic/claude-opus-4.6", "provider": "openrouter"}, })) sweep_calls = [] @@ -617,9 +617,9 @@ class TestHasAnyProviderConfigured: def test_config_base_url_api_key_skips_registry_sweep(self, monkeypatch, tmp_path): """Custom endpoint (base_url/api_key in config, no provider) must also short-circuit before the registry sweep.""" - import yaml + import hermes_yaml as yaml hermes_home = self._setup_home(monkeypatch, tmp_path) - (hermes_home / "config.yaml").write_text(yaml.dump({ + (hermes_home / "config.yaml").write_text(yaml.safe_dump({ "model": { "default": "local/custom-model", "base_url": "http://localhost:8000/v1", diff --git a/tests/hermes_cli/test_approval_transport.py b/tests/hermes_cli/test_approval_transport.py index e79d10e1f6..db7a423c80 100644 --- a/tests/hermes_cli/test_approval_transport.py +++ b/tests/hermes_cli/test_approval_transport.py @@ -8,7 +8,7 @@ import threading import time import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest from tools import approval_context, approval_prompt diff --git a/tests/hermes_cli/test_approvals_command.py b/tests/hermes_cli/test_approvals_command.py index 470869b616..758b1f177e 100644 --- a/tests/hermes_cli/test_approvals_command.py +++ b/tests/hermes_cli/test_approvals_command.py @@ -3,7 +3,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch -import yaml +import hermes_yaml as yaml from cli import HermesCLI from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, SUBCOMMANDS, gateway_help_lines, resolve_command diff --git a/tests/hermes_cli/test_atomic_yaml_write.py b/tests/hermes_cli/test_atomic_yaml_write.py index fd50fd10a7..51470cb61c 100644 --- a/tests/hermes_cli/test_atomic_yaml_write.py +++ b/tests/hermes_cli/test_atomic_yaml_write.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml from utils import atomic_yaml_write @@ -25,7 +25,7 @@ class TestAtomicYamlWrite: original = {"preserved": True} target.write_text(yaml.safe_dump(original), encoding="utf-8") - with patch("utils.yaml.dump", side_effect=SimulatedAbort): + with patch("utils.yaml.safe_dump", side_effect=SimulatedAbort): with pytest.raises(SimulatedAbort): atomic_yaml_write(target, {"new": True}) diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index af8e539771..88240b5dc5 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -9,7 +9,7 @@ from datetime import datetime, timezone from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml def _write_auth_store(tmp_path, payload: dict) -> None: @@ -990,8 +990,8 @@ def test_seed_from_singletons_respects_hermes_pkce_suppression(tmp_path, monkeyp hermes_home.mkdir(parents=True, exist_ok=True) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - import yaml - (hermes_home / "config.yaml").write_text(yaml.dump({"model": {"provider": "anthropic", "model": "claude"}})) + import hermes_yaml as yaml + (hermes_home / "config.yaml").write_text(yaml.safe_dump({"model": {"provider": "anthropic", "model": "claude"}})) (hermes_home / "auth.json").write_text(json.dumps({ "version": 1, "providers": {}, diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index d3923bc61f..1ea108f789 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -487,7 +487,7 @@ class TestLoginNousSkipKeepsCurrent: """ def _setup_home_with_openrouter(self, tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) @@ -554,7 +554,7 @@ class TestLoginNousSkipKeepsCurrent: def test_skip_keep_current_preserves_provider_and_model(self, tmp_path, monkeypatch): """User picks Skip → config.yaml untouched, Nous creds still saved.""" import argparse - import yaml + import hermes_yaml as yaml from hermes_cli.auth import PROVIDER_REGISTRY, _login_nous hermes_home, config_path, auth_path = self._setup_home_with_openrouter( @@ -585,7 +585,7 @@ class TestLoginNousSkipKeepsCurrent: def test_picking_model_switches_to_nous(self, tmp_path, monkeypatch): """User picks a Nous model → provider flips to nous with that model.""" import argparse - import yaml + import hermes_yaml as yaml from hermes_cli.auth import PROVIDER_REGISTRY, _login_nous hermes_home, config_path, auth_path = self._setup_home_with_openrouter( @@ -613,7 +613,7 @@ class TestLoginNousSkipKeepsCurrent: """Fresh install (no prior active_provider) → Skip clears active_provider instead of leaving it as nous.""" import argparse - import yaml + import hermes_yaml as yaml from hermes_cli.auth import PROVIDER_REGISTRY, _login_nous hermes_home = tmp_path / "hermes" diff --git a/tests/hermes_cli/test_auth_provider_gate.py b/tests/hermes_cli/test_auth_provider_gate.py index a85ddedb0a..66589caaec 100644 --- a/tests/hermes_cli/test_auth_provider_gate.py +++ b/tests/hermes_cli/test_auth_provider_gate.py @@ -7,8 +7,8 @@ import pytest def _write_config(tmp_path, config: dict) -> None: hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) - import yaml - (hermes_home / "config.yaml").write_text(yaml.dump(config)) + import hermes_yaml as yaml + (hermes_home / "config.yaml").write_text(yaml.safe_dump(config)) def _write_auth_store(tmp_path, payload: dict) -> None: diff --git a/tests/hermes_cli/test_aux_picker_inventory.py b/tests/hermes_cli/test_aux_picker_inventory.py index 27336409e9..53bcf619dc 100644 --- a/tests/hermes_cli/test_aux_picker_inventory.py +++ b/tests/hermes_cli/test_aux_picker_inventory.py @@ -22,7 +22,7 @@ from pathlib import Path from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml from hermes_cli import main_provider_setup diff --git a/tests/hermes_cli/test_backup.py b/tests/hermes_cli/test_backup.py index e94bd9eb61..fbd08a6977 100644 --- a/tests/hermes_cli/test_backup.py +++ b/tests/hermes_cli/test_backup.py @@ -1733,7 +1733,7 @@ class TestRunPreUpdateBackup: @staticmethod def _set_mode(hermes_home, value): - import yaml + import hermes_yaml as yaml (hermes_home / "config.yaml").write_text(yaml.safe_dump({ "_config_version": 22, "updates": {"pre_update_backup": value}, @@ -1929,7 +1929,7 @@ class TestRestoreConfigModelSettingsIfRewritten: return cfg def test_restores_rewritten_provider_and_dropped_moa(self, tmp_path): - import yaml + import hermes_yaml as yaml from hermes_cli.backup import restore_config_model_settings_if_rewritten hermes_home = tmp_path / ".hermes" @@ -1980,7 +1980,7 @@ class TestRestoreConfigModelSettingsIfRewritten: def test_preserves_legitimate_update_writes(self, tmp_path): """Only protected keys are restored — a version bump or a new section the migration legitimately wrote must survive the restore.""" - import yaml + import hermes_yaml as yaml from hermes_cli.backup import restore_config_model_settings_if_rewritten hermes_home = tmp_path / ".hermes" diff --git a/tests/hermes_cli/test_bedrock_mantle_key_env.py b/tests/hermes_cli/test_bedrock_mantle_key_env.py index 48f8733320..09a5673ef3 100644 --- a/tests/hermes_cli/test_bedrock_mantle_key_env.py +++ b/tests/hermes_cli/test_bedrock_mantle_key_env.py @@ -11,7 +11,7 @@ runtime resolver then makes of it. import os -import yaml +import hermes_yaml as yaml import hermes_cli.runtime_provider as rp from hermes_cli.model_setup_flows_bedrock import _model_flow_bedrock_api_key diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index d59d1034dc..d9dfb10d90 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -6,7 +6,7 @@ from pathlib import Path from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.config import ( DEFAULT_CONFIG, @@ -529,9 +529,9 @@ class TestSaveConfigAtomicity: config_path = tmp_path / "config.yaml" assert config_path.exists() - # Simulate a crash during yaml.dump by making atomic_yaml_write's - # yaml.dump raise after the temp file is created but before replace. - with patch("utils.yaml.dump", side_effect=OSError("disk full")): + # Simulate a crash during yaml.safe_dump by making atomic_yaml_write's + # yaml.safe_dump raise after the temp file is created but before replace. + with patch("utils.yaml.safe_dump", side_effect=OSError("disk full")): try: config["model"] = "should-not-persist" save_config(config) @@ -548,7 +548,7 @@ class TestSaveConfigAtomicity: config = load_config() save_config(config) - with patch("utils.yaml.dump", side_effect=OSError("disk full")): + with patch("utils.yaml.safe_dump", side_effect=OSError("disk full")): try: save_config(config) except OSError: diff --git a/tests/hermes_cli/test_config_dotted_key_names.py b/tests/hermes_cli/test_config_dotted_key_names.py index 92c5fb5138..b2c64a34cc 100644 --- a/tests/hermes_cli/test_config_dotted_key_names.py +++ b/tests/hermes_cli/test_config_dotted_key_names.py @@ -24,7 +24,7 @@ import os from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.config import ( _MISSING, diff --git a/tests/hermes_cli/test_config_loader_e2e.py b/tests/hermes_cli/test_config_loader_e2e.py index 30a8cf24c3..08f2402a2a 100644 --- a/tests/hermes_cli/test_config_loader_e2e.py +++ b/tests/hermes_cli/test_config_loader_e2e.py @@ -141,7 +141,7 @@ def test_writeback_roundtrip_byte_identical_when_unchanged(tmp_path): import json from pathlib import Path from hermes_cli.config import read_user_config_raw - import yaml + import hermes_yaml as yaml p = Path(__import__('os').environ['HERMES_HOME']) / 'config.yaml' before = p.read_text(encoding='utf-8') diff --git a/tests/hermes_cli/test_config_set_coercion.py b/tests/hermes_cli/test_config_set_coercion.py index 53518a5910..1317601119 100644 --- a/tests/hermes_cli/test_config_set_coercion.py +++ b/tests/hermes_cli/test_config_set_coercion.py @@ -15,7 +15,7 @@ from hermes_cli import config as cfg def _read(tmp_path, *path): """Read a nested value straight from the on-disk config.yaml.""" - import yaml + import hermes_yaml as yaml data = yaml.safe_load((tmp_path / "config.yaml").read_text()) or {} node = data for seg in path: diff --git a/tests/hermes_cli/test_config_set_platforms_redirect.py b/tests/hermes_cli/test_config_set_platforms_redirect.py index 803a70e58f..f467992cef 100644 --- a/tests/hermes_cli/test_config_set_platforms_redirect.py +++ b/tests/hermes_cli/test_config_set_platforms_redirect.py @@ -10,13 +10,13 @@ edit appeared to succeed while having no effect. from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml def _write_config(hermes_home: Path, data: dict) -> Path: hermes_home.mkdir(parents=True, exist_ok=True) config_path = hermes_home / "config.yaml" - config_path.write_text(yaml.dump(data)) + config_path.write_text(yaml.safe_dump(data)) return config_path diff --git a/tests/hermes_cli/test_custom_provider_model_switch.py b/tests/hermes_cli/test_custom_provider_model_switch.py index 43edf559d1..74284f8d60 100644 --- a/tests/hermes_cli/test_custom_provider_model_switch.py +++ b/tests/hermes_cli/test_custom_provider_model_switch.py @@ -38,7 +38,7 @@ class TestCustomProviderModelSwitch: ): """Switching custom endpoints must not leave the old model.api_key credential selectable from the previous endpoint's pool.""" - import yaml + import hermes_yaml as yaml from agent.credential_pool import load_pool from hermes_cli.auth import read_credential_pool, write_credential_pool from hermes_cli.model_setup_flows import _model_flow_custom @@ -121,7 +121,7 @@ class TestCustomProviderModelSwitch: def test_env_template_api_key_is_preserved_in_model_config(self, config_home, monkeypatch): """Selecting an env-backed custom provider must not inline the secret.""" - import yaml + import hermes_yaml as yaml from hermes_cli.model_setup_flows import _model_flow_named_custom config_path = config_home / "config.yaml" @@ -164,7 +164,7 @@ class TestCustomProviderModelSwitch: def test_key_env_custom_provider_persists_reference_not_secret(self, config_home, monkeypatch): """key_env custom providers should also avoid writing plaintext keys.""" - import yaml + import hermes_yaml as yaml from hermes_cli.model_setup_flows import _model_flow_named_custom config_path = config_home / "config.yaml" @@ -211,7 +211,7 @@ class TestCustomProviderModelSwitch: ``api_key_ref`` to stay empty and the resolved secret to be written to ``config.yaml``. This test drives the real picker-callsite code path. """ - import yaml + import hermes_yaml as yaml from hermes_cli.main import select_provider_and_model config_path = config_home / "config.yaml" @@ -279,7 +279,7 @@ class TestCustomProviderModelSwitch: ``key_env``; the runtime resolves it directly, so no inline ``api_key`` belongs on disk. """ - import yaml + import hermes_yaml as yaml from hermes_cli.model_setup_flows import _model_flow_named_custom config_path = config_home / "config.yaml" @@ -393,7 +393,7 @@ class TestCustomProviderModelSwitch: """A ``providers:`` entry that already has an inline ``api_key`` template must keep it untouched. Only entries that never declared an ``api_key`` should skip the write.""" - import yaml + import hermes_yaml as yaml from hermes_cli.model_setup_flows import _model_flow_named_custom config_path = config_home / "config.yaml" @@ -470,7 +470,7 @@ class TestCustomProviderDiscoverModels: def test_discover_false_saves_choice_from_configured_list(self, config_home): """User picks the 2nd configured model; it persists, list-driven.""" - import yaml + import hermes_yaml as yaml from hermes_cli.model_setup_flows import _model_flow_named_custom provider_info = { @@ -498,7 +498,7 @@ class TestCustomProviderDiscoverModels: def test_probe_empty_falls_back_to_configured_list(self, config_home): """When discovery is on but the probe returns nothing, fall back to the configured models: list instead of forcing manual entry.""" - import yaml + import hermes_yaml as yaml from hermes_cli.model_setup_flows import _model_flow_named_custom provider_info = { diff --git a/tests/hermes_cli/test_dashboard_basic_auth_plugin_enable.py b/tests/hermes_cli/test_dashboard_basic_auth_plugin_enable.py index d0fd5d810e..c78381a2f5 100644 --- a/tests/hermes_cli/test_dashboard_basic_auth_plugin_enable.py +++ b/tests/hermes_cli/test_dashboard_basic_auth_plugin_enable.py @@ -10,7 +10,7 @@ from __future__ import annotations from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.dashboard_auth import clear_providers, list_providers from hermes_cli.plugins import PluginManager, discover_plugins diff --git a/tests/hermes_cli/test_deferred_platform_client_tools.py b/tests/hermes_cli/test_deferred_platform_client_tools.py index 67638f51d4..3ec470048f 100644 --- a/tests/hermes_cli/test_deferred_platform_client_tools.py +++ b/tests/hermes_cli/test_deferred_platform_client_tools.py @@ -21,7 +21,7 @@ import sys from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml A2A_CLIENT_TOOLS = { @@ -70,7 +70,7 @@ def _write_platform_plugin( if provides_tools: manifest_data["provides_tools"] = provides_tools (plugin_dir / "plugin.yaml").write_text( - yaml.dump(manifest_data), + yaml.safe_dump(manifest_data), encoding="utf-8", ) diff --git a/tests/hermes_cli/test_destructive_slash_confirm_gate.py b/tests/hermes_cli/test_destructive_slash_confirm_gate.py index f8dcd4425d..fe61e02de0 100644 --- a/tests/hermes_cli/test_destructive_slash_confirm_gate.py +++ b/tests/hermes_cli/test_destructive_slash_confirm_gate.py @@ -32,7 +32,7 @@ class TestUserConfigMerge: user didn't override).""" def test_existing_user_config_without_key_gets_default(self, tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml home = tmp_path / ".hermes" home.mkdir() diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index 1b76269533..13f20561c2 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -342,12 +342,12 @@ class TestDoctorMemoryProviderSection: """Create a minimal HERMES_HOME with config.yaml.""" home = tmp_path / ".hermes" home.mkdir(parents=True, exist_ok=True) - import yaml + import hermes_yaml as yaml config = dict(memory_config or {}) if provider: config["provider"] = provider config = {"memory": config} - (home / "config.yaml").write_text(yaml.dump(config)) + (home / "config.yaml").write_text(yaml.safe_dump(config)) return home def _run_doctor_and_capture( @@ -448,10 +448,10 @@ def test_run_doctor_accepts_named_provider_from_providers_section(monkeypatch, t home = tmp_path / ".hermes" home.mkdir(parents=True, exist_ok=True) - import yaml + import hermes_yaml as yaml (home / "config.yaml").write_text( - yaml.dump( + yaml.safe_dump( { "model": { "provider": "volcengine-plan", diff --git a/tests/hermes_cli/test_early_recovery.py b/tests/hermes_cli/test_early_recovery.py index 122c62b627..a5ca8602e6 100644 --- a/tests/hermes_cli/test_early_recovery.py +++ b/tests/hermes_cli/test_early_recovery.py @@ -137,7 +137,7 @@ def _project(tmp_path: Path, *, pyproject: bool = True) -> Path: if pyproject: (root / "pyproject.toml").write_text( '[project]\nname = "x"\ndependencies = [\n' - ' "PyYAML==6.0.2",\n' + ' "ruamel.yaml==0.18.17",\n' ' "python-dotenv==1.2.2",\n' ' "PyJWT[crypto]==2.13.0",\n' "]\n", diff --git a/tests/hermes_cli/test_fallback_cmd.py b/tests/hermes_cli/test_fallback_cmd.py index a20a85293b..04221c2489 100644 --- a/tests/hermes_cli/test_fallback_cmd.py +++ b/tests/hermes_cli/test_fallback_cmd.py @@ -6,7 +6,7 @@ from pathlib import Path from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_fleet_config_migration_windows_live.py b/tests/hermes_cli/test_fleet_config_migration_windows_live.py index 95b5767d8d..34ae8b35e3 100644 --- a/tests/hermes_cli/test_fleet_config_migration_windows_live.py +++ b/tests/hermes_cli/test_fleet_config_migration_windows_live.py @@ -7,7 +7,7 @@ import sys from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml WORKTREE = Path(__file__).resolve().parents[2] sys.path.insert(0, str(WORKTREE)) diff --git a/tests/hermes_cli/test_gemini_free_tier_setup_block.py b/tests/hermes_cli/test_gemini_free_tier_setup_block.py index dd73225abb..568d25274b 100644 --- a/tests/hermes_cli/test_gemini_free_tier_setup_block.py +++ b/tests/hermes_cli/test_gemini_free_tier_setup_block.py @@ -55,7 +55,7 @@ class TestGeminiSetupFreeTierBlock: assert "Not saving Gemini as the default provider" in output # Config must NOT show gemini as the provider - import yaml + import hermes_yaml as yaml cfg = yaml.safe_load((config_home / "config.yaml").read_text()) or {} model = cfg.get("model") if isinstance(model, dict): @@ -86,7 +86,7 @@ class TestGeminiSetupFreeTierBlock: assert "paid" in output.lower() assert "Not saving Gemini" not in output - import yaml + import hermes_yaml as yaml cfg = yaml.safe_load((config_home / "config.yaml").read_text()) or {} model = cfg.get("model") assert isinstance(model, dict), f"model should be dict, got {type(model)}" diff --git a/tests/hermes_cli/test_gmi_provider.py b/tests/hermes_cli/test_gmi_provider.py index 732dab2986..2ccecf5d80 100644 --- a/tests/hermes_cli/test_gmi_provider.py +++ b/tests/hermes_cli/test_gmi_provider.py @@ -320,7 +320,7 @@ class TestGmiMainFlow: _model_flow_api_key_provider(load_config(), "gmi", "old-model") - import yaml + import hermes_yaml as yaml from hermes_constants import get_hermes_home config = yaml.safe_load((get_hermes_home() / "config.yaml").read_text()) or {} diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index 6726c55b3a..1848cdc21a 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -41,7 +41,7 @@ def _cfg(model=None, providers=None, custom_providers=None) -> dict: def test_load_picker_context_coerces_numeric_yaml_provider(): - """PyYAML parses unquoted `provider: 2070` as int; picker context must be str. + """YAML parses unquoted `provider: 2070` as int; picker context must be str. Desktop GET /api/model/options crashed when a custom endpoint was named after a GPU: current_provider.strip() and providers dict keys .lower(). diff --git a/tests/hermes_cli/test_keyed_provider_credential_pool.py b/tests/hermes_cli/test_keyed_provider_credential_pool.py index e059ed7542..f1035f7b6e 100644 --- a/tests/hermes_cli/test_keyed_provider_credential_pool.py +++ b/tests/hermes_cli/test_keyed_provider_credential_pool.py @@ -10,7 +10,7 @@ from __future__ import annotations import json -import yaml +import hermes_yaml as yaml POOL_KEY = "sk-real-b-ai-pool-key-12345" diff --git a/tests/hermes_cli/test_managed_scope_loaders.py b/tests/hermes_cli/test_managed_scope_loaders.py index 0161102d5e..093233d597 100644 --- a/tests/hermes_cli/test_managed_scope_loaders.py +++ b/tests/hermes_cli/test_managed_scope_loaders.py @@ -77,7 +77,7 @@ def test_gateway_env_bridge_honors_managed(homes, monkeypatch): managed_scope.invalidate_managed_cache() # The bridge loads config.yaml, expands env, then applies this overlay before # writing HERMES_TIMEZONE = cfg["timezone"]. Prove the overlay flips the value. - import yaml + import hermes_yaml as yaml raw = yaml.safe_load((home / "config.yaml").read_text()) bridged = managed_scope.apply_managed_overlay(raw) diff --git a/tests/hermes_cli/test_mcp_catalog.py b/tests/hermes_cli/test_mcp_catalog.py index 3367974e19..f27fb777bf 100644 --- a/tests/hermes_cli/test_mcp_catalog.py +++ b/tests/hermes_cli/test_mcp_catalog.py @@ -12,7 +12,7 @@ from pathlib import Path from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_mcp_catalog_env_boundary.py b/tests/hermes_cli/test_mcp_catalog_env_boundary.py index 7e131f83ed..ea2beb0198 100644 --- a/tests/hermes_cli/test_mcp_catalog_env_boundary.py +++ b/tests/hermes_cli/test_mcp_catalog_env_boundary.py @@ -6,7 +6,7 @@ import os from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml from fastapi.testclient import TestClient from hermes_cli.web_server import _SESSION_TOKEN, app diff --git a/tests/hermes_cli/test_mcp_config.py b/tests/hermes_cli/test_mcp_config.py index a63174ffff..d49aba217b 100644 --- a/tests/hermes_cli/test_mcp_config.py +++ b/tests/hermes_cli/test_mcp_config.py @@ -61,7 +61,7 @@ def _make_args(**kwargs): def _seed_config(tmp_path: Path, mcp_servers: dict): """Write a config.yaml with the given mcp_servers.""" - import yaml + import hermes_yaml as yaml config = {"mcp_servers": mcp_servers, "_config_version": 9} config_path = tmp_path / "config.yaml" diff --git a/tests/hermes_cli/test_mcp_reload_confirm_gate.py b/tests/hermes_cli/test_mcp_reload_confirm_gate.py index 980bdad4ff..7e4d1c1ef9 100644 --- a/tests/hermes_cli/test_mcp_reload_confirm_gate.py +++ b/tests/hermes_cli/test_mcp_reload_confirm_gate.py @@ -40,7 +40,7 @@ class TestUserConfigMerge: """ def test_existing_user_config_without_key_gets_default(self, tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml # Simulate a legacy user config without the new key. home = tmp_path / ".hermes" diff --git a/tests/hermes_cli/test_mcp_security.py b/tests/hermes_cli/test_mcp_security.py index 4849360db8..4b135cd6ee 100644 --- a/tests/hermes_cli/test_mcp_security.py +++ b/tests/hermes_cli/test_mcp_security.py @@ -130,7 +130,7 @@ def test_explicit_registration_skips_dangerous_entry_before_connect(monkeypatch) def test_migration_disables_existing_dangerous_entry(tmp_path): - import yaml + import hermes_yaml as yaml from hermes_cli.config import load_config, migrate_config diff --git a/tests/hermes_cli/test_memory_dependency_admission.py b/tests/hermes_cli/test_memory_dependency_admission.py index 5398a5e429..678acf7ace 100644 --- a/tests/hermes_cli/test_memory_dependency_admission.py +++ b/tests/hermes_cli/test_memory_dependency_admission.py @@ -9,9 +9,8 @@ import sys from types import SimpleNamespace import pytest -import yaml +import hermes_yaml as yaml -import pm from hermes_cli import memory_setup from hermes_cli.runtime_paths import selected_venv from pm import paths @@ -67,7 +66,9 @@ def test_setup_requires_dependencies_and_keeps_the_existing_union(tmp_path, monk uv, {**uv_env(kwargs.get('base_env')), 'UV_PYTHON': sys.executable, 'UV_OFFLINE': '1', 'UV_CACHE_DIR': str(tmp_path / 'cache')}, )) - pm.sync_venv(explicit=True) + # Keep admission on the real engine with the offline tool fixture. + monkeypatch.setattr("pm.client.sync_venv", ensure.sync_venv) + ensure.sync_venv(explicit=True) original_environment = selected_venv(core) before = {file: file.read_bytes() for file in (config, other_config, paths.runtime_facts_path())} post_calls = [] diff --git a/tests/hermes_cli/test_memory_setup.py b/tests/hermes_cli/test_memory_setup.py index fe2a2e447f..d2428b55fe 100644 --- a/tests/hermes_cli/test_memory_setup.py +++ b/tests/hermes_cli/test_memory_setup.py @@ -63,7 +63,7 @@ def test_cmd_setup_generic_choice_cancel_writes_nothing(tmp_path, monkeypatch): def test_install_dependencies_force_resyncs_declared_extra(tmp_path, monkeypatch): """force=True re-syncs the provider's extra even when it imports fine, so a downgraded/stripped bridge package is restored on hermes update.""" - import yaml as _yaml + import hermes_yaml as _yaml plugin_dir = tmp_path / "mem0" plugin_dir.mkdir() diff --git a/tests/hermes_cli/test_model_assignment_env_key_mirror.py b/tests/hermes_cli/test_model_assignment_env_key_mirror.py index 9d869b530c..384aaaa866 100644 --- a/tests/hermes_cli/test_model_assignment_env_key_mirror.py +++ b/tests/hermes_cli/test_model_assignment_env_key_mirror.py @@ -13,7 +13,7 @@ import importlib import os import pytest -import yaml +import hermes_yaml as yaml import hermes_cli.web_server_config as _web_server_config diff --git a/tests/hermes_cli/test_model_picker_excluded_providers.py b/tests/hermes_cli/test_model_picker_excluded_providers.py index 9ca3394c4d..ccc72452fa 100644 --- a/tests/hermes_cli/test_model_picker_excluded_providers.py +++ b/tests/hermes_cli/test_model_picker_excluded_providers.py @@ -32,7 +32,7 @@ def config_home(tmp_path, monkeypatch): def _write_config(home, **top_level): - import yaml + import hermes_yaml as yaml cfg = {"model": "old-model", "custom_providers": []} cfg.update(top_level) (home / "config.yaml").write_text(yaml.safe_dump(cfg)) diff --git a/tests/hermes_cli/test_model_provider_persistence.py b/tests/hermes_cli/test_model_provider_persistence.py index 5607591c0d..9b5b5019e0 100644 --- a/tests/hermes_cli/test_model_provider_persistence.py +++ b/tests/hermes_cli/test_model_provider_persistence.py @@ -44,7 +44,7 @@ class TestSaveModelChoiceAlwaysDict: _save_model_choice("kimi-k2.5") - import yaml + import hermes_yaml as yaml config = yaml.safe_load((config_home / "config.yaml").read_text()) or {} model = config.get("model") assert isinstance(model, dict), ( @@ -102,7 +102,7 @@ class TestProviderPersistsAfterModelSave: patch("builtins.input", return_value=""): _model_flow_api_key_provider(load_config(), "kimi-coding", "old-model") - import yaml + import hermes_yaml as yaml config = yaml.safe_load((config_home / "config.yaml").read_text()) or {} model = config.get("model") assert isinstance(model, dict), f"model should be dict, got {type(model)}" diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index 40b87b09a7..9e515083e8 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -14,7 +14,7 @@ import time import hermes_cli.providers as providers_mod import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.model_switch import list_authenticated_providers, switch_model from hermes_cli.model_switch_providers import _fetch_picker_live_models, _save_discovered_models_to_config from hermes_cli.providers import resolve_provider_full diff --git a/tests/hermes_cli/test_model_switch_opencode_anthropic.py b/tests/hermes_cli/test_model_switch_opencode_anthropic.py index 44a0b73fe7..564379a126 100644 --- a/tests/hermes_cli/test_model_switch_opencode_anthropic.py +++ b/tests/hermes_cli/test_model_switch_opencode_anthropic.py @@ -190,7 +190,7 @@ class TestStaleConfigDefaultDoesNotWedgeResolver: """ def test_kimi_switch_keeps_v1_despite_claude_config_default(self, tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml import importlib monkeypatch.setenv("HERMES_HOME", str(tmp_path)) diff --git a/tests/hermes_cli/test_personality_single_owner.py b/tests/hermes_cli/test_personality_single_owner.py index 1281099e14..b299d34352 100644 --- a/tests/hermes_cli/test_personality_single_owner.py +++ b/tests/hermes_cli/test_personality_single_owner.py @@ -12,7 +12,7 @@ import os from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.personality import ( BUILTIN_PERSONALITIES, diff --git a/tests/hermes_cli/test_pet_toggle.py b/tests/hermes_cli/test_pet_toggle.py index c1b55f939f..0b3dd05232 100644 --- a/tests/hermes_cli/test_pet_toggle.py +++ b/tests/hermes_cli/test_pet_toggle.py @@ -27,10 +27,10 @@ def boba_installed(tmp_path, monkeypatch): def _write_config(home, *, enabled: bool, slug: str = "") -> None: - import yaml + import hermes_yaml as yaml cfg = {"display": {"pet": {"enabled": enabled, "slug": slug, "scale": 0.33}}} - (home / "config.yaml").write_text(yaml.dump(cfg), encoding="utf-8") + (home / "config.yaml").write_text(yaml.safe_dump(cfg), encoding="utf-8") @@ -56,7 +56,7 @@ def test_pets_cli_quoted_false_disables_and_toggle_enables(tmp_path, monkeypatch bool('false') is True — before the is_truthy_value fix, _has_active_pet reported an active pet and /pet toggle DISABLED instead of enabling. """ - import yaml + import hermes_yaml as yaml from hermes_cli.pets import _has_active_pet, toggle_pet_display diff --git a/tests/hermes_cli/test_plugin_api_compat.py b/tests/hermes_cli/test_plugin_api_compat.py index d0024dce2a..197d182747 100644 --- a/tests/hermes_cli/test_plugin_api_compat.py +++ b/tests/hermes_cli/test_plugin_api_compat.py @@ -3,7 +3,7 @@ from pathlib import Path import shutil -import yaml +import hermes_yaml as yaml from hermes_cli.plugins import PluginManager diff --git a/tests/hermes_cli/test_plugin_capabilities.py b/tests/hermes_cli/test_plugin_capabilities.py index 2518b304fc..b4660da8b7 100644 --- a/tests/hermes_cli/test_plugin_capabilities.py +++ b/tests/hermes_cli/test_plugin_capabilities.py @@ -11,7 +11,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.plugin_capabilities import ( CAPABILITY_REGISTRY, diff --git a/tests/hermes_cli/test_plugin_catalog.py b/tests/hermes_cli/test_plugin_catalog.py index 19a3613515..6bb7cdcc6b 100644 --- a/tests/hermes_cli/test_plugin_catalog.py +++ b/tests/hermes_cli/test_plugin_catalog.py @@ -5,7 +5,7 @@ from __future__ import annotations import json -import yaml +import hermes_yaml as yaml from hermes_cli import plugin_catalog as pc diff --git a/tests/hermes_cli/test_plugin_config_state_bridge.py b/tests/hermes_cli/test_plugin_config_state_bridge.py index e992650dd1..a5dc767e5a 100644 --- a/tests/hermes_cli/test_plugin_config_state_bridge.py +++ b/tests/hermes_cli/test_plugin_config_state_bridge.py @@ -10,7 +10,7 @@ from concurrent.futures import ThreadPoolExecutor from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml from hermes_constants import reset_hermes_home_override, set_hermes_home_override from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest diff --git a/tests/hermes_cli/test_plugin_dependency_consent.py b/tests/hermes_cli/test_plugin_dependency_consent.py index 86d850644c..0a4493d0ee 100644 --- a/tests/hermes_cli/test_plugin_dependency_consent.py +++ b/tests/hermes_cli/test_plugin_dependency_consent.py @@ -4,7 +4,7 @@ import os import subprocess import pytest -import yaml +import hermes_yaml as yaml from hermes_cli import plugins_cmd from tests.pm.test_plugin_survival_contract import admission_env # noqa: F401 diff --git a/tests/hermes_cli/test_plugin_event_bus.py b/tests/hermes_cli/test_plugin_event_bus.py index f02454bc1a..84796aac1e 100644 --- a/tests/hermes_cli/test_plugin_event_bus.py +++ b/tests/hermes_cli/test_plugin_event_bus.py @@ -395,7 +395,7 @@ def test_manifest_emits_listens_present(): def test_manifest_parse_reads_emits_listens(tmp_path): """parse_manifest_file picks up optional emits/listens from plugin.yaml.""" - import yaml + import hermes_yaml as yaml plugin_dir = tmp_path / "myplug" plugin_dir.mkdir() @@ -420,7 +420,7 @@ def test_manifest_parse_reads_emits_listens(tmp_path): def test_manifest_parse_absent_emits_listens(tmp_path): - import yaml + import hermes_yaml as yaml plugin_dir = tmp_path / "bare" plugin_dir.mkdir() @@ -441,7 +441,7 @@ def test_manifest_parse_absent_emits_listens(tmp_path): def test_plugins_show_includes_emits_listens(tmp_path, monkeypatch, capsys): - import yaml + import hermes_yaml as yaml from hermes_cli import plugins_cmd plugin_dir = tmp_path / "showplug" diff --git a/tests/hermes_cli/test_plugin_install_ref.py b/tests/hermes_cli/test_plugin_install_ref.py index 0fcfbfb73e..63b11540c4 100644 --- a/tests/hermes_cli/test_plugin_install_ref.py +++ b/tests/hermes_cli/test_plugin_install_ref.py @@ -9,7 +9,7 @@ import subprocess from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.subcommands.plugins import build_plugins_parser diff --git a/tests/hermes_cli/test_plugin_manifest_v2.py b/tests/hermes_cli/test_plugin_manifest_v2.py index a38e9a328e..4908f736f6 100644 --- a/tests/hermes_cli/test_plugin_manifest_v2.py +++ b/tests/hermes_cli/test_plugin_manifest_v2.py @@ -9,7 +9,7 @@ declare-only seam (surfaced, never installed). import logging import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.plugins import ( PluginManager, @@ -26,7 +26,7 @@ def _write_plugin(base, name, manifest_extra=None, register_body="pass"): manifest = {"name": name, "version": "0.1.0", "description": f"test {name}"} if manifest_extra: manifest.update(manifest_extra) - (plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest)) + (plugin_dir / "plugin.yaml").write_text(yaml.safe_dump(manifest)) (plugin_dir / "__init__.py").write_text( f"def register(ctx):\n {register_body}\n" ) diff --git a/tests/hermes_cli/test_plugin_message_injection.py b/tests/hermes_cli/test_plugin_message_injection.py index 209f6f3e6e..2a732936d0 100644 --- a/tests/hermes_cli/test_plugin_message_injection.py +++ b/tests/hermes_cli/test_plugin_message_injection.py @@ -4,7 +4,7 @@ from queue import SimpleQueue from types import SimpleNamespace from unittest.mock import MagicMock, patch -import yaml +import hermes_yaml as yaml from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest diff --git a/tests/hermes_cli/test_plugin_ownership_ledger.py b/tests/hermes_cli/test_plugin_ownership_ledger.py index 35d0bbaa77..28b724a655 100644 --- a/tests/hermes_cli/test_plugin_ownership_ledger.py +++ b/tests/hermes_cli/test_plugin_ownership_ledger.py @@ -8,7 +8,7 @@ from threading import Event from time import monotonic, sleep from types import MethodType -import yaml +import hermes_yaml as yaml def _write_plugin(hermes_home: Path) -> None: diff --git a/tests/hermes_cli/test_plugin_packs.py b/tests/hermes_cli/test_plugin_packs.py index c9c80cc73a..d1609056ac 100644 --- a/tests/hermes_cli/test_plugin_packs.py +++ b/tests/hermes_cli/test_plugin_packs.py @@ -12,7 +12,7 @@ from types import SimpleNamespace from unittest import mock import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.plugin_packs import ( PackError, diff --git a/tests/hermes_cli/test_plugin_scanner_recursion.py b/tests/hermes_cli/test_plugin_scanner_recursion.py index 4a0614f959..980292675d 100644 --- a/tests/hermes_cli/test_plugin_scanner_recursion.py +++ b/tests/hermes_cli/test_plugin_scanner_recursion.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import Any, Dict import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.plugins import PluginManager @@ -44,7 +44,7 @@ def _write_plugin( } if manifest_extra: manifest.update(manifest_extra) - (plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest)) + (plugin_dir / "plugin.yaml").write_text(yaml.safe_dump(manifest)) (plugin_dir / "__init__.py").write_text( f"def register(ctx):\n {register_body}\n" ) diff --git a/tests/hermes_cli/test_plugin_validate.py b/tests/hermes_cli/test_plugin_validate.py index 4398b517f8..0cfc27306c 100644 --- a/tests/hermes_cli/test_plugin_validate.py +++ b/tests/hermes_cli/test_plugin_validate.py @@ -8,7 +8,7 @@ from __future__ import annotations from pathlib import Path -import yaml +import hermes_yaml as yaml from hermes_cli.plugin_validate import validate_plugin_dir diff --git a/tests/hermes_cli/test_plugin_version_order.py b/tests/hermes_cli/test_plugin_version_order.py index cd76f51495..cfb9d00fed 100644 --- a/tests/hermes_cli/test_plugin_version_order.py +++ b/tests/hermes_cli/test_plugin_version_order.py @@ -3,7 +3,7 @@ import json from types import SimpleNamespace import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.plugins_updates import run_checks diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 85415617ae..f39f88ff96 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -9,7 +9,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.plugins import ( ENTRY_POINTS_GROUP, @@ -81,7 +81,7 @@ def _make_plugin_dir(base: Path, name: str, *, register_body: str = "pass", if manifest_extra: manifest.update(manifest_extra) - (plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest)) + (plugin_dir / "plugin.yaml").write_text(yaml.safe_dump(manifest)) (plugin_dir / "__init__.py").write_text( f"def register(ctx):\n {register_body}\n" ) @@ -479,7 +479,7 @@ class TestPluginLoading: plugin_dir = plugins_dir / "mempalace" plugin_dir.mkdir(parents=True) # No explicit `kind:` — the heuristic should kick in. - (plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "mempalace"})) + (plugin_dir / "plugin.yaml").write_text(yaml.safe_dump({"name": "mempalace"})) (plugin_dir / "__init__.py").write_text( "class MemPalaceProvider:\n" " pass\n" @@ -1699,7 +1699,7 @@ class TestPluginContext: plugins_dir = tmp_path / "hermes_test" / "plugins" plugin_dir = plugins_dir / "evil_override_plugin" plugin_dir.mkdir(parents=True) - (plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "evil_override_plugin"})) + (plugin_dir / "plugin.yaml").write_text(yaml.safe_dump({"name": "evil_override_plugin"})) (plugin_dir / "__init__.py").write_text( 'def register(ctx):\n' ' ctx.register_tool(\n' @@ -1769,7 +1769,7 @@ class TestPluginContext: plugins_dir = tmp_path / "hermes_test" / "plugins" plugin_dir = plugins_dir / "delayed_override_plugin" plugin_dir.mkdir(parents=True) - (plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "delayed_override_plugin"})) + (plugin_dir / "plugin.yaml").write_text(yaml.safe_dump({"name": "delayed_override_plugin"})) # register(ctx) only STORES a callback; the override fires later, # after load has finished and any transient scope is gone. (plugin_dir / "__init__.py").write_text( @@ -1833,7 +1833,7 @@ class TestPluginToolVisibility: plugins_dir = tmp_path / "hermes_test" / "plugins" plugin_dir = plugins_dir / "vis_plugin" plugin_dir.mkdir(parents=True) - (plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "vis_plugin"})) + (plugin_dir / "plugin.yaml").write_text(yaml.safe_dump({"name": "vis_plugin"})) (plugin_dir / "__init__.py").write_text( 'def register(ctx):\n' ' ctx.register_tool(\n' @@ -2058,7 +2058,7 @@ class TestPluginCommands: plugin_dir = plugins_dir / "engine-plugin" plugin_dir.mkdir(parents=True, exist_ok=True) (plugin_dir / "plugin.yaml").write_text( - yaml.dump({ + yaml.safe_dump({ "name": "engine-plugin", "version": "0.1.0", "description": "Test engine plugin", @@ -2194,7 +2194,7 @@ class TestPluginCommands: plugin_dir = (home / "plugins" / "stateful-plugin") plugin_dir.mkdir(parents=True, exist_ok=True) (plugin_dir / "plugin.yaml").write_text( - yaml.dump({ + yaml.safe_dump({ "name": "stateful-plugin", "version": "0.1.0", "description": "Relative-import regression plugin", diff --git a/tests/hermes_cli/test_plugins_admission_setter.py b/tests/hermes_cli/test_plugins_admission_setter.py index a0bbcb3757..21b1ad1e83 100644 --- a/tests/hermes_cli/test_plugins_admission_setter.py +++ b/tests/hermes_cli/test_plugins_admission_setter.py @@ -50,13 +50,8 @@ def _write_sets(home, enabled=(), disabled=()): @pytest.fixture def sync_calls(monkeypatch): - """pm.ensure is shadowed by a pm.ensure() function — patch the MODULE.""" - import importlib - import sys - - if "pm.ensure" not in sys.modules: - importlib.import_module("pm.ensure") - ensure = sys.modules["pm.ensure"] + """Stub the admission caller seam; real engine transactions are tested below.""" + from pm import client calls = [] def _sync(extras=None, *, explicit=False, plugin_dirs=None, before_publish=None): @@ -65,7 +60,7 @@ def sync_calls(monkeypatch): if before_publish is not None: before_publish() # same contract: config commits under the sync - monkeypatch.setattr(ensure, "sync_venv", _sync) + monkeypatch.setattr(client, "sync_venv", _sync) return calls @@ -88,17 +83,12 @@ def _discovery(monkeypatch, *entries): def _refusing_sync(monkeypatch, message="uv lock exited 1: unsatisfiable"): - import importlib - import sys - - if "pm.ensure" not in sys.modules: - importlib.import_module("pm.ensure") - ensure = sys.modules["pm.ensure"] + from pm import client def _boom(*a, **k): raise RuntimeError(message) - monkeypatch.setattr(ensure, "sync_venv", _boom) + monkeypatch.setattr(client, "sync_venv", _boom) # ── cmd_enable (CLI path) ──────────────────────────────────────────────────── diff --git a/tests/hermes_cli/test_plugins_cmd.py b/tests/hermes_cli/test_plugins_cmd.py index 8c1a303d92..6308508748 100644 --- a/tests/hermes_cli/test_plugins_cmd.py +++ b/tests/hermes_cli/test_plugins_cmd.py @@ -9,7 +9,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.plugins_cmd import ( PluginOperationError, diff --git a/tests/hermes_cli/test_plugins_cmd_category_discovery.py b/tests/hermes_cli/test_plugins_cmd_category_discovery.py index 43c3a77156..7bbff89917 100644 --- a/tests/hermes_cli/test_plugins_cmd_category_discovery.py +++ b/tests/hermes_cli/test_plugins_cmd_category_discovery.py @@ -22,8 +22,8 @@ def _make_plugin_dir(parent: Path, name: str, manifest: dict) -> Path: """Create a minimal plugin directory with a plugin.yaml.""" d = parent / name d.mkdir(parents=True, exist_ok=True) - import yaml - (d / "plugin.yaml").write_text(yaml.dump(manifest), encoding="utf-8") + import hermes_yaml as yaml + (d / "plugin.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8") (d / "__init__.py").write_text("def register(ctx): pass\n", encoding="utf-8") return d @@ -68,8 +68,8 @@ class TestReadManifestInfo: d = tmp_path / "my-plugin" d.mkdir() - import yaml - (d / "plugin.yml").write_text(yaml.dump({"name": "my-plugin"}), encoding="utf-8") + import hermes_yaml as yaml + (d / "plugin.yml").write_text(yaml.safe_dump({"name": "my-plugin"}), encoding="utf-8") result = _read_manifest_info(d, "") assert result is not None assert result[0] == "my-plugin" @@ -134,9 +134,9 @@ class TestDiscoverAllPlugins: # 3 levels: should NOT be found deep = tmp_path / "a" / "b" / "c" deep.mkdir(parents=True) - import yaml + import hermes_yaml as yaml (deep / "plugin.yaml").write_text( - yaml.dump({"name": "too-deep"}), encoding="utf-8" + yaml.safe_dump({"name": "too-deep"}), encoding="utf-8" ) mock_user_dir.return_value = tmp_path mock_bundled_dir.return_value = tmp_path / "nonexistent" diff --git a/tests/hermes_cli/test_plugins_cmd_deps_flow.py b/tests/hermes_cli/test_plugins_cmd_deps_flow.py index d6b9cb209c..2a11dc6734 100644 --- a/tests/hermes_cli/test_plugins_cmd_deps_flow.py +++ b/tests/hermes_cli/test_plugins_cmd_deps_flow.py @@ -63,7 +63,7 @@ def test_noninteractive_skips_install(tmp_path, monkeypatch, resolve_env): monkeypatch.setattr(pc.sys.stdin, "isatty", lambda: False, raising=False) called = [] monkeypatch.setattr( - "pm.workspace.lock_and_sync", lambda *a, **k: called.append(a) + "pm.client.sync_venv", lambda *a, **k: called.append(a) ) ok, reason = pc._install_plugin_python_deps( {"name": "dep-plug", "python_dependencies": ["somepkg>=1,<2"]}, @@ -83,7 +83,7 @@ def test_decline_skips_install(tmp_path, monkeypatch, resolve_env): monkeypatch.setattr("builtins.input", lambda *a: "n") called = [] monkeypatch.setattr( - "pm.workspace.lock_and_sync", lambda *a, **k: called.append(a) + "pm.client.sync_venv", lambda *a, **k: called.append(a) ) ok, reason = pc._install_plugin_python_deps( {"name": "dep-plug", "python_dependencies": ["somepkg>=1,<2"]}, @@ -114,12 +114,7 @@ def test_conflict_surfaces_at_admission_not_consent(tmp_path, monkeypatch, resol assert ok is True and reason is None # The conflict surfaces when the enable COMMITS, with config untouched: - import importlib - import sys - - if "pm.ensure" not in sys.modules: - importlib.import_module("pm.ensure") - ensure = sys.modules["pm.ensure"] + from pm import client def boom(*a, **k): raise RuntimeError( @@ -127,7 +122,7 @@ def test_conflict_surfaces_at_admission_not_consent(tmp_path, monkeypatch, resol "and hermes-agent depends on somepkg==1.0.0, unsatisfiable" ) - monkeypatch.setattr(ensure, "sync_venv", boom) + monkeypatch.setattr(client, "sync_venv", boom) from hermes_cli import plugins_admission as adm with pytest.raises(adm.AdmissionRefused) as excinfo: @@ -147,14 +142,7 @@ def test_success_consents_without_plugin_dir_writes(tmp_path, monkeypatch, resol monkeypatch.setattr(pc.sys.stdout, "isatty", lambda: True, raising=False) monkeypatch.setattr("builtins.input", lambda *a: "y") synced = [] - import importlib - import sys - - if "pm.ensure" not in sys.modules: - importlib.import_module("pm.ensure") - monkeypatch.setattr( - sys.modules["pm.ensure"], "sync_venv", lambda *a, **k: synced.append(a) - ) + monkeypatch.setattr("pm.client.sync_venv", lambda *a, **k: synced.append(a)) ok, reason = pc._install_plugin_python_deps( {"name": "dep-plug", "python_dependencies": ["somepkg>=1,<2"]}, plug, diff --git a/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py b/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py index bd71ef18c9..e0d221873b 100644 --- a/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py +++ b/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py @@ -28,8 +28,8 @@ def _capture_selection(monkeypatch): def _make_plugin_dir(parent: Path, name: str, manifest: dict) -> Path: d = parent / name d.mkdir(parents=True, exist_ok=True) - import yaml - (d / "plugin.yaml").write_text(yaml.dump(manifest), encoding="utf-8") + import hermes_yaml as yaml + (d / "plugin.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8") (d / "__init__.py").write_text("def register(ctx): pass\n", encoding="utf-8") return d diff --git a/tests/hermes_cli/test_plugins_transcription_registration.py b/tests/hermes_cli/test_plugins_transcription_registration.py index 5f6ab4a2f7..d6e829274b 100644 --- a/tests/hermes_cli/test_plugins_transcription_registration.py +++ b/tests/hermes_cli/test_plugins_transcription_registration.py @@ -14,7 +14,7 @@ import os from pathlib import Path from typing import Any, Dict -import yaml +import hermes_yaml as yaml def _write_plugin( @@ -33,7 +33,7 @@ def _write_plugin( } if manifest_extra: manifest.update(manifest_extra) - (plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest)) + (plugin_dir / "plugin.yaml").write_text(yaml.safe_dump(manifest)) (plugin_dir / "__init__.py").write_text( f"def register(ctx):\n {register_body}\n" ) diff --git a/tests/hermes_cli/test_plugins_tts_registration.py b/tests/hermes_cli/test_plugins_tts_registration.py index 81a6b6a0bd..3aa45b1638 100644 --- a/tests/hermes_cli/test_plugins_tts_registration.py +++ b/tests/hermes_cli/test_plugins_tts_registration.py @@ -14,7 +14,7 @@ import os from pathlib import Path from typing import Any, Dict -import yaml +import hermes_yaml as yaml def _write_plugin( @@ -33,7 +33,7 @@ def _write_plugin( } if manifest_extra: manifest.update(manifest_extra) - (plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest)) + (plugin_dir / "plugin.yaml").write_text(yaml.safe_dump(manifest)) (plugin_dir / "__init__.py").write_text( f"def register(ctx):\n {register_body}\n" ) diff --git a/tests/hermes_cli/test_plugins_update_sync.py b/tests/hermes_cli/test_plugins_update_sync.py index 51c5620a95..e085f73dad 100644 --- a/tests/hermes_cli/test_plugins_update_sync.py +++ b/tests/hermes_cli/test_plugins_update_sync.py @@ -1,7 +1,6 @@ """Every update surface validates staged code before changing an active plugin.""" from __future__ import annotations -import importlib import subprocess import pytest @@ -44,7 +43,7 @@ def test_disabled_update_does_not_change_dependencies_or_enablement(installed, m config = (home / "config.yaml").read_bytes() facts = paths.runtime_facts_path().read_bytes() state["sha"] = _version(repo, "2.0.0") - monkeypatch.setattr(importlib.import_module("pm.ensure"), "sync_venv", + monkeypatch.setattr("pm.client.sync_venv", lambda **kwargs: pytest.fail("disabled plugin changed the dependency selection")) result = pc.dashboard_update_user_plugin("transactional") assert result["ok"], result diff --git a/tests/hermes_cli/test_profile_display_name.py b/tests/hermes_cli/test_profile_display_name.py index 27dbd6e77c..60b14278db 100644 --- a/tests/hermes_cli/test_profile_display_name.py +++ b/tests/hermes_cli/test_profile_display_name.py @@ -9,7 +9,7 @@ from __future__ import annotations from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.profiles import ( create_profile, diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index b8acc83a22..44481c17e2 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -16,7 +16,7 @@ from pathlib import Path from unittest.mock import patch, MagicMock import pytest -import yaml +import hermes_yaml as yaml from hermes_cli import profiles from hermes_cli.profiles import ( @@ -938,10 +938,8 @@ class TestWriteProfileMetaDurability: def _interrupted_write(profile_dir): """Run a ``write_profile_meta`` whose serialization fails mid-call. - The pre-fix code called ``yaml.safe_dump``; ``utils.atomic_yaml_write`` - calls ``yaml.dump``. Breaking both keeps this serializer-agnostic, so - it measures durability rather than the choice of entry point. A - scoped ``MonkeyPatch.context`` is used instead of the fixture so the + Interrupt the shared serializer used by ``utils.atomic_yaml_write``. + A scoped ``MonkeyPatch.context`` is used instead of the fixture so the patch is reverted immediately, without touching the session-wide env isolation that shares the function-scoped ``monkeypatch`` instance. """ @@ -950,7 +948,6 @@ class TestWriteProfileMetaDurability: with pytest.MonkeyPatch.context() as mp: mp.setattr(yaml, "safe_dump", _boom) - mp.setattr(yaml, "dump", _boom) with pytest.raises(RuntimeError): profiles.write_profile_meta(profile_dir, description_auto=True) diff --git a/tests/hermes_cli/test_read_raw_config_readonly.py b/tests/hermes_cli/test_read_raw_config_readonly.py index 3326e5fa1f..20cc74e07e 100644 --- a/tests/hermes_cli/test_read_raw_config_readonly.py +++ b/tests/hermes_cli/test_read_raw_config_readonly.py @@ -15,7 +15,7 @@ import os import time import pytest -import yaml +import hermes_yaml as yaml @pytest.fixture() diff --git a/tests/hermes_cli/test_reasoning_full_command.py b/tests/hermes_cli/test_reasoning_full_command.py index 8f6c23dff4..45f110f7e4 100644 --- a/tests/hermes_cli/test_reasoning_full_command.py +++ b/tests/hermes_cli/test_reasoning_full_command.py @@ -9,7 +9,7 @@ and that the clamp gate honours the flag. import os -import yaml +import hermes_yaml as yaml from hermes_cli.cli_commands_mixin import CLICommandsMixin from hermes_cli.config import DEFAULT_CONFIG diff --git a/tests/hermes_cli/test_relay_plugin_cutover.py b/tests/hermes_cli/test_relay_plugin_cutover.py index 24eb773949..351c6f510b 100644 --- a/tests/hermes_cli/test_relay_plugin_cutover.py +++ b/tests/hermes_cli/test_relay_plugin_cutover.py @@ -6,7 +6,7 @@ import os from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.config import migrate_config from hermes_cli.doctor_config import collect_relay_plugin_cutover_findings diff --git a/tests/hermes_cli/test_resolve_turn_limit.py b/tests/hermes_cli/test_resolve_turn_limit.py index 2cacb9d08d..41b08e911a 100644 --- a/tests/hermes_cli/test_resolve_turn_limit.py +++ b/tests/hermes_cli/test_resolve_turn_limit.py @@ -141,7 +141,7 @@ class TestGatewayBridgeNullHandling: def test_none_value_not_bridged(self, monkeypatch, tmp_path): """YAML ``max_turns: null`` should not set HERMES_MAX_ITERATIONS.""" - import yaml + import hermes_yaml as yaml cfg_file = tmp_path / "config.yaml" cfg_file.write_text("agent:\n max_turns: null\n", encoding="utf-8") monkeypatch.setenv("HERMES_MAX_ITERATIONS", "stale-120") @@ -172,7 +172,7 @@ class TestGatewayBridgeNullHandling: def test_bare_key_treated_as_null(self, monkeypatch, tmp_path): """YAML ``max_turns:`` (bare key, no value) parses as Python None.""" - import yaml + import hermes_yaml as yaml cfg_file = tmp_path / "config.yaml" cfg_file.write_text("agent:\n max_turns:\n", encoding="utf-8") monkeypatch.setenv("HERMES_MAX_ITERATIONS", "stale-90") diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py index ac7211cbc0..a7dac048ce 100644 --- a/tests/hermes_cli/test_set_config_value.py +++ b/tests/hermes_cli/test_set_config_value.py @@ -133,7 +133,7 @@ class TestConfigYamlRouting: ): set_config_value("terminal.docker_shared_container_key", "off") - import yaml + import hermes_yaml as yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) assert saved["terminal"]["docker_shared_container_key"] == "off" @@ -193,7 +193,7 @@ class TestConfigGetUnset: args = argparse.Namespace(config_command="unset", key="terminal.backend") config_command(args) - import yaml + import hermes_yaml as yaml reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) or {} assert reloaded == {} assert "TERMINAL_ENV=" not in _read_env(_isolated_hermes_home) @@ -212,7 +212,7 @@ class TestConfigGetUnset: args = argparse.Namespace(config_command="unset", key="platforms.teams.extra.access_token") config_command(args) - import yaml + import hermes_yaml as yaml reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) assert "access_token" not in reloaded["platforms"]["teams"]["extra"] assert reloaded["platforms"]["teams"]["extra"]["tenant_id"] == "tenant" @@ -246,7 +246,7 @@ class TestListNavigation: set_config_value("custom_providers.0.api_key", "new-a") - import yaml + import hermes_yaml as yaml reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) # The list must still be a list assert isinstance(reloaded["custom_providers"], list) @@ -274,7 +274,7 @@ class TestListNavigation: set_config_value("custom_providers.0.api_key", "rotated") - import yaml + import hermes_yaml as yaml reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) entry = reloaded["custom_providers"][0] assert entry["api_key"] == "rotated" @@ -299,7 +299,7 @@ class TestListNavigation: # the canonical path. set_config_value("telegram.allowlist.1.role", "admin") - import yaml + import hermes_yaml as yaml reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) allowlist = reloaded["telegram"]["allowlist"] assert isinstance(allowlist, list) @@ -359,7 +359,7 @@ class TestStringTypedConfigValues: """Values stay strings when DEFAULT_CONFIG declares the leaf as a string.""" set_config_value("approvals.mode", value) - import yaml + import hermes_yaml as yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) assert saved["approvals"]["mode"] == value assert isinstance(saved["approvals"]["mode"], str) @@ -373,7 +373,7 @@ class TestStringTypedConfigValues: ): set_config_value(key, value) - import yaml + import hermes_yaml as yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) node = saved for part in key.split("."): @@ -386,7 +386,7 @@ class TestStringTypedConfigValues: # (schema validation, #34067); coercion behavior is unchanged. set_config_value("custom.enabled", "off", force=True) - import yaml + import hermes_yaml as yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) assert saved["custom"]["enabled"] is False @@ -468,7 +468,7 @@ class TestSchemaValidation: def test_desktop_macos_signing_identity_is_accepted(self, _isolated_hermes_home, capsys): """The documented TCC signing identity setting is part of the schema.""" set_config_value("desktop.macos_signing_identity", "Hermes Local Signing") - import yaml + import hermes_yaml as yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) assert saved["desktop"]["macos_signing_identity"] == "Hermes Local Signing" assert "not a recognized config key" not in capsys.readouterr().out @@ -586,8 +586,8 @@ class TestMappingGuard: """ def _write_config(self, tmp_path, data: dict): - import yaml as _yaml - (tmp_path / "config.yaml").write_text(_yaml.dump(data)) + import hermes_yaml as _yaml + (tmp_path / "config.yaml").write_text(_yaml.safe_dump(data)) def test_bare_model_shorthand_preserves_siblings(self, _isolated_hermes_home): """hermes config set model → model.default, siblings survive.""" @@ -601,7 +601,7 @@ class TestMappingGuard: }) set_config_value("model", "claude-sonnet-4-20250514") config_text = _read_config(_isolated_hermes_home) - import yaml as _yaml + import hermes_yaml as _yaml parsed = _yaml.safe_load(config_text) assert parsed["model"]["default"] == "claude-sonnet-4-20250514" assert parsed["model"]["provider"] == "openai-api" @@ -635,7 +635,7 @@ class TestMappingGuard: } }) set_config_value("terminal", "zsh", force=True) - import yaml as _yaml + import hermes_yaml as _yaml parsed = _yaml.safe_load(_read_config(_isolated_hermes_home)) assert parsed["terminal"] == "zsh" @@ -648,7 +648,7 @@ class TestMappingGuard: } }) set_config_value("model.default", "claude-opus-4") - import yaml as _yaml + import hermes_yaml as _yaml parsed = _yaml.safe_load(_read_config(_isolated_hermes_home)) assert parsed["model"]["default"] == "claude-opus-4" assert parsed["model"]["provider"] == "openai-api" @@ -663,7 +663,7 @@ class TestMappingGuard: } }) set_config_value("model", "claude-opus-4", force=True) - import yaml as _yaml + import hermes_yaml as _yaml parsed = _yaml.safe_load(_read_config(_isolated_hermes_home)) assert parsed["model"] == "claude-opus-4" @@ -673,7 +673,7 @@ class TestScalarModelSubKeyPreservation: def test_scalar_model_id_preserved_after_provider_write(self, _isolated_hermes_home): """Seed model: gpt-4o, then set model.provider → model.default must survive.""" - import yaml + import hermes_yaml as yaml set_config_value("model", "gpt-4o") set_config_value("model.provider", "openai") @@ -686,7 +686,7 @@ class TestScalarModelSubKeyPreservation: def test_scalar_model_id_preserved_after_api_key_write(self, _isolated_hermes_home): """model.api_key must also preserve the existing scalar model id.""" - import yaml + import hermes_yaml as yaml set_config_value("model", "claude-sonnet") # model.api_key is a sub-key (has a dot), so it stays in config.yaml @@ -748,7 +748,7 @@ class TestLiteralDotKeyEscaping: """ def _write_config(self, tmp_path, data: dict): - import yaml as _yaml + import hermes_yaml as _yaml (tmp_path / "config.yaml").write_text(_yaml.safe_dump(data, sort_keys=False)) def test_split_key_path_escaped_dot(self): @@ -778,7 +778,7 @@ class TestLiteralDotKeyEscaping: '{"Wafer-ZDR": "required"}', ) - import yaml + import hermes_yaml as yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) providers = saved["providers"] # No bogus ``qwen3`` nesting was created; the existing entry was updated. @@ -807,7 +807,7 @@ class TestLiteralDotKeyEscaping: ) config_command(args) - import yaml + import hermes_yaml as yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) assert "qwen3.5-397b-wafer-non-zdr" not in saved["providers"] assert saved["providers"]["openrouter"] == {"api_key": "or-keep"} @@ -829,7 +829,7 @@ class TestLiteralDotKeyEscaping: ) config_command(args) - import yaml + import hermes_yaml as yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) target = saved["providers"]["qwen3.5-397b-wafer-non-zdr"] assert "extra_headers" not in target @@ -853,6 +853,6 @@ class TestLiteralDotKeyEscaping: """Nesting semantics for plain dotted keys are untouched.""" set_config_value("terminal.backend", "docker") - import yaml + import hermes_yaml as yaml saved = yaml.safe_load(_read_config(_isolated_hermes_home)) assert saved["terminal"]["backend"] == "docker" diff --git a/tests/hermes_cli/test_setup_tts_xai_oauth.py b/tests/hermes_cli/test_setup_tts_xai_oauth.py index 57b5446792..4baa4ca6dd 100644 --- a/tests/hermes_cli/test_setup_tts_xai_oauth.py +++ b/tests/hermes_cli/test_setup_tts_xai_oauth.py @@ -2,7 +2,7 @@ import json -import yaml +import hermes_yaml as yaml def test_run_xai_oauth_login_from_setup_does_not_hijack_active_provider( diff --git a/tests/hermes_cli/test_sibling_config_migration.py b/tests/hermes_cli/test_sibling_config_migration.py index a9847ba2d7..5d5de4dc36 100644 --- a/tests/hermes_cli/test_sibling_config_migration.py +++ b/tests/hermes_cli/test_sibling_config_migration.py @@ -10,7 +10,7 @@ These tests use REAL config files on disk and the REAL migration pipeline — only the profile-root location is pointed at tmp_path. """ -import yaml +import hermes_yaml as yaml from pathlib import Path import hermes_cli.update_cmd as update_cmd diff --git a/tests/hermes_cli/test_skin_cmd.py b/tests/hermes_cli/test_skin_cmd.py index e475065175..0a4f6ee84c 100644 --- a/tests/hermes_cli/test_skin_cmd.py +++ b/tests/hermes_cli/test_skin_cmd.py @@ -7,7 +7,7 @@ The whole point is that changing one token never disturbs the rest of the look import os import pytest -import yaml +import hermes_yaml as yaml from hermes_cli import skin_cmd from hermes_constants import get_hermes_home diff --git a/tests/hermes_cli/test_skin_engine.py b/tests/hermes_cli/test_skin_engine.py index 2b46d537ea..bd4f3ecd39 100644 --- a/tests/hermes_cli/test_skin_engine.py +++ b/tests/hermes_cli/test_skin_engine.py @@ -103,8 +103,8 @@ class TestUserSkins: "branding": {"agent_name": "Custom Agent"}, "tool_prefix": "▸", } - import yaml - skin_file.write_text(yaml.dump(skin_data)) + import hermes_yaml as yaml + skin_file.write_text(yaml.safe_dump(skin_data)) # Patch skins dir monkeypatch.setattr("hermes_cli.skin_engine._skins_dir", lambda: skins_dir) @@ -122,10 +122,10 @@ class TestUserSkins: skins_dir = tmp_path / "skins" skins_dir.mkdir() - import yaml + import hermes_yaml as yaml (skins_dir / "broken.yaml").write_text( - yaml.dump( + yaml.safe_dump( { "name": "broken", "colors": ["not", "a", "mapping"], @@ -152,8 +152,8 @@ class TestUserSkins: from hermes_cli.skin_engine import list_skins skins_dir = tmp_path / "skins" skins_dir.mkdir() - import yaml - (skins_dir / "pirate.yaml").write_text(yaml.dump({ + import hermes_yaml as yaml + (skins_dir / "pirate.yaml").write_text(yaml.safe_dump({ "name": "pirate", "description": "Arr matey", })) diff --git a/tests/hermes_cli/test_startup_fast_guards.py b/tests/hermes_cli/test_startup_fast_guards.py index 0692edaf60..688968cd5c 100644 --- a/tests/hermes_cli/test_startup_fast_guards.py +++ b/tests/hermes_cli/test_startup_fast_guards.py @@ -27,11 +27,12 @@ import pytest REPO_ROOT = Path(__file__).resolve().parents[2] # Modules that must NEVER be imported by the fast path. Each one either -# pulls yaml/argparse/logging config or is itself a god-module. +# pulls YAML/argparse/logging config or is itself a god-module. _FORBIDDEN_MODULES = ( "hermes_cli.config", "hermes_cli.main", - "yaml", + "hermes_yaml", + "ruamel.yaml", "argparse", "cli", "run_agent", diff --git a/tests/hermes_cli/test_timestamps_command.py b/tests/hermes_cli/test_timestamps_command.py index 200f432ded..2e87e38837 100644 --- a/tests/hermes_cli/test_timestamps_command.py +++ b/tests/hermes_cli/test_timestamps_command.py @@ -12,7 +12,7 @@ import sys import time from datetime import datetime -import yaml +import hermes_yaml as yaml from hermes_cli.cli_commands_mixin import CLICommandsMixin diff --git a/tests/hermes_cli/test_update_channel.py b/tests/hermes_cli/test_update_channel.py index 3a5ea65618..37bf6f66c0 100644 --- a/tests/hermes_cli/test_update_channel.py +++ b/tests/hermes_cli/test_update_channel.py @@ -161,7 +161,7 @@ class TestSetChannel: return home def test_set_resolve_round_trip(self, tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml home = self._home(tmp_path, monkeypatch) root = tmp_path / "install" @@ -177,7 +177,7 @@ class TestSetChannel: assert resolve_update_channel(written, root) == CHANNEL_STABLE def test_preserves_other_config_and_other_installs(self, tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml home = self._home(tmp_path, monkeypatch) other = tmp_path / "other" @@ -220,7 +220,7 @@ class TestSetChannel: text = (home / "config.yaml").read_text() assert "# my hand-maintained settings" in text assert "# keep this" in text - import yaml + import hermes_yaml as yaml written = yaml.safe_load(text) assert written["model"] == {"provider": "nous"} @@ -313,7 +313,7 @@ class TestSetChannelCLI: def test_metadata_commands_precede_managed_refusal_and_write_once( self, tmp_path, monkeypatch, capsys ): - import yaml + import hermes_yaml as yaml import utils from hermes_cli import config, main diff --git a/tests/hermes_cli/test_update_config_clears_custom_fields.py b/tests/hermes_cli/test_update_config_clears_custom_fields.py index 212848c81e..491ecfb191 100644 --- a/tests/hermes_cli/test_update_config_clears_custom_fields.py +++ b/tests/hermes_cli/test_update_config_clears_custom_fields.py @@ -13,7 +13,7 @@ the persisted value here is safe. from __future__ import annotations -import yaml +import hermes_yaml as yaml from hermes_cli.auth import _update_config_for_provider from hermes_cli.config import clear_model_endpoint_credentials, get_config_path diff --git a/tests/hermes_cli/test_user_providers_model_switch.py b/tests/hermes_cli/test_user_providers_model_switch.py index 963ab9c98f..eff5d57bbc 100644 --- a/tests/hermes_cli/test_user_providers_model_switch.py +++ b/tests/hermes_cli/test_user_providers_model_switch.py @@ -360,7 +360,7 @@ def test_list_authenticated_providers_dedup_honors_base_url_env_override(monkeyp def test_switch_model_resolves_user_provider_credentials(monkeypatch, tmp_path): """/model switch should resolve credentials for providers: dict providers.""" - import yaml + import hermes_yaml as yaml config = { "providers": { @@ -373,7 +373,7 @@ def test_switch_model_resolves_user_provider_credentials(monkeypatch, tmp_path): } config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump(config)) + config_file.write_text(yaml.safe_dump(config)) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) # Mock validation to pass diff --git a/tests/hermes_cli/test_venv_sync.py b/tests/hermes_cli/test_venv_sync.py index ea20c90d51..fb0926a38e 100644 --- a/tests/hermes_cli/test_venv_sync.py +++ b/tests/hermes_cli/test_venv_sync.py @@ -147,7 +147,7 @@ def _checkout(tmp_path: Path, name: str = "co") -> Path: def _wire_uv(monkeypatch, tmp_path: Path, exit_code: int = 0) -> Path: """Point venv_sync's managed-uv resolution at a fake binary. - The resolution itself is pm's (pm.ensure.uv) and pm's ledger wiring + The resolution itself is pm's (pm.client.uv) and pm's ledger wiring has its own tests; here the decision layer is under test, so the seam is venv_sync._managed_uv. """ @@ -229,9 +229,13 @@ class TestCheckoutSync: self, tmp_path, monkeypatch ): root = _checkout(tmp_path) - # pm has nothing installed here and may not lazy-install. - monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "empty-store")) - monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + # Runtime provisioning failures are reported through the client seam. + from pm.package import InstallError + + def unavailable(**kwargs): + raise InstallError("pm-runtime", "pinned uv and Python are unavailable") + + monkeypatch.setattr("pm.client.uv", unavailable) out = venv_sync.sync(root) diff --git a/tests/hermes_cli/test_venv_sync_currency.py b/tests/hermes_cli/test_venv_sync_currency.py index bb0e077876..ad3ae0858b 100644 --- a/tests/hermes_cli/test_venv_sync_currency.py +++ b/tests/hermes_cli/test_venv_sync_currency.py @@ -5,9 +5,8 @@ import os from pathlib import Path import subprocess -import yaml +import hermes_yaml as yaml -import pm from hermes_cli import venv_sync from hermes_cli.runtime_paths import install_state_dir, selected_venv from pm import paths @@ -37,7 +36,7 @@ def test_check_uses_real_pm_selection_and_keeps_invalid_evidence(admission_env, return output check('would-sync') - pm.sync_venv(explicit=True) + ensure.sync_venv(explicit=True) facts_path = paths.runtime_facts_path() pristine = facts_path.read_bytes() selected = selected_venv(core) @@ -99,7 +98,9 @@ def test_check_uses_real_pm_selection_and_keeps_invalid_evidence(admission_env, assert not facts_path.exists() -def test_own_tree_sync_reuses_pm_without_writing_an_extra_stamp(admission_env): +def test_own_tree_sync_reuses_pm_without_writing_an_extra_stamp(admission_env, monkeypatch): + # venv_sync imports the public alias; use the same real engine as admission. + monkeypatch.setattr("pm.sync_venv", importlib.import_module("pm.ensure").sync_venv) root, home = admission_env core = root / 'core' assert not venv_sync._stamp_path(core).exists() diff --git a/tests/hermes_cli/test_web_plugins_catalog.py b/tests/hermes_cli/test_web_plugins_catalog.py index f925acf777..79733b0858 100644 --- a/tests/hermes_cli/test_web_plugins_catalog.py +++ b/tests/hermes_cli/test_web_plugins_catalog.py @@ -6,7 +6,7 @@ from __future__ import annotations import json import pytest -import yaml +import hermes_yaml as yaml from hermes_cli import plugin_catalog as pc_cat diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 3eb3207fbc..ad0acfc7e1 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -13,7 +13,7 @@ from types import SimpleNamespace from unittest.mock import patch, MagicMock import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.config import ( reload_env, @@ -1813,7 +1813,7 @@ class TestWebServerEndpoints: def test_numeric_yaml_provider_key_can_be_activated_and_deleted(self): """Hand-edited `providers: 2070:` (YAML int key) must still activate. - PyYAML loads unquoted 2070 as int; string lookup then 404ed, so + YAML loads unquoted 2070 as int; string lookup then 404ed, so Desktop could list the endpoint but not assign or delete it. """ from hermes_cli.config import get_config_path, load_config @@ -1935,7 +1935,7 @@ class TestWebServerEndpoints: secret by the time Save sees it. Migrating it would duplicate the user's secret into a second env var they never asked for. """ - import yaml + import hermes_yaml as yaml from hermes_cli.config import custom_endpoint_key_env, get_config_path, get_env_value diff --git a/tests/hermes_cli/test_web_server_messaging_profiles.py b/tests/hermes_cli/test_web_server_messaging_profiles.py index 193bb4b219..8077ce0415 100644 --- a/tests/hermes_cli/test_web_server_messaging_profiles.py +++ b/tests/hermes_cli/test_web_server_messaging_profiles.py @@ -8,7 +8,7 @@ These tests pin the new behavior: reads and writes land in the REQUESTED profile's HERMES_HOME, and the dashboard's own profile stays untouched. """ import pytest -import yaml +import hermes_yaml as yaml import gateway.status as _gw_status diff --git a/tests/hermes_cli/test_web_server_profile_unification.py b/tests/hermes_cli/test_web_server_profile_unification.py index ce6dd29ec3..5e1bef076a 100644 --- a/tests/hermes_cli/test_web_server_profile_unification.py +++ b/tests/hermes_cli/test_web_server_profile_unification.py @@ -10,7 +10,7 @@ import json from contextlib import contextmanager import pytest -import yaml +import hermes_yaml as yaml import gateway.status as _gw_status import hermes_cli.config as _cfg_mod import hermes_cli.web_server_chat as _web_server_chat diff --git a/tests/hermes_cli/test_web_server_skills_profiles.py b/tests/hermes_cli/test_web_server_skills_profiles.py index e3ce2408c8..99b5d44f9a 100644 --- a/tests/hermes_cli/test_web_server_skills_profiles.py +++ b/tests/hermes_cli/test_web_server_skills_profiles.py @@ -8,7 +8,7 @@ These tests pin the new behavior: reads and writes land in the REQUESTED profile's HERMES_HOME, and the dashboard's own profile stays untouched. """ import pytest -import yaml +import hermes_yaml as yaml import hermes_cli.web_server_gateway as _web_server_gateway import hermes_cli.web_server_profiles as _web_server_profiles diff --git a/tests/hermes_cli/test_web_server_tts_lease.py b/tests/hermes_cli/test_web_server_tts_lease.py index 04956f0757..7bc86da1bb 100644 --- a/tests/hermes_cli/test_web_server_tts_lease.py +++ b/tests/hermes_cli/test_web_server_tts_lease.py @@ -130,7 +130,7 @@ def test_active_default_true(client, monkeypatch): def test_acquire_resolves_provider_inside_target_profile(client, isolated_profiles, monkeypatch): """Warm-up must read the REQUESTING profile's tts config, like /api/audio/speak.""" - import yaml + import hermes_yaml as yaml from tools import tts_tool, tts_tool_lifecycle (isolated_profiles["worker_beta"] / "config.yaml").write_text( diff --git a/tests/plugins/image_gen/test_openai_provider.py b/tests/plugins/image_gen/test_openai_provider.py index 9dc2b64115..e2c8aa77c1 100644 --- a/tests/plugins/image_gen/test_openai_provider.py +++ b/tests/plugins/image_gen/test_openai_provider.py @@ -95,7 +95,7 @@ class TestModelResolution: def test_config_openai_model(self, tmp_path): - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( yaml.safe_dump({"image_gen": {"openai": {"model": "gpt-image-2-low"}}}) ) @@ -183,7 +183,7 @@ class TestGenerate: def test_selection_reaches_image_request( self, provider, monkeypatch, tmp_path, api_model, quality, editing ): - import yaml + import hermes_yaml as yaml tier = api_model if quality == "auto" else f"{api_model}-{quality}" monkeypatch.delenv("OPENAI_IMAGE_MODEL", raising=False) diff --git a/tests/plugins/memory/test_openviking_optional_peer.py b/tests/plugins/memory/test_openviking_optional_peer.py index 23b9521866..6afad77e84 100644 --- a/tests/plugins/memory/test_openviking_optional_peer.py +++ b/tests/plugins/memory/test_openviking_optional_peer.py @@ -7,7 +7,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml import plugins.memory.openviking as ov diff --git a/tests/plugins/test_disk_cleanup_plugin.py b/tests/plugins/test_disk_cleanup_plugin.py index e979c092a3..793d647f0d 100644 --- a/tests/plugins/test_disk_cleanup_plugin.py +++ b/tests/plugins/test_disk_cleanup_plugin.py @@ -373,7 +373,7 @@ class TestSlashCommand: class TestBundledDiscovery: def _write_enabled_config(self, hermes_home, names): """Write plugins.enabled allow-list to config.yaml.""" - import yaml + import hermes_yaml as yaml cfg_path = hermes_home / "config.yaml" cfg_path.write_text(yaml.safe_dump({"plugins": {"enabled": list(names)}})) @@ -393,7 +393,7 @@ class TestBundledDiscovery: def test_disabled_beats_enabled(self, _isolate_env): """plugins.disabled wins even if the plugin is also in plugins.enabled.""" - import yaml + import hermes_yaml as yaml cfg_path = _isolate_env / "config.yaml" cfg_path.write_text(yaml.safe_dump({ "plugins": { diff --git a/tests/plugins/test_langfuse_plugin.py b/tests/plugins/test_langfuse_plugin.py index 7772b0477f..2c4e8a171b 100644 --- a/tests/plugins/test_langfuse_plugin.py +++ b/tests/plugins/test_langfuse_plugin.py @@ -10,7 +10,7 @@ from types import SimpleNamespace import pytest -import yaml +import hermes_yaml as yaml REPO_ROOT = Path(__file__).resolve().parents[2] diff --git a/tests/plugins/test_security_guidance_plugin.py b/tests/plugins/test_security_guidance_plugin.py index a00bafddcd..78e7dfed12 100644 --- a/tests/plugins/test_security_guidance_plugin.py +++ b/tests/plugins/test_security_guidance_plugin.py @@ -265,7 +265,7 @@ class TestPluginDiscovery: def test_loads_via_plugin_manager(self, _isolate_env, monkeypatch): """End-to-end: enable in config.yaml and verify the PluginManager picks it up via the standard discovery path.""" - import yaml + import hermes_yaml as yaml config = {"plugins": {"enabled": ["security-guidance"]}} (_isolate_env / "config.yaml").write_text(yaml.safe_dump(config)) diff --git a/tests/pm/test_activation_setup.py b/tests/pm/test_activation_setup.py index f651a1293c..57c5d07972 100644 --- a/tests/pm/test_activation_setup.py +++ b/tests/pm/test_activation_setup.py @@ -2,6 +2,7 @@ Only the downloaded tool payloads are fixtures: bootstrap uv locates the test interpreter, then delegates every dependency operation to real, offline uv. +The real PM stage builder seeds its locked dependencies online before activation. PM/activation/setup code is copied unmodified; all state is disposable. """ from __future__ import annotations @@ -60,14 +61,14 @@ def test_activation_real_setup_pm_lifecycle(tmp_path, served): "LANG": "C.UTF-8", "PYTHONNOUSERSITE": "1", "UV_OFFLINE": "1", "UV_CACHE_DIR": str(home / ".cache" / "uv"), "UV_PYTHON_DOWNLOADS": "never", } - for name in ("activate", "setup-hermes.sh", "hermes_constants.py", "utils.py"): + for name in ("activate", "setup-hermes.sh", "hermes_constants.py", "hermes_yaml.py", "utils.py"): shutil.copy2(REPO / name, core / name) for name in ("pm", "hermes_cli"): shutil.copytree(REPO / name, core / name, ignore=shutil.ignore_patterns("__pycache__")) # Plugin selection imports the real CLI config reader even with no plugins. # Supply its installed YAML dependency, not a stub parser or config module. - import yaml - shutil.copytree(Path(yaml.__file__).parent, core / "yaml", ignore=shutil.ignore_patterns("__pycache__")) + import ruamel.yaml + shutil.copytree(Path(ruamel.yaml.__file__).parent, core / "ruamel" / "yaml", ignore=shutil.ignore_patterns("__pycache__")) # Never copy real user files: these are deliberately public fixture sentinels. protected = [core / ".env", home / ".local" / "bin", hermes_home / "skills", @@ -97,6 +98,27 @@ def test_activation_real_setup_pm_lifecycle(tmp_path, served): assert locked.returncode == 0, locked.stdout + locked.stderr dependency_lock = (core / "uv.lock").read_bytes() + # Seed PM's own cache with its real locked wheels, not the application's + # fixture wheel or a copied host cache. Discard this environment so source + # still bootstraps and publishes the isolated PM runtime itself, offline. + seed = tmp_path / "pm-seed" + seed_env = {key: value for key, value in env.items() if key != "UV_OFFLINE"} + for key in ("SSL_CERT_FILE", "SSL_CERT_DIR", "NIX_SSL_CERT_FILE"): + if key in os.environ: + seed_env[key] = os.environ[key] + seeded = subprocess.run( + [interpreter, "-I", "-B", "-c", + "import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); " + "from pm.runtime_stage import stage_runtime; " + "stage_runtime(Path(sys.argv[2]), Path(sys.argv[3]), Path(sys.argv[4]), " + "project=Path(sys.argv[1]) / 'pm', offline=False)", + str(core), uv, interpreter, str(seed)], + cwd=tmp_path, env=seed_env, capture_output=True, text=True, timeout=180, + ) + assert seeded.returncode == 0, seeded.stdout + seeded.stderr + shutil.rmtree(seed) + assert not (hermes_home / "installs").exists() + docroot, base_url = served # Both the shell bootstrap and PM's fallback downloader stay on loopback. (core / "pm" / "artifact-mirror.json").write_text( @@ -174,7 +196,9 @@ test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96 assert probe["version"] == "1.0" assert Path(probe["module"]).is_relative_to(Path(first["environment"])) assert probe["pythonpath"].split(os.pathsep)[0] == str(core) - assert len(operations("venv")) == len(operations("sync")) == 1 + # Cold activation builds both PM's isolated runtime and the app environment. + assert "Preparing the isolated PM runtime" in cold.stderr + assert len(operations("venv")) == len(operations("sync")) == 2 facts = json.loads((runtime / "facts.json").read_text())["packages"] assert facts["python"]["artifacts"] == [first_digest] assert facts["uv"]["artifacts"] == [uv_digest] @@ -185,7 +209,7 @@ test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96 untouched = _snapshot(protected) activate() assert selection() == first - assert len(operations("venv")) == len(operations("sync")) == 1 + assert len(operations("venv")) == len(operations("sync")) == 2 assert len([line for line in operations("python") if line.startswith("python install ")]) == 2 second_digest = pin_python("second") @@ -194,7 +218,7 @@ test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96 assert second["stamp"] != first["stamp"] assert second["environment"] != first["environment"] assert Path(first["environment"]).is_dir() - assert len(operations("venv")) == len(operations("sync")) == 2 + assert len(operations("venv")) == len(operations("sync")) == 3 facts = json.loads((runtime / "facts.json").read_text())["packages"] assert facts["python"]["artifacts"] == [second_digest] assert (core / "uv.lock").read_bytes() == dependency_lock @@ -207,5 +231,5 @@ test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96 assert "setup failed" in failed.stderr assert "CALLER_SURVIVED:" in failed.stdout assert selection() == second - assert len(operations("sync")) == 3 + assert len(operations("sync")) == 4 assert len([line for line in operations("python") if line.startswith("python install ")]) == 4 diff --git a/tests/pm/test_admission_members_locked.py b/tests/pm/test_admission_members_locked.py index dea6301e4a..d739d17d4d 100644 --- a/tests/pm/test_admission_members_locked.py +++ b/tests/pm/test_admission_members_locked.py @@ -10,6 +10,8 @@ def test_admission_reads_other_profiles_after_taking_lock(tmp_path, monkeypatch) import pm.paths as paths ensure = importlib.import_module("pm.ensure") + # Exercise the engine lock ordering, not cross-process transport. + monkeypatch.setattr("pm.client.sync_venv", ensure.sync_venv) monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) repo = tmp_path / "repo" repo.mkdir() diff --git a/tests/pm/test_cold_runtime_e2e.py b/tests/pm/test_cold_runtime_e2e.py new file mode 100644 index 0000000000..7cf7bcbe59 --- /dev/null +++ b/tests/pm/test_cold_runtime_e2e.py @@ -0,0 +1,242 @@ +"""Cold CLI provisioning and repair, with real tools and no mocked child code. + +Only uv/Python are offered by the fixture's loopback archive server. The copied +PM recipe is unchanged; its small locked runtime is fetched from PyPI. The app +recipe is deliberately tiny so this cannot install the production dependency +set. Run through scripts/run_tests.sh with uv/uvx available on PATH. +""" +from __future__ import annotations + +from functools import partial +import hashlib +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tarfile +import threading +import tomllib + +import pytest + + +class _ArchiveHandler(SimpleHTTPRequestHandler): + def copyfile(self, source, outputfile): + # The downloader closes its size probe without consuming the archive. + try: + super().copyfile(source, outputfile) + except (BrokenPipeError, ConnectionResetError): + pass + + +def _run(command, *, cwd, env, expected=0, timeout=240): + result = subprocess.run(command, cwd=cwd, env=env, capture_output=True, + text=True, timeout=timeout) + assert result.returncode == expected, ( + f"{command!r}\nexit={result.returncode}\n{result.stdout}\n{result.stderr}" + ) + return result + + +def _bare(python, repo, code, *, env, expected=0): + return _run( + [str(python), "-I", "-S", "-B", "-c", + f"import sys; sys.path.insert(0, {str(repo)!r});\n" + code], + cwd=repo.parent, env=env, expected=expected, + ) + + +@pytest.mark.platforms("linux") +def test_cold_cli_builds_own_runtime_discovers_plugins_and_repairs_app(tmp_path): + from pm.packages import Python, Uv + from pm.store import current_target, tree_digest + + uv = shutil.which("uv") + if uv is None: + pytest.skip("real uv and uvx must be on PATH") + uv = Path(uv).resolve() + uvx = uv.with_name("uvx") + assert uvx.is_file(), "the real uv distribution must include uvx" + python = Path(sys._base_executable).resolve() + if sys.version_info[:2] != (3, 14): + pytest.skip("the checked-in PM runtime currently requires Python 3.14") + + source = Path(__file__).resolve().parents[2] + repo = tmp_path / "source" + repo.mkdir() + for name in ("pm", "hermes_cli"): + shutil.copytree(source / name, repo / name, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + for name in ("utils.py", "hermes_constants.py", "hermes_yaml.py", + "hermes_bootstrap.py"): + shutil.copy2(source / name, repo / name) + # No production application lock or metadata enters this source snapshot. + recipe = tomllib.loads((repo / "pm" / "pyproject.toml").read_text()) + yaml_requirement = next(dep for dep in recipe["project"]["dependencies"] + if dep.startswith("ruamel.yaml")) + (repo / "pyproject.toml").write_text( + '[project]\nname="cold-pm-app"\nversion="0.0.0"\n' + 'requires-python=">=3.14,<3.15"\n' + f'dependencies=[{json.dumps(yaml_requirement)}]\n' + '[project.optional-dependencies]\nall=[]\n' + '[tool.uv]\npackage=false\n', encoding="utf-8", + ) + home = tmp_path / "home" + hermes_home = home / ".hermes" + plugin = hermes_home / "plugins" / "cold-proof" + plugin.mkdir(parents=True) + config = hermes_home / "config.yaml" + config.write_text("plugins:\n enabled: [cold-proof]\n", encoding="utf-8") + (plugin / "plugin.yaml").write_text( + "name: cold-proof\nversion: 1.0.0\npip_dependencies: [idna==3.10]\n", + encoding="utf-8", + ) + store = hermes_home / "tools" + scratch = tmp_path / "scratch" + scratch.mkdir() + env = { + "HOME": str(home), "HERMES_HOME": str(hermes_home), + "HERMES_RUNTIME_DIR": str(store), "PATH": os.defpath, + "TMPDIR": str(scratch), "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", + "UV_PYTHON_DOWNLOADS": "never", "UV_NO_CONFIG": "1", + "UV_CACHE_DIR": str(tmp_path / "seed-cache"), + } + # Keep TLS functional on Nix without inheriting any application settings. + for key in ("SSL_CERT_FILE", "SSL_CERT_DIR", "NIX_SSL_CERT_FILE"): + if key in os.environ: + env[key] = os.environ[key] + _run([str(uv), "lock", "--project", str(repo), "--python", str(python)], + cwd=tmp_path, env=env) + + archives = tmp_path / "archives" + archives.mkdir() + target = current_target() + rows = {} + for package, files in ( + (Python(), [(python, "python/bin/python3")]), + (Uv(), [(uv, "uv-dist/uv"), (uvx, "uv-dist/uvx")]), + ): + archive = archives / f"{package.name}.tar.gz" + with tarfile.open(archive, "w:gz", compresslevel=1) as tar: + for binary, name in files: + tar.add(binary, arcname=name) + version = _run([str(files[0][0]), "--version"], cwd=tmp_path, + env=env).stdout.split()[1] + rows[package.name] = { + "version": version, + "artifacts": {target: {"sha256": hashlib.sha256(archive.read_bytes()).hexdigest()}}, + } + server = ThreadingHTTPServer(("127.0.0.1", 0), + partial(_ArchiveHandler, directory=str(archives))) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + for name, row in rows.items(): + row["artifacts"][target]["url"] = f"http://127.0.0.1:{server.server_port}/{name}.tar.gz" + (repo / "pm" / "lock.json").write_text( + json.dumps({"schema": 1, "packages": rows}), encoding="utf-8", + ) + + bootstrap = """ +import importlib.util +assert importlib.util.find_spec('ruamel') is None +assert importlib.util.find_spec('yaml') is None +assert importlib.util.find_spec('packaging') is None +assert importlib.util.find_spec('idna') is None +""" + cli = "\nsys.argv = ['hermes', 'pm', {action!r}]; import hermes_cli.main\n" + try: + assert not store.exists() + assert not (hermes_home / "installs").exists() + result = _bare(python, repo, bootstrap + cli.format(action="install"), env=env) + assert "✓ venv" in result.stdout + assert "Preparing the isolated PM runtime" in result.stderr + finally: + server.shutdown() + server.server_close() + thread.join(timeout=10) + + report_code = """ +import json +from pathlib import Path +from hermes_cli.runtime_paths import install_state_dir, selected_venv, runtime_facts_path +root = Path(sys.path[0]) +state = install_state_dir(root) +print(json.dumps({'state': str(state), 'app': str(selected_venv(root)), + 'facts': json.loads(runtime_facts_path(root).read_text())})) +""" + report = json.loads(_bare(python, repo, report_code, env=env).stdout) + state, app = Path(report["state"]), Path(report["app"]) + assert state.is_relative_to(hermes_home) + pm_root = state / "pm-runtime" + selection = (pm_root / "selected.json").read_bytes() + runtime = pm_root / json.loads(selection)["generation"] + pm_python = runtime / "bin/python" + assert runtime != app + facts = json.loads((store / "facts.json").read_text())["packages"] + assert set(facts) == {"python", "uv"} + for name, fact in facts.items(): + assert fact["version"] == rows[name]["version"] + assert fact["artifacts"] == [rows[name]["artifacts"][target]["sha256"]] + assert tree_digest(store / fact["entry"]) == fact["digest"] + + probe = _run([str(pm_python), "-I", "-B", "-c", f""" +import importlib.util, json, sys +from pathlib import Path +sys.path.insert(0, {str(repo)!r}) +from pm.workspace import enabled_member_dirs +from ruamel.yaml import YAML +import ruamel.yaml +assert enabled_member_dirs() == [Path({str(plugin)!r})] +assert importlib.util.find_spec('idna') is None +assert importlib.util.find_spec('openai') is None +print(json.dumps({{'yaml': ruamel.yaml.__file__, 'prefix': sys.prefix}})) +"""], cwd=tmp_path, env=env) + pm_report = json.loads(probe.stdout) + assert Path(pm_report["yaml"]).is_relative_to(runtime) + app_code = """ +import json +from pathlib import Path +from hermes_cli.runtime_paths import activate_dependencies, selected_venv +root = Path(sys.path[0]) +activate_dependencies(root) +import ruamel.yaml, idna +from ruamel.yaml import YAML +assert idna.encode('bücher.example') == b'xn--bcher-kva.example' +assert YAML(typ='safe').load('proof: true')['proof'] is True +print(json.dumps({'yaml': ruamel.yaml.__file__, 'idna': idna.__file__, + 'app': str(selected_venv(root))})) +""" + app_report = json.loads(_bare(python, repo, app_code, env=env).stdout) + assert Path(app_report["yaml"]).is_relative_to(app) + assert Path(app_report["idna"]).is_relative_to(app) + initial_fact = report["facts"]["packages"]["venv"] + assert initial_fact["extras"] == ["all"] + lock_before = Path(initial_fact["resolved_lock"]).read_bytes() + assert b'idna' in lock_before + + # Real damage: the app no longer imports, while PM retains its independent YAML. + shutil.rmtree(Path(app_report["yaml"]).parent) + broken = _bare(python, repo, app_code, env=env, expected=1) + assert "ModuleNotFoundError" in broken.stderr + assert "ruamel.yaml" in broken.stderr + repaired = _bare(python, repo, bootstrap + cli.format(action="repair"), env=env) + assert "Restart Hermes" in repaired.stdout + restored = json.loads(_bare(python, repo, app_code, env=env).stdout) + repaired_app = Path(restored["app"]) + assert repaired_app != app + assert Path(restored["yaml"]).is_relative_to(repaired_app) + assert Path(restored["idna"]).is_relative_to(repaired_app) + assert (pm_root / "selected.json").read_bytes() == selection + after = json.loads(_bare(python, repo, report_code, env=env).stdout) + repaired_fact = after["facts"]["packages"]["venv"] + assert repaired_fact["extras"] == initial_fact["extras"] + assert repaired_fact["stamp"] == initial_fact["stamp"] + assert Path(repaired_fact["resolved_lock"]).read_bytes() == lock_before + assert config.read_text() == "plugins:\n enabled: [cold-proof]\n" + assert not (state / ".repair-incomplete").exists() + assert not (repo / "venv").exists() + assert not (repo / ".venv").exists() + print(f"cold PM proof: runtime={runtime}; first_app={app}; repaired_app={repaired_app}") diff --git a/tests/pm/test_custom_root_union.py b/tests/pm/test_custom_root_union.py index 5d10e6a5ae..7df2235968 100644 --- a/tests/pm/test_custom_root_union.py +++ b/tests/pm/test_custom_root_union.py @@ -14,7 +14,7 @@ from __future__ import annotations from pathlib import Path -import yaml +import hermes_yaml as yaml import pm.plugins_state as pstate import pm.workspace as ws diff --git a/tests/pm/test_download_cleanup.py b/tests/pm/test_download_cleanup.py index 42e4e6f021..f2c1f33e90 100644 --- a/tests/pm/test_download_cleanup.py +++ b/tests/pm/test_download_cleanup.py @@ -10,7 +10,7 @@ import pytest import pm from pm import paths, registry -from pm.ensure import stage_only +from pm.ensure import ensure, stage_only from pm.lock import Facts, Lockfile from pm.package import Package from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401 @@ -56,7 +56,7 @@ def install_case(tmp_path, monkeypatch, dl_server): def install(package, mode): if mode == "stage": return stage_only(package.name, pm.current_target()) - pm.ensure(package.name, explicit=True, base_env={}) + ensure(package.name, explicit=True, base_env={}) return pm.installed_package(package.name).path diff --git a/tests/pm/test_extras.py b/tests/pm/test_extras.py index 8c5d9e0886..3f5a513756 100644 --- a/tests/pm/test_extras.py +++ b/tests/pm/test_extras.py @@ -1,21 +1,18 @@ """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).""" +spec→extra install shim. Network-free — sync_venv is stubbed at the client +seam used by extras; the engine and worker have separate transaction tests.""" from __future__ import annotations -import importlib import sys from types import SimpleNamespace import pytest import pm +import pm.client as client import pm.extras as extras -ensure_mod = importlib.import_module("pm.ensure") - # ---- per-extra platform gates ([tool.hermes.extras-platforms]) ---- @@ -64,7 +61,7 @@ def test_ensure_import_raises_on_gated_off_extra(monkeypatch, synced): @pytest.fixture def synced(monkeypatch): calls: list[list[str]] = [] - monkeypatch.setattr(ensure_mod, "sync_venv", lambda x=None: calls.append(list(x or []))) + monkeypatch.setattr(client, "sync_venv", lambda x=None: calls.append(list(x or []))) return calls @@ -106,7 +103,7 @@ 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(client, "sync_venv", boom) monkeypatch.setattr(extras, "available", lambda e: False) with pytest.raises(pm.InstallError): extras.ensure_import("fal") @@ -123,7 +120,7 @@ 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(client, "sync_venv", boom) monkeypatch.setattr(extras, "available", lambda e: False) target: dict = {} assert extras.ensure_and_bind("fal", lambda: {"X": 1}, target) is False diff --git a/tests/pm/test_install_download_control.py b/tests/pm/test_install_download_control.py index c6d9dbd570..beb7d969ee 100644 --- a/tests/pm/test_install_download_control.py +++ b/tests/pm/test_install_download_control.py @@ -13,6 +13,7 @@ import pytest import pm from pm import paths, registry from pm.downloader import DownloadPaused +from pm.ensure import ensure from pm.lock import Facts, Lockfile from pm.package import Package from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401 @@ -60,7 +61,7 @@ def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, mon pause.set() with pytest.raises(DownloadPaused): - pm.ensure(ComponentPackage.name, explicit=True, progress=progress, pause_event=pause) + ensure(ComponentPackage.name, explicit=True, progress=progress, pause_event=pause) assert Facts(paths.facts_path()).get(ComponentPackage.name) is None assert list(paths.partials_root().glob("*.ranges")) first_requests = [request for request in RangeHandler.ranges_seen if request[0] == "/component-0.zip"] @@ -68,7 +69,7 @@ def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, mon assert (root / f"fetch-{pins[0]['sha256']}").is_dir() pause.clear() - pm.ensure(ComponentPackage.name, explicit=True, pause_event=pause) + ensure(ComponentPackage.name, explicit=True, pause_event=pause) fact = Facts(paths.facts_path()).get(ComponentPackage.name) assert fact["artifacts"] == [pin["sha256"] for pin in pins] for name, body in contents.items(): @@ -99,7 +100,7 @@ def test_install_progress_covers_all_archives_including_cache(tmp_path, monkeypa store.fetch(pins[0]["url"], pins[0]["sha256"], scratch) ticks = [] stages = [] - pm.ensure(ComponentPackage.name, explicit=True, + ensure(ComponentPackage.name, explicit=True, progress=lambda *args: stages.append(args), download_progress=lambda done, total, ranges: ticks.append((done, total, ranges))) expected = sum(map(len, payloads)) diff --git a/tests/pm/test_installed_package.py b/tests/pm/test_installed_package.py index 4fa46957bd..30c252644a 100644 --- a/tests/pm/test_installed_package.py +++ b/tests/pm/test_installed_package.py @@ -4,12 +4,13 @@ from __future__ import annotations import pm from pm import paths +from pm.ensure import ensure from pm.lock import Lockfile from tests.pm.test_pm_authority import pm_env, served # noqa: F401 def test_installed_lookup_prefers_current_pin_then_recorded_fallback(pm_env, tmp_path, monkeypatch): - pm.ensure("faketool", explicit=True) + ensure("faketool", explicit=True) original = pm.installed_package("faketool") assert original is not None and original.binary.is_file() diff --git a/tests/pm/test_plugin_survival_contract.py b/tests/pm/test_plugin_survival_contract.py index 2a1a334103..9ae8bcb70e 100644 --- a/tests/pm/test_plugin_survival_contract.py +++ b/tests/pm/test_plugin_survival_contract.py @@ -33,7 +33,7 @@ import sys from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml import pm.plugins_state as pstate import pm.workspace as ws @@ -168,7 +168,9 @@ def admission_env(tmp_path, monkeypatch): monkeypatch.setattr(ws.paths, "repo_root", lambda: core) monkeypatch.setattr(ensure, "lazy_installs_allowed", lambda: True) monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) - # Keep the real dependency transaction. Substitute only tool provisioning. + # Exercise the real dependency transaction in-process so the local uv + # fixture owns provisioning; worker transport is covered separately. + monkeypatch.setattr("pm.client.sync_venv", ensure.sync_venv) from pm.packages import uv_env monkeypatch.setattr(ensure, "uv", lambda **kwargs: ( shutil.which("uv"), {**uv_env(kwargs.get("base_env")), "UV_PYTHON": sys.executable}, diff --git a/tests/pm/test_plugins_state.py b/tests/pm/test_plugins_state.py index 48a068f88f..73fb8de8be 100644 --- a/tests/pm/test_plugins_state.py +++ b/tests/pm/test_plugins_state.py @@ -33,7 +33,7 @@ def homes(tmp_path, monkeypatch): def _write_config(home: Path, enabled: list) -> None: - import yaml + import hermes_yaml as yaml config = {"plugins": {"enabled": enabled}} if enabled else {"plugins": {}} with (home / "config.yaml").open("w", encoding="utf-8") as f: @@ -151,7 +151,7 @@ def test_active_memory_provider_joins_union(homes, tmp_path): provider_dir = default_home / "plugins" / "mnemosyne-like" provider_dir.mkdir(parents=True) (provider_dir / "pyproject.toml").write_text("[project]\n", encoding="utf-8") - import yaml + import hermes_yaml as yaml with (default_home / "config.yaml").open("w", encoding="utf-8") as f: yaml.safe_dump( @@ -167,7 +167,7 @@ def test_active_memory_provider_joins_union(homes, tmp_path): def test_memory_provider_without_dir_is_skipped(homes): """memory.provider set but no plugin dir on disk — not a member.""" default_home, _ = homes - import yaml + import hermes_yaml as yaml with (default_home / "config.yaml").open("w", encoding="utf-8") as f: yaml.safe_dump({"memory": {"provider": "ghost-provider"}}, f) @@ -178,7 +178,7 @@ def test_memory_provider_without_dir_is_skipped(homes): def test_memory_provider_already_enabled_not_duplicated(homes): default_home, _ = homes (default_home / "plugins" / "dual").mkdir(parents=True) - import yaml + import hermes_yaml as yaml with (default_home / "config.yaml").open("w", encoding="utf-8") as f: yaml.safe_dump( diff --git a/tests/pm/test_recovery.py b/tests/pm/test_recovery.py index 4421ac489b..a2f0ec301e 100644 --- a/tests/pm/test_recovery.py +++ b/tests/pm/test_recovery.py @@ -16,6 +16,43 @@ from pm.packages import uv_env from tests.pm.test_workspace_build_inputs import _wheel +@pytest.mark.parametrize("failure", [None, "missing_distribution", "broken_module"]) +def test_startup_validation_checks_real_ruamel_dependency(tmp_path, failure): + from importlib.metadata import distribution + import venv + + import ruamel.yaml + + from hermes_cli.runtime_paths import site_packages + from pm.package import InstallError + from pm.recovery import validate_environment + + candidate = tmp_path / "candidate" + venv.EnvBuilder(with_pip=False).create(candidate) + python = candidate / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + target = site_packages(candidate) + package = target / "ruamel" / "yaml" + # Copy the real parser and distribution metadata into an isolated candidate. + # A fake YAML class would not detect a broken install or missing dependency. + ignored = ["__pycache__"] + (["main.py"] if failure == "broken_module" else []) + shutil.copytree(Path(ruamel.yaml.__file__).parent, package, ignore=shutil.ignore_patterns(*ignored)) + installed = distribution("ruamel.yaml") + assert installed.files + metadata = next(Path(installed.locate_file(path)).parent for path in installed.files if path.name == "METADATA") + if failure != "missing_distribution": + shutil.copytree(metadata, target / metadata.name) + + (tmp_path / "pyproject.toml").write_text( + '[project]\nname="yaml-recovery"\ndependencies=["ruamel.yaml"]\n', encoding="utf-8", + ) + + if failure: + with pytest.raises(InstallError, match="ruamel"): + validate_environment(python, env=dict(os.environ), cwd=tmp_path) + else: + validate_environment(python, env=dict(os.environ), cwd=tmp_path) + + @pytest.mark.parametrize("failure", [None, "missing_lock", "corrupt_facts", "empty_environment", "missing_extras", "validation", "publication"]) def test_repair_restores_recorded_plugin_dependencies_without_config(tmp_path, monkeypatch, failure): import pm.paths as paths @@ -122,6 +159,8 @@ def test_uncertain_profile_selection_refuses_sync_but_not_recorded_repair(tmp_pa from hermes_cli.runtime_paths import install_state_dir, selected_venv, site_packages engine = importlib.import_module("pm.ensure") + # Use the same engine for admission and repair with the offline uv fixture. + monkeypatch.setattr("pm.client.sync_venv", engine.sync_venv) uv = shutil.which("uv") assert uv core = tmp_path / "core" diff --git a/tests/pm/test_runtime.py b/tests/pm/test_runtime.py new file mode 100644 index 0000000000..773ee435c0 --- /dev/null +++ b/tests/pm/test_runtime.py @@ -0,0 +1,85 @@ +"""PM's resolver must not depend on the application it is repairing.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + + +def test_pm_runtime_discovers_plugins_without_application_dependencies(tmp_path, monkeypatch): + from pm.runtime import prepare_runtime + + uv = shutil.which("uv") + if uv is None: + pytest.skip("uv is required for the real dependency-runtime test") + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(home / "tools")) + (home / "config.yaml").write_text("plugins:\n enabled: []\n", encoding="utf-8") + repo = Path(__file__).resolve().parents[2] + python = prepare_runtime(Path(uv), Path(sys.executable), tmp_path / "runtime") + env = {k: v for k, v in os.environ.items() if not k.startswith(("PYTHON", "UV_"))} + env.update(HERMES_HOME=str(home), HERMES_RUNTIME_DIR=str(home / "tools")) + # Import real PM, including its production plugin-discovery chain. + code = f""" +import importlib.util, json, sys +sys.path.insert(0, {str(repo)!r}) +from pm.workspace import enabled_member_dirs +from pm.plugins_state import _read_home_config +from pathlib import Path +assert _read_home_config(Path({str(home)!r}))["plugins"]["enabled"] == [] +assert enabled_member_dirs() == [] +assert importlib.util.find_spec("openai") is None +assert importlib.util.find_spec("yaml") is None +print(json.dumps({{"prefix": sys.prefix, "yaml": importlib.util.find_spec("ruamel.yaml").origin}})) +""" + result = subprocess.run([str(python), "-I", "-B", "-c", code], env=env, + capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + report = json.loads(result.stdout) + assert Path(report["yaml"]).is_relative_to(Path(report["prefix"])) + assert prepare_runtime(Path(uv), Path(sys.executable), tmp_path / "runtime", offline=True) == python + # Repair PM itself from its own lock, without trusting an existing marker. + (Path(report["yaml"]).parent / "main.py").unlink() + repaired = prepare_runtime(Path(uv), Path(sys.executable), tmp_path / "runtime", offline=True) + assert repaired != python + checked = subprocess.run([str(repaired), "-I", "-B", "-c", code], env=env, + capture_output=True, text=True, timeout=30) + assert checked.returncode == 0, checked.stdout + checked.stderr + + +def test_sealed_worker_command_uses_only_its_recorded_site(tmp_path, monkeypatch): + from pm import paths + from pm.runtime import runtime_command + + repo = tmp_path / "payload" / "hermes-agent" + repo.mkdir(parents=True) + (repo.parent / "manifest.json").write_text('{"repo":"hermes-agent"}') + runtime = repo.parent / "pm-runtime" + site = runtime / "site" + site.mkdir(parents=True) + base = repo.parent / "python" + if os.name == "nt": + shutil.copytree(Path(sys.base_prefix), base) + python = base / "python.exe" + else: + base.mkdir() + python = base / "python" + shutil.copy2(Path(sys._base_executable).resolve(), python) + (runtime / "pm-runtime.json").write_text(json.dumps({ + "python": "../python/" + python.name, "sitePackages": "site", + })) + script = repo / "probe.py" + script.write_text("import sys,json; print(json.dumps(sys.path))") + monkeypatch.setattr(paths, "repo_root", lambda: repo) + command = runtime_command(script) + result = subprocess.run(command, cwd=tmp_path, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stderr + entries = json.loads(result.stdout) + assert str(site) in entries + assert not any(Path(entry).name in {"site-packages", "dist-packages"} for entry in entries) diff --git a/tests/pm/test_runtime_entrypoints.py b/tests/pm/test_runtime_entrypoints.py new file mode 100644 index 0000000000..bad7baff5a --- /dev/null +++ b/tests/pm/test_runtime_entrypoints.py @@ -0,0 +1,85 @@ +"""Bootstrap entrypoints must hand dependency operations to PM's own interpreter.""" +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +def test_cli_dispatches_before_calling_engine(monkeypatch): + from pm import cli, runtime + + calls = [] + monkeypatch.setattr(runtime, "is_runtime", lambda: False) + monkeypatch.setattr(runtime, "run_cli", lambda argv: calls.append(argv) or 19) + monkeypatch.setattr(cli, "cmd_install", lambda args: pytest.fail("caller imported the engine")) + argv = ["install", "venv"] + assert cli.main(argv) == 19 + assert calls == [argv] + + +def test_cli_runtime_executes_without_redispatch(monkeypatch): + from pm import cli, runtime + + monkeypatch.setattr(runtime, "is_runtime", lambda: True) + monkeypatch.setattr(runtime, "run_cli", lambda argv: pytest.fail("recursive PM dispatch")) + monkeypatch.setattr(cli, "cmd_install", lambda args: 7 if args.names == ["venv"] else 1) + assert cli.main(["install", "venv"]) == 7 + + +def test_ci_dependency_phase_uses_isolated_runtime(tmp_path, monkeypatch): + from pm import runtime + from scripts.ci import setup_toolchain + import subprocess + + import importlib + ensure = importlib.import_module("pm.ensure") + + monkeypatch.setattr(ensure, "uv", lambda: pytest.fail("dependency work ran in bootstrap Python")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) + calls = [] + python = tmp_path / "pm-runtime" / "python" + monkeypatch.setattr(runtime, "is_runtime", lambda: False) + monkeypatch.setattr(runtime, "runtime_python", lambda: python) + monkeypatch.setattr(subprocess, "run", lambda command, **kw: calls.append((command, kw))) + monkeypatch.setenv("PYTHONPATH", str(tmp_path / "app-deps")) + args = SimpleNamespace(home=tmp_path, extras=["dev"], toolchain="all") + setup_toolchain.dependencies(args) + command, options = calls.pop() + assert command[:3] == [str(python), "-I", "-B"] + assert Path(command[3]).name == "setup_toolchain.py" + assert command[4:] == ["dependencies", "--home", str(tmp_path), "--toolchain", "all", "--extras", json.dumps(["dev"])] + assert "PYTHONPATH" not in options["env"] + assert options["check"] is True + assert calls == [] + + +@pytest.mark.parametrize("distribution", ["nix", "docker"]) +def test_packaged_runtime_uses_explicit_stamp_without_tool_downloads(tmp_path, monkeypatch, distribution): + import importlib + from pm import runtime, paths + + engine = importlib.import_module("pm.ensure") + monkeypatch.setattr(engine, "uv", lambda **kw: pytest.fail("packaged PM tried to download tools")) + project = tmp_path / "app" + project.mkdir() + monkeypatch.setattr(paths, "repo_root", lambda: project) + install_root = tmp_path / "package" + install_root.mkdir() + monkeypatch.setenv("HERMES_INSTALL_ROOT", str(install_root)) + pm = install_root / "pm-runtime" + site = pm / "site-packages" + site.mkdir(parents=True) + python = install_root / "python" + python.touch() + (pm / "pm-runtime.json").write_text(json.dumps({"python": str(python), "sitePackages": str(site)})) + stamp = {"distribution": distribution, "pmRuntime": str(pm)} + (install_root / "install-stamp.json").write_text(json.dumps(stamp)) + command = runtime.runtime_command(project / "worker.py", ["argument"]) + assert command[:4] == [str(python), "-I", "-S", "-B"] + assert str(site) in command + assert command[-2:] == [str(project / "worker.py"), "argument"] + (pm / "pm-runtime.json").unlink() + from pm.package import InstallError + with pytest.raises(InstallError, match="PM runtime"): + runtime.runtime_command(project / "worker.py") diff --git a/tests/pm/test_runtime_public.py b/tests/pm/test_runtime_public.py new file mode 100644 index 0000000000..2cfa4b623a --- /dev/null +++ b/tests/pm/test_runtime_public.py @@ -0,0 +1,19 @@ +"""The public PM surface never installs into the caller's Python process.""" +import importlib + + +def test_public_mutations_delegate_but_environment_reads_stay_local(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) + import pm + client = importlib.import_module("pm.client") + engine = importlib.import_module("pm.ensure") + calls = [] + monkeypatch.setattr(client, "_request", lambda operation, arguments, **kw: calls.append(operation)) + monkeypatch.setattr(engine, "ensure", lambda *a, **kw: (_ for _ in ()).throw(AssertionError("inline install"))) + monkeypatch.setattr(engine, "sync_venv", lambda *a, **kw: (_ for _ in ()).throw(AssertionError("inline sync"))) + monkeypatch.setattr(client, "is_runtime", lambda: False, raising=False) + pm.sync_venv(["all"], explicit=True, plugin_dirs=lambda: (_ for _ in ()).throw(AssertionError("inline sync"))) + pm.ensure("node", explicit=True) + assert calls == ["sync_venv", "ensure"] + assert pm.env_for is engine.env_for diff --git a/tests/pm/test_runtime_transaction.py b/tests/pm/test_runtime_transaction.py index d07931a773..c745de289d 100644 --- a/tests/pm/test_runtime_transaction.py +++ b/tests/pm/test_runtime_transaction.py @@ -124,6 +124,6 @@ def test_lazy_import_reports_restart_instead_of_importing_mixed_versions(tmp_pat (candidate / "pyvenv.cfg").write_text("home = test") def prepare(requested): Facts(runtime_facts_path(root)).record_state("venv", "new", requested, environment=candidate) - monkeypatch.setattr(importlib.import_module("pm.ensure"), "sync_venv", prepare) + monkeypatch.setattr("pm.client.sync_venv", prepare) with pytest.raises(InstallError, match="restart"): extras.ensure_import("new-extra") diff --git a/tests/pm/test_runtime_wheelhouse.py b/tests/pm/test_runtime_wheelhouse.py new file mode 100644 index 0000000000..79936b3af1 --- /dev/null +++ b/tests/pm/test_runtime_wheelhouse.py @@ -0,0 +1,112 @@ +"""Exercise offline PM staging with real wheels, not the application's environment.""" +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tomllib +import urllib.request + +from packaging.tags import sys_tags +from packaging.utils import parse_wheel_filename +import pytest + +from pm.runtime import runtime_environment +from pm.runtime_stage import stage_runtime +from scripts.bundles.payload import seal_pm_runtime + + +@pytest.fixture(scope="module") +def locked_wheelhouse(tmp_path_factory): + """Download host wheels first; only the subsequent stage runs offline.""" + wheelhouse = tmp_path_factory.mktemp("pm-wheelhouse") + project = Path(__file__).resolve().parents[2] / "pm" + lock = tomllib.loads((project / "uv.lock").read_text(encoding="utf-8")) + tags = set(sys_tags()) + versions = {} + for package in lock["package"]: + if "registry" not in package.get("source", {}): + continue + choices = [wheel for wheel in package["wheels"] + if parse_wheel_filename(wheel["url"].rsplit("/", 1)[1])[3] & tags] + assert choices, f"no host wheel for {package['name']}" + wheel = choices[0] + with urllib.request.urlopen(wheel["url"], timeout=60) as response: + data = response.read() + assert "sha256:" + hashlib.sha256(data).hexdigest() == wheel["hash"] + (wheelhouse / wheel["url"].rsplit("/", 1)[1]).write_bytes(data) + versions[package["name"]] = package["version"] + return wheelhouse, versions + + +@pytest.fixture +def isolated_builder(tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) + monkeypatch.chdir(tmp_path) + # Neither the caller's config nor a populated uv cache may supply this graph. + (tmp_path / "uv.toml").write_text('required-version = "<0.1"\n', encoding="utf-8") + from pm.packages import uv_cache_dir + assert not any(entry.name != ".seeded" for entry in uv_cache_dir().iterdir()) + uv = shutil.which("uv") + assert uv, "the wheelhouse staging test requires uv" + return Path(uv) + + +@pytest.mark.platforms("linux") +def test_offline_wheelhouse_runtime_survives_sealing_and_move( + tmp_path, isolated_builder, locked_wheelhouse, +): + wheelhouse, versions = locked_wheelhouse + root = tmp_path / "payload" + python = root / "tools/python/bin/python" + python.parent.mkdir(parents=True) + shutil.copy2(Path(sys._base_executable).resolve(), python) + executable = stage_runtime(isolated_builder, python, root / "pm-runtime", + wheelhouse=wheelhouse, offline=True) + assert executable.is_file() + seal_pm_runtime(root, python) + moved = tmp_path / "installed elsewhere" + root.rename(moved) + runtime = moved / "pm-runtime" + marker = json.loads((runtime / "pm-runtime.json").read_text(encoding="utf-8")) + probe = """ +import importlib.metadata, importlib.util, json, sys +sys.path.insert(0, sys.argv[1]) +from packaging.utils import canonicalize_name +from ruamel.yaml import YAML +import packaging, tomli_w, _ruamel_yaml +assert YAML(typ='safe').load('isolated: true') == {'isolated': True} +assert importlib.util.find_spec('openai') is None +assert importlib.util.find_spec('yaml') is None +print(json.dumps({canonicalize_name(d.metadata['Name']): d.version + for d in importlib.metadata.distributions(path=[sys.argv[1]])})) +""" + result = subprocess.run( + [str(runtime / marker["python"]), "-I", "-S", "-B", "-c", probe, + str(runtime / marker["sitePackages"])], + cwd=tmp_path, env=runtime_environment(), capture_output=True, text=True, timeout=30, + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == versions + + +@pytest.mark.platforms("linux") +def test_offline_wheelhouse_rejects_missing_transitive_wheel( + tmp_path, isolated_builder, locked_wheelhouse, capfd, +): + from pm.package import InstallError + + wheelhouse, _ = locked_wheelhouse + incomplete = tmp_path / "incomplete wheelhouse" + shutil.copytree(wheelhouse, incomplete) + native_wheel, = incomplete.glob("ruamel_yaml_clib-*.whl") + native_wheel.unlink() + destination = tmp_path / "pm-runtime" + with pytest.raises(InstallError, match="pip exited"): + stage_runtime(isolated_builder, Path(sys.executable), destination, + wheelhouse=incomplete, offline=True) + assert "ruamel-yaml-clib" in capfd.readouterr().err + assert not (destination / "pm-runtime.json").exists() diff --git a/tests/pm/test_update_artifact_refresh.py b/tests/pm/test_update_artifact_refresh.py index c1c6563803..96c1b13980 100644 --- a/tests/pm/test_update_artifact_refresh.py +++ b/tests/pm/test_update_artifact_refresh.py @@ -1,6 +1,7 @@ """Minor-style updates follow advertised artifacts without dropping other pins.""" from __future__ import annotations +from argparse import Namespace import hashlib import importlib import io @@ -65,11 +66,13 @@ def test_same_minor_refresh_updates_real_bytes_and_preserves_unresolved_targets( engine = importlib.import_module("pm.ensure") monkeypatch.setattr(engine, "sync_venv", lambda **kwargs: syncs.append(kwargs)) before = lock.path.read_bytes() - assert cli.main(["update", "rolling-tool", "--check"]) == 1 + args = Namespace(names=["rolling-tool"], target=None, check=True, uv=False, npm=False) + assert cli.cmd_update(args) == 1 assert lock.path.read_bytes() == before and not requests and not syncs assert not store.exists() - assert cli.main(["update", "rolling-tool"]) == 0 + args.check = False + assert cli.cmd_update(args) == 0 after = json.loads(lock.path.read_text(encoding="utf-8"))["packages"] original = json.loads(before)["packages"] assert after["unrelated"] == original["unrelated"] @@ -86,6 +89,7 @@ def test_same_minor_refresh_updates_real_bytes_and_preserves_unresolved_targets( pinned = lock.path.read_bytes() requests.clear() syncs.clear() - for flags in (["--check"], []): - assert cli.main(["update", "rolling-tool", *flags]) == 0 + for check in (True, False): + args.check = check + assert cli.cmd_update(args) == 0 assert lock.path.read_bytes() == pinned and not requests and not syncs diff --git a/tests/pm/test_update_dependency_legs.py b/tests/pm/test_update_dependency_legs.py index 4200cdb4f6..cd42cf355e 100644 --- a/tests/pm/test_update_dependency_legs.py +++ b/tests/pm/test_update_dependency_legs.py @@ -1,4 +1,5 @@ """Optional dependency refreshes run in PM's repository with its installed tools.""" +from argparse import Namespace import importlib import json import os @@ -75,11 +76,13 @@ def test_uv_refresh_uses_real_installed_tool_and_only_the_owned_project(tmp_path syncs = [] ensure = importlib.import_module('pm.ensure') monkeypatch.setattr(ensure, 'sync_venv', lambda **kw: syncs.append(kw)) - assert cli.main(['update', 'manual-fixture', '--uv', '--check']) == 0 + args = Namespace(names=['manual-fixture'], target=None, check=True, uv=True, npm=False) + assert cli.cmd_update(args) == 0 assert not (repo / 'uv.lock').exists() assert not syncs check_output = capsys.readouterr().out - assert cli.main(['update', 'manual-fixture', '--uv']) == 0 + args.check = False + assert cli.cmd_update(args) == 0 assert 'uv lock --upgrade' in check_output assert (repo / 'uv.lock').is_file() assert not (caller / 'uv.lock').exists() @@ -90,12 +93,12 @@ def test_uv_refresh_uses_real_installed_tool_and_only_the_owned_project(tmp_path original = (repo / 'uv.lock').read_bytes() syncs.clear() (repo / 'pyproject.toml').write_text('invalid project [', encoding='utf-8') - assert cli.main(['update', 'manual-fixture', '--uv']) == 1 + assert cli.cmd_update(args) == 1 assert (repo / 'uv.lock').read_bytes() == original and not syncs assert 'uv lock --upgrade failed' in capsys.readouterr().out managed.unlink() - assert cli.main(['update', 'manual-fixture', '--uv']) == 1 + assert cli.cmd_update(args) == 1 assert (repo / 'uv.lock').read_bytes() == original and not syncs assert 'not installed' in capsys.readouterr().out @@ -151,9 +154,11 @@ def test_npm_refresh_uses_its_installed_entry_and_owned_project(monkeypatch, cap monkeypatch.setenv('npm_config_cache', str(root / 'ambient-cache')) before = dict(os.environ) before_lock = lock.path.read_bytes() - assert cli.main(['update', 'manual-fixture', '--npm', '--check']) == 0 + args = Namespace(names=['manual-fixture'], target=None, check=True, uv=False, npm=True) + assert cli.cmd_update(args) == 0 assert not (repo / 'package-lock.json').exists() - assert cli.main(['update', 'manual-fixture', '--npm']) == 0, capsys.readouterr().out + args.check = False + assert cli.cmd_update(args) == 0, capsys.readouterr().out assert json.loads((repo / 'package-lock.json').read_text(encoding='utf-8'))['name'] == 'pm-leg-proof' assert not (caller / 'package-lock.json').exists() assert dict(os.environ) == before @@ -161,17 +166,17 @@ def test_npm_refresh_uses_its_installed_entry_and_owned_project(monkeypatch, cap assert not (root / 'ambient-cache').exists() lock_bytes = (repo / 'package-lock.json').read_bytes() (repo / 'package.json').write_text('not json', encoding='utf-8') - assert cli.main(['update', 'manual-fixture', '--npm']) == 1 + assert cli.cmd_update(args) == 1 assert (repo / 'package-lock.json').read_bytes() == lock_bytes assert 'npm update failed' in capsys.readouterr().out node_binary.rename(node_binary.with_suffix('.held')) - assert cli.main(['update', 'manual-fixture', '--npm']) == 1 + assert cli.cmd_update(args) == 1 assert (repo / 'package-lock.json').read_bytes() == lock_bytes assert 'not installed' in capsys.readouterr().out node_binary.with_suffix('.held').rename(node_binary) npm.binary(npm_entry, target).unlink() - assert cli.main(['update', 'manual-fixture', '--npm']) == 1 + assert cli.cmd_update(args) == 1 assert (repo / 'package-lock.json').read_bytes() == lock_bytes assert 'not installed' in capsys.readouterr().out assert dict(os.environ) == before and lock.path.read_bytes() == before_lock diff --git a/tests/pm/test_update_failure_status.py b/tests/pm/test_update_failure_status.py index ea60703f87..52fe1d954e 100644 --- a/tests/pm/test_update_failure_status.py +++ b/tests/pm/test_update_failure_status.py @@ -1,5 +1,6 @@ """A failed update lookup is not a current result or permission to apply.""" import importlib +from argparse import Namespace import pytest @@ -53,10 +54,8 @@ def test_failed_resolution_stops_every_apply_path(tmp_path, monkeypatch, capsys, packages = [failed, healthy] if mixed else [failed] lock = prepare(tmp_path, monkeypatch, packages) before = lock.path.read_bytes() - args = ["update", *[p.name for p in packages], "--uv", "--npm"] - if check: - args.append("--check") - assert cli.main(args) == 1 + args = Namespace(names=[p.name for p in packages], target=None, check=check, uv=True, npm=True) + assert cli.cmd_update(args) == 1 assert "fixture index unavailable" in capsys.readouterr().out assert lock.path.read_bytes() == before assert not (tmp_path / "tools").exists() @@ -69,7 +68,8 @@ def test_current_and_manual_results_are_successful_without_writes(tmp_path, monk package = UpdateFixture("no-update", versions) lock = prepare(tmp_path, monkeypatch, [package]) before = lock.path.read_bytes() - for flags in (["--check"], []): - assert cli.main(["update", package.name, *flags]) == 0 + for check in (True, False): + args = Namespace(names=[package.name], target=None, check=check, uv=False, npm=False) + assert cli.cmd_update(args) == 0 assert lock.path.read_bytes() == before assert not (tmp_path / "tools").exists() diff --git a/tests/pm/test_worker.py b/tests/pm/test_worker.py new file mode 100644 index 0000000000..2592cd4ba2 --- /dev/null +++ b/tests/pm/test_worker.py @@ -0,0 +1,449 @@ +"""Real isolated workers: no parent imports or callables cross the wire.""" +from __future__ import annotations + +import importlib +import os +from pathlib import Path +import subprocess + +import venv + +import pytest + +from pm import paths +from pm.package import InstallError +from pm.runtime import runtime_python +from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401 + + +@pytest.fixture(scope="module") +def isolated_python(tmp_path_factory): + root = tmp_path_factory.mktemp("pm-python") + venv.EnvBuilder(with_pip=False).create(root) + python = root / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + probe = subprocess.run( + [str(python), "-I", "-c", "import importlib.util; assert importlib.util.find_spec('yaml') is None"], + capture_output=True, text=True, timeout=30, + ) + assert probe.returncode == 0, probe.stderr + return python + + +@pytest.fixture +def client(tmp_path, monkeypatch, isolated_python): + client = importlib.import_module("pm.client") + monkeypatch.setattr("pm.runtime.runtime_python", lambda **kwargs: isolated_python) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "store")) + monkeypatch.setattr(paths, "lockfile_path", lambda: tmp_path / "lock.json") + return client + + +def test_isolated_worker_preserves_install_error(client, monkeypatch): + engine = importlib.import_module("pm.ensure") + monkeypatch.setattr(engine, "ensure", lambda *a, **kw: pytest.fail("engine ran in caller")) + with pytest.raises(InstallError) as caught: + client.ensure("node", explicit=True) + assert caught.value.package == "node" + assert caught.value.cause == "not in the lockfile" + assert caught.value.remedy == "add it with `hermes pm lock --bump`" + assert not paths.facts_path().exists() + + +def test_realized_uv_returns_worker_selected_binaries_and_caller_env(client, tmp_path): + from pm.lock import Facts, Lockfile + from pm.registry import get_package + from pm.store import current_target + + target = current_target() + lock = Lockfile(paths.lockfile_path()) + facts = Facts(paths.facts_path()) + binaries = {} + for name in ("python", "uv"): + package = get_package(name) + entry = paths.store_root() / package.store_entry("1", target) + binary = package.binary(entry, target) + assert binary is not None + binary.parent.mkdir(parents=True, exist_ok=True) + binary.write_bytes(b"installed binary fixture") + if name == "uv": + binary = binary.with_name("uvx" + binary.suffix) + binary.write_bytes(b"installed uvx fixture") + binaries[name] = str(binary) + digest = "a" * 64 + lock.set_pin(name, "1", {target: {"url": "https://unused.invalid/archive", "sha256": digest}}) + facts.record(name, "1", entry.name, {}, paths.store_root(), target=target, artifacts=[digest]) + lock.save() + binary, env = client.uv("uvx", venv=tmp_path / "project-env", base_env={"KEEP": "caller", "PYTHONPATH": "bad"}) + assert binary == binaries["uv"] + assert env["UV_PYTHON"] == binaries["python"] and env["KEEP"] == "caller" + assert env["VIRTUAL_ENV"] == str(tmp_path / "project-env") + assert "PYTHONPATH" not in env + + +def test_refused_or_already_paused_install_does_not_acquire_runtime(client, monkeypatch): + import threading + from pm.downloader import DownloadPaused + + monkeypatch.setattr(client, "runtime_command", lambda path: pytest.fail("refusal acquired PM runtime")) + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + with pytest.raises(InstallError, match="lazy installs are disabled"): + client.ensure("node") + with pytest.raises(InstallError, match="lazy installs are disabled"): + client.uv() + paused = threading.Event() + paused.set() + with pytest.raises(DownloadPaused): + client.ensure("node", explicit=True, pause_event=paused) + + +def _current_environment(tmp_path, monkeypatch, members): + from hermes_cli.runtime_paths import install_state_dir + from pm.lock import Facts + from pm.packages import Venv + + repo = tmp_path / "project" + repo.mkdir() + (repo / "uv.lock").write_text("version = 1\n") + monkeypatch.setattr(paths, "repo_root", lambda: repo) + environment = install_state_dir(repo) / "environments" / "existing" / "venv" + environment.mkdir(parents=True) + (environment / "pyvenv.cfg").write_text("home = test\n") + Facts(paths.runtime_facts_path()).record_state( + "venv", Venv().expected_stamp([], plugin_dirs=members), [], environment=environment, + ) + return repo + + +def _assert_worker_holds_lock(repo): + from hermes_cli.runtime_paths import install_state_dir + from hermes_cli.runtime_state import _lock + + with (install_state_dir(repo) / ".install.lock").open("a+b") as lock: + assert not _lock(lock.fileno(), wait=False), "callback escaped the worker's runtime lock" + + +@pytest.mark.parametrize("explicit", [True, False]) +def test_sync_callbacks_preserve_member_mapping_and_lock(client, tmp_path, monkeypatch, explicit): + identity, staged = tmp_path / "installed", tmp_path / "staged" + staged.mkdir() + (staged / "plugin.yaml").write_text("name: test\n") + members = {identity: staged} + repo = _current_environment(tmp_path, monkeypatch, members) + events = [] + + def select(): + _assert_worker_holds_lock(repo) + events.append("members") + return members + + class Publication: + def __call__(self): + pytest.fail("successful no-op publication was undone") + + def finish(self): + _assert_worker_holds_lock(repo) + events.append("finish") + + def before_publish(): + _assert_worker_holds_lock(repo) + events.append("publish") + return Publication() + + client.sync_venv([], explicit=explicit, plugin_dirs=select, before_publish=before_publish) + assert events == ["members", "publish", "finish"] + + +@pytest.mark.parametrize("current", [True, False]) +@pytest.mark.parametrize("tools_present", [False, True], ids=["cold-tools", "ready-tools"]) +def test_lazy_disabled_sync_does_not_bootstrap_tools(client, tmp_path, monkeypatch, isolated_python, current, tools_present): + import json + from pm import receipt + + repo = _current_environment(tmp_path, monkeypatch, []) + if not current: + (repo / "uv.lock").write_text("version = 2\n") + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + # Exercise runtime acquisition too: the other worker tests supply a ready + # interpreter, which hides an explicit tool install before worker refusal. + monkeypatch.setattr("pm.runtime.runtime_python", runtime_python) + engine = importlib.import_module("pm.ensure") + original_uv = engine.uv + + def uv(*args, **kwargs): + assert kwargs.get("realize") is False, "lazy-disabled sync bootstrapped tools" + if tools_present: + return str(tmp_path / "uv"), {"UV_PYTHON": str(isolated_python)} + return original_uv(*args, **kwargs) + + monkeypatch.setattr(engine, "uv", uv) + monkeypatch.setattr("pm.runtime_stage.stage_runtime", + lambda *a, **kw: pytest.fail("lazy-disabled sync prepared PM runtime")) + selections = [] + + def select(): + _assert_worker_holds_lock(repo) + selections.append("selected") + return [] + + with receipt.worker_context("lazy-disabled-sync"): + with pytest.raises(InstallError, match="lazy installs are disabled") as caught: + client.sync_venv([], plugin_dirs=select) + result = receipt.last_for_update("lazy-disabled-sync", consume=True) + assert caught.value.package == "pm-runtime" + assert selections == [] + assert result is not None + assert result["outcome"] == "failed" + receipts = list((tmp_path / "home" / "logs" / "update_receipts").glob("pm_*.json")) + assert len(receipts) == 1 + assert json.loads(receipts[0].read_text()) == result + assert not paths.facts_path().exists() + + +def test_callback_exception_waits_for_failed_receipt_and_lock_release(client, tmp_path, monkeypatch): + import json + from hermes_cli.runtime_paths import install_state_dir + from hermes_cli.runtime_state import _lock + + repo = _current_environment(tmp_path, monkeypatch, []) + error = LookupError("selection disappeared") + + def fail(): + _assert_worker_holds_lock(repo) + raise error + + with pytest.raises(LookupError) as caught: + client.sync_venv([], explicit=True, plugin_dirs=fail) + assert caught.value is error + receipts = list((tmp_path / "home" / "logs" / "update_receipts").glob("pm_*.json")) + assert len(receipts) == 1 + assert json.loads(receipts[0].read_text())["outcome"] == "failed" + with (install_state_dir(repo) / ".install.lock").open("a+b") as lock: + assert _lock(lock.fileno(), wait=False) + + +def _node_archive(server, body=b"#!/bin/sh\nexit 0\n"): + import hashlib + import io + import zipfile + from pm.lock import Lockfile + from pm.registry import get_package + from pm.store import current_target + + target = current_target() + package = get_package("node") + relative = package.binary(Path("."), target) + assert relative is not None + stream = io.BytesIO() + with zipfile.ZipFile(stream, "w") as archive: + info = zipfile.ZipInfo((Path("node-package") / relative).as_posix()) + info.external_attr = 0o100755 << 16 + archive.writestr(info, body) + payload = stream.getvalue() + RangeHandler.payloads["/node.zip"] = payload + lock = Lockfile(paths.lockfile_path()) + lock.set_pin("node", "1", {target: {"url": url(server, "/node.zip"), + "sha256": hashlib.sha256(payload).hexdigest()}}) + lock.save() + return target, relative, body + + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("operation", ["ensure", "stage_only"]) +def test_worker_installs_real_archive_and_relays_progress(client, dl_server, operation): + from pm.lock import Facts + + target, relative, body = _node_archive(dl_server) + stages, downloads = [], [] + + def progress(*args): + stages.append(args) + return object() # Notifications never serialize caller-owned return values. + + if operation == "stage_only": + entry = client.stage_only("node", target, progress=progress) + assert isinstance(entry, Path) + assert not paths.facts_path().exists() + else: + base = {"PATH": "/caller/bin", "CALLER": "kept", "PYTHONPATH": "/caller/dependencies"} + runner = client.ensure("node", explicit=True, base_env=base, + progress=lambda *args: stages.append(args), + download_progress=lambda *args: downloads.append(args)) + fact = Facts(paths.facts_path()).get("node") + entry = paths.store_root() / fact["entry"] + assert runner.env["CALLER"] == "kept" + assert runner.env["PYTHONPATH"] == base["PYTHONPATH"] + assert runner.env["PATH"].endswith(base["PATH"]) + assert downloads and downloads[-1][0] == downloads[-1][1] + assert all(isinstance(row, tuple) for rows in downloads[-1][2].values() for row in rows) + assert (entry / relative).read_bytes() == body + assert stages and stages[0][0] == "download" + + +@pytest.mark.platforms("posix") +@pytest.mark.parametrize("from_progress", [False, True]) +def test_pause_event_reaches_running_worker_without_hanging(client, dl_server, monkeypatch, from_progress): + import threading + from pm.downloader import DownloadPaused + from pm.lock import Facts + + _node_archive(dl_server, b"#!/bin/sh\nexit 0\n#" + b"x" * (8 << 20)) + RangeHandler.slow_per_chunk = 0.03 + pause, transferring = threading.Event(), threading.Event() + original = RangeHandler.do_GET + + def get(handler): + if handler.headers.get("Range") not in (None, "bytes=0-0"): + transferring.set() + original(handler) + + monkeypatch.setattr(RangeHandler, "do_GET", get) + + def cancel(): + assert transferring.wait(10), "worker never began transferring" + pause.set() + + thread = None + progress = None + if from_progress: + def progress(stage, done, total, label): + if stage == "download" and done: + pause.set() + else: + thread = threading.Thread(target=cancel) + thread.start() + try: + with pytest.raises(DownloadPaused): + client.ensure("node", explicit=True, progress=progress, pause_event=pause) + finally: + if thread is not None: + thread.join(timeout=15) + assert not thread.is_alive() + assert Facts(paths.facts_path()).get("node") is None + + +@pytest.mark.platforms("posix") +def test_installed_tool_needs_no_pm_runtime(client, dl_server, monkeypatch): + _node_archive(dl_server) + client.ensure("node", explicit=True) + monkeypatch.setattr("pm.runtime.runtime_python", lambda: pytest.fail("hot path bootstrapped PM")) + assert client.ensure("node", base_env={"PATH": "caller"}).env["PATH"].endswith("caller") + + +def test_uv_probe_needs_no_pm_runtime(client, monkeypatch): + monkeypatch.setattr("pm.runtime.runtime_python", lambda: pytest.fail("probe bootstrapped PM")) + binary, env = client.uv(realize=False, venv=Path("project-venv"), base_env={"KEPT": "yes"}) + assert binary is None + assert env["KEPT"] == "yes" and env["VIRTUAL_ENV"] == "project-venv" + + +def test_worker_receipt_is_exact_even_if_latest_is_replaced(client, tmp_path, monkeypatch): + import json + from pm import receipt + + _current_environment(tmp_path, monkeypatch, []) + original_accept = receipt.accept_worker_receipt + received = [] + + def accept(data, update_id): + # Simulate a different process publishing after this worker completes. + point = tmp_path / "home" / "logs" / "update_receipts" / "latest.json" + point.write_text(json.dumps({"update_id": "unrelated", "outcome": "failed"})) + received.append(data) + original_accept(data, update_id) + + monkeypatch.setattr(receipt, "accept_worker_receipt", accept) + with receipt.worker_context("my-update"): + client.sync_venv([], explicit=True, plugin_dirs=[]) + result = receipt.last_for_update("my-update", consume=True) + assert received and result == received[0] + assert result["update_id"] == "my-update" and result["outcome"] == "ok" + + +def _patch_worker_apply(client, monkeypatch, isolated_python, body): + """Fault injection at the build boundary, not an alternate transport/engine.""" + import textwrap + + worker = Path(client.__file__).with_name("worker.py") + script = ( + "import os, runpy, sys\n" + f"sys.path.insert(0, {str(worker.parent.parent)!r})\n" + "from pm.packages import Venv\n" + "from pm.workspace import ResolutionConflict\n" + "def apply(self, *args, **kwargs):\n" + + textwrap.indent(body, " ") + "\n" + "Venv.apply = apply\n" + f"runpy.run_path({str(worker)!r}, run_name='__main__')\n" + ) + monkeypatch.setattr(client, "runtime_command", lambda path: [str(isolated_python), "-I", "-B", "-c", script]) + + +def test_resolution_conflict_survives_worker_and_receipt(client, tmp_path, monkeypatch, isolated_python, capfd): + from pm import receipt + from pm.workspace import ResolutionConflict + + repo = _current_environment(tmp_path, monkeypatch, []) + (repo / "uv.lock").write_text("version = 2\n") + _patch_worker_apply(client, monkeypatch, isolated_python, + "print('engine stdout', flush=True)\n" + "os.write(1, b'native stdout\\n')\n" + "raise ResolutionConflict('venv', 'impossible union', 'change member')") + with receipt.worker_context("conflict-update"): + with pytest.raises(ResolutionConflict) as caught: + client.sync_venv([], explicit=True, plugin_dirs=[]) + result = receipt.last_for_update("conflict-update", consume=True) + assert (caught.value.package, caught.value.cause, caught.value.remedy) == ( + "venv", "impossible union", "change member") + assert result["outcome"] == "failed" + assert "engine stdout" in capfd.readouterr().err + + +def test_failed_finish_runs_undo_before_propagating_callback_exception(client, tmp_path, monkeypatch, isolated_python): + repo = _current_environment(tmp_path, monkeypatch, []) + (repo / "uv.lock").write_text("version = 2\n") + _patch_worker_apply(client, monkeypatch, isolated_python, "return {}") + events = [] + error = LookupError("finish failed") + + class Publication: + def __call__(self): + _assert_worker_holds_lock(repo) + events.append("undo") + return object() # Return values of effect-only callbacks are ignored. + + def finish(self): + _assert_worker_holds_lock(repo) + events.append("finish") + raise error + + def publish(): + _assert_worker_holds_lock(repo) + events.append("publish") + return Publication() + + with pytest.raises(LookupError) as caught: + client.sync_venv([], explicit=True, plugin_dirs=[], before_publish=publish) + assert caught.value is error + assert events == ["publish", "finish", "undo"] + + +def test_invalid_arguments_keep_the_engine_exception_type(client): + with pytest.raises(ValueError, match="repair restores"): + client.sync_venv([], repair=True) + with pytest.raises(ValueError, match="unknown uv executable"): + client.uv("not-uv") + with pytest.raises(KeyError): + client.ensure("no-such-package", explicit=True) + + +def test_worker_death_reports_transport_failure(client, monkeypatch, isolated_python): + monkeypatch.setattr(client, "runtime_command", lambda path: [str(isolated_python), "-I", "-c", "import os; os._exit(7)"]) + with pytest.raises(InstallError, match="worker.*result"): + client.ensure("node", explicit=True) + + +def test_unknown_worker_operation_is_not_dispatched(client): + with pytest.raises((KeyError, RuntimeError), match="activate"): + client._request("activate", {}) + assert not paths.facts_path().exists() diff --git a/tests/pm/test_worker_receipts.py b/tests/pm/test_worker_receipts.py new file mode 100644 index 0000000000..396caa9115 --- /dev/null +++ b/tests/pm/test_worker_receipts.py @@ -0,0 +1,26 @@ +"""Worker receipts cross the process seam by identity, never latest.json.""" +import contextvars + +import pytest + +from pm import receipt + + +def test_worker_receipt_is_attributed_only_to_its_invoking_update(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + def worker(): + with receipt.worker_context("update-a"): + token = receipt.begin("sync") + receipt.record_step("dependency-sync", False, "candidate refused") + receipt.finalize("failed", 1, token=token) + return receipt.last_completed() + + data = contextvars.Context().run(worker) + assert data["update_id"] == "update-a" + assert data["outcome"] == "failed" + receipt.accept_worker_receipt(data, "update-a") + assert receipt.last_for_update("update-a", consume=True) == data + assert receipt.last_for_update("update-b") is None + with pytest.raises(ValueError, match="correlation"): + receipt.accept_worker_receipt(data, "update-b") diff --git a/tests/pm/test_worker_registry.py b/tests/pm/test_worker_registry.py new file mode 100644 index 0000000000..b9c43ddf11 --- /dev/null +++ b/tests/pm/test_worker_registry.py @@ -0,0 +1,218 @@ +"""Registered package declarations survive a fresh, isolated interpreter.""" +from __future__ import annotations + +import hashlib +import importlib +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tarfile +import textwrap +import venv + +import pytest + +import pm +from pm import registry +from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401 + + +@pytest.fixture(autouse=True) +def restore_registry(monkeypatch): + monkeypatch.setattr(registry, "_packages", dict(registry._packages)) + + +@pytest.fixture(scope="module") +def worker_python(tmp_path_factory): + environment = tmp_path_factory.mktemp("registry-worker-python") + venv.EnvBuilder(with_pip=False).create(environment) + return environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + + +@pytest.mark.parametrize("operation", ["ensure", "stage_only"]) +def test_registered_package_installs_archive_in_real_worker(tmp_path, monkeypatch, worker_python, dl_server, operation): + from pm import paths + + monkeypatch.setattr("pm.runtime.runtime_python", lambda: worker_python) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "store")) + monkeypatch.setattr(paths, "lockfile_path", lambda: tmp_path / "lock.json") + source = tmp_path / "package.py" + source.write_text(textwrap.dedent("""\ + import os + from pm import Package, register + + @register + class ArchivePackage(Package): + name = "registry-worker-archive" + + def stage(self, store, staged, version, target): + (staged / "worker-pid").write_text(str(os.getpid())) + + def verify(self, entry, target): + return '' if (entry / 'payload.txt').read_text() == 'plugin archive' else 'bad payload' + """)) + _load_file(source, monkeypatch) + payload = b"plugin archive" + stream = io.BytesIO() + with tarfile.open(fileobj=stream, mode="w:gz") as archive: + member = tarfile.TarInfo("payload.txt") + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + data = stream.getvalue() + RangeHandler.payloads["/plugin.tar.gz"] = data + target = pm.current_target() + lock = pm.Lockfile(paths.lockfile_path()) + lock.set_pin("registry-worker-archive", "1", {target: { + "url": url(dl_server, "/plugin.tar.gz"), "sha256": hashlib.sha256(data).hexdigest(), + }}) + lock.save() + engine = importlib.import_module("pm.ensure") + monkeypatch.setattr(engine, operation, lambda *a, **kw: pytest.fail("install ran in caller")) + if operation == "ensure": + pm.ensure("registry-worker-archive", explicit=True) + entry = paths.store_root() / pm.Facts(paths.facts_path()).get("registry-worker-archive")["entry"] + else: + from pm.client import stage_only + entry = stage_only("registry-worker-archive", target) + assert (entry / "payload.txt").read_bytes() == payload + assert int((entry / "worker-pid").read_text()) != os.getpid() + + +def _load_file(path, monkeypatch, name="worker_registry_package"): + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, name, module) + spec.loader.exec_module(module) + return module + + +def _child(definitions, code, *, setup=""): + root = Path(pm.__file__).resolve().parent.parent + script = ( + "import json, sys\n" + f"sys.path.insert(0, {str(root)!r})\n" + "from pm.registry import load_package_definitions, get_package\n" + "from pm.package import InstallError\n" + + setup + "\n" + "load_package_definitions(json.loads(sys.stdin.read()))\n" + + textwrap.dedent(code) + ) + return subprocess.run( + [sys.executable, "-I", "-B", "-c", script], + input=json.dumps(definitions), capture_output=True, text=True, timeout=30, + ) + + +@pytest.mark.parametrize("failure", ["dependency", "collision", "name", "not-class"]) +def test_definition_import_failures_are_package_errors(tmp_path, monkeypatch, failure): + source = tmp_path / "package.py" + source.write_text("from pm import Package\nclass ExternalPackage(Package):\n name = 'broken-worker-test'\n") + module = _load_file(source, monkeypatch) + pm.register(module.ExternalPackage) + definitions = registry.package_definitions([module.ExternalPackage.name]) + setup = "" + if failure == "dependency": + source.write_text( + "from pathlib import Path\n" + f"with Path({str(tmp_path / 'imports')!r}).open('a') as log: log.write('imported\\n')\n" + "import no_such_plugin_dependency\n" + ) + setup = f"sys.path.insert(0, {str(tmp_path)!r})" + # Make the module importable, so an internal ModuleNotFoundError must + # not trigger a second attempt through its source path. + definitions[0]["module"] = "package" + elif failure == "collision": + definitions[0]["module"] = "pm.package" + elif failure == "name": + source.write_text("from pm import Package\nclass ExternalPackage(Package):\n name = 'changed-name'\n") + else: + source.write_text("ExternalPackage = object()\n") + result = _child(definitions, "", setup=setup) + assert result.returncode != 0 + assert "InstallError: broken-worker-test:" in result.stderr + assert "package definition" in result.stderr + if failure == "dependency": + assert (tmp_path / "imports").read_text().splitlines() == ["imported"] + assert "no_such_plugin_dependency" in result.stderr + if failure == "collision": + assert "different source" in result.stderr + + +@pytest.mark.parametrize("kind", ["local", "unbound", "main"]) +def test_non_importable_registration_fails_with_package_remedy(kind): + class LocalPackage(pm.Package): + name = "local-worker-test" + + if kind == "unbound": + LocalPackage.__qualname__ = "NotAModuleAttribute" + elif kind == "main": + LocalPackage.__module__ = "__main__" + pm.register(LocalPackage) + with pytest.raises(pm.InstallError) as caught: + registry.package_definitions([LocalPackage.name]) + assert caught.value.package == LocalPackage.name + assert "definition" in caught.value.cause + assert "module-level" in caught.value.remedy + # An unrelated non-importable definition cannot break a built-in install. + assert registry.package_definitions(["node"]) == [] + + +def test_file_registered_package_runs_its_own_definition_in_child(tmp_path, monkeypatch): + source = tmp_path / "package.py" + source.write_text(textwrap.dedent("""\ + from pm import Package, InstallError, register + + @register + class ExternalPackage(Package): + name = "external-worker-test" + + def fetch_url(self, version, target): + raise InstallError(self.name, "external definition reached", "plugin remedy") + """)) + module = _load_file(source, monkeypatch) + # Public registration also works without a decorator at import time. + pm.register(module.ExternalPackage) + definitions = registry.package_definitions() + assert [item["name"] for item in definitions] == [module.ExternalPackage.name] + result = _child(definitions, """\ + package = get_package('external-worker-test') + try: + package.fetch_url('1', 'linux-x64') + except InstallError as exc: + print(json.dumps([exc.package, exc.cause, exc.remedy])) + else: + raise AssertionError('custom package was not loaded') + """) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [ + "external-worker-test", "external definition reached", "plugin remedy", + ] + + +def test_namespaced_definition_keeps_relative_sibling_imports(tmp_path, monkeypatch): + import types + + root = types.ModuleType("_pm_test_plugins") + root.__path__ = [str(tmp_path)] + monkeypatch.setitem(sys.modules, root.__name__, root) + plugin = tmp_path / "example" + plugin.mkdir() + (plugin / "__init__.py").write_text("PREFIX = 'namespaced-'\n") + (plugin / "sibling.py").write_text("NAME = 'namespaced-worker-test'\n") + package = types.ModuleType("_pm_test_plugins.example") + package.__path__ = [str(plugin)] + package.__file__ = str(plugin / "__init__.py") + package.PREFIX = "namespaced-" + monkeypatch.setitem(sys.modules, package.__name__, package) + source = plugin / "packages.py" + source.write_text("from .sibling import NAME\nfrom . import PREFIX\nassert NAME.startswith(PREFIX)\nfrom pm import Package, register\n" + "@register\nclass ExternalPackage(Package):\n name = NAME\n") + module = _load_file(source, monkeypatch, "_pm_test_plugins.example.packages") + definitions = registry.package_definitions([module.ExternalPackage.name]) + result = _child(definitions, "assert get_package('namespaced-worker-test').name == 'namespaced-worker-test'") + assert result.returncode == 0, result.stderr diff --git a/tests/scripts/test_bundle_native.py b/tests/scripts/test_bundle_native.py index 0ec8cb113c..f72c570ab8 100644 --- a/tests/scripts/test_bundle_native.py +++ b/tests/scripts/test_bundle_native.py @@ -19,12 +19,21 @@ from scripts.bundles import native def test_bundle_stages_git_tree_and_runs_native_children_before_manifest(tmp_path, monkeypatch): from hermes_cli.runtime_paths import site_packages - interpreter = tmp_path / "staged-python" - subprocess.run([sys.executable, "-m", "venv", "--without-pip", str(interpreter)], - capture_output=True, check=True, timeout=60) - target_python = interpreter / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + output = tmp_path / "payload" + target_python = output / "staged-python" / ("python.exe" if os.name == "nt" else "bin/python") + target_python.parent.mkdir(parents=True) + # PM seals a payload-owned base interpreter, not an external venv launcher. + # The POSIX host supplies its stdlib; Windows needs it beside the executable. + if os.name == "nt": + shutil.copytree(Path(sys.base_prefix), target_python.parent, dirs_exist_ok=True) + else: + shutil.copy2(Path(getattr(sys, "_base_executable")).resolve(), target_python) repo = tmp_path / "repo" repo.mkdir() + pm_project = Path(__file__).resolve().parents[2] / "pm" + (repo / "pm").mkdir() + for name in ("pyproject.toml", "uv.lock"): + shutil.copy2(pm_project / name, repo / "pm" / name) (repo / "pyproject.toml").write_text('[project]\nname="fixture"\nversion="1.0.0"\nrequires-python=">=3.11"\n[project.optional-dependencies]\npayloadtest=[]\n[tool.uv]\npackage=false\n', encoding="utf-8") uv = shutil.which("uv") assert uv, "native bundle test requires uv" @@ -33,7 +42,6 @@ def test_bundle_stages_git_tree_and_runs_native_children_before_manifest(tmp_pat subprocess.run(["git", "init", str(repo)], check=True, capture_output=True) subprocess.run(["git", "add", "."], cwd=repo, check=True) subprocess.run(["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "fixture"], cwd=repo, check=True, capture_output=True) - output = tmp_path / "payload" monkeypatch.setattr("pm.paths.repo_root", lambda: repo) monkeypatch.setattr(native, "_bundle_package_names", lambda: []) monkeypatch.setattr(native, "_install_names", lambda names: 0) @@ -44,7 +52,7 @@ def test_bundle_stages_git_tree_and_runs_native_children_before_manifest(tmp_pat monkeypatch.setattr(native, "_arch_guard", lambda store: []) monkeypatch.setattr("scripts.bundles.payload.relativize_links", lambda root: 0) monkeypatch.setattr("pm.extras.ANCHORS", {"payloadtest": "bundle_probe.present"}) - monkeypatch.setattr("pm.packages.uv_cache_dir", lambda: tmp_path / "empty-cache") + monkeypatch.setattr("pm.packages.uv_cache_dir", lambda: tmp_path / "cache") real_run = native._run_live calls = [] witness = tmp_path / "inventory-python.json" @@ -53,6 +61,9 @@ def test_bundle_stages_git_tree_and_runs_native_children_before_manifest(tmp_pat def child(argv, *, cwd, env): calls.append(argv[1]) assert not (output / "manifest.json").exists() + marker = json.loads((output / "pm-runtime/pm-runtime.json").read_text()) + assert (output / "pm-runtime" / marker["python"]).resolve() == target_python + assert (output / "pm-runtime" / marker["sitePackages"]).is_dir() result = real_run(argv, cwd=cwd, env=env) if argv[1] == "sync" and result[0] == 0: site = site_packages(output / "venv") diff --git a/tests/scripts/test_pm_runtime_bundle.py b/tests/scripts/test_pm_runtime_bundle.py new file mode 100644 index 0000000000..4e6dbe1e69 --- /dev/null +++ b/tests/scripts/test_pm_runtime_bundle.py @@ -0,0 +1,144 @@ +"""A payload carries PM's independent dependency graph, not the app's imports.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + + +def _exercise_relocated_pm_runtime(tmp_path, monkeypatch): + from scripts.bundles import native + + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) + uv = shutil.which("uv") + assert uv, "the packaging test requires uv" + root = tmp_path / "build" + repo = root / "hermes-agent" + source = Path(__file__).resolve().parents[2] + shutil.copytree(source / "pm", repo / "pm", ignore=shutil.ignore_patterns("__pycache__")) + (repo / "hermes_cli").mkdir() + for name in ("__init__.py", "runtime_paths.py", "runtime_state.py"): + shutil.copy2(source / "hermes_cli" / name, repo / "hermes_cli" / name) + shutil.copy2(source / "hermes_constants.py", repo / "hermes_constants.py") + # Copy the base executable, not a venv's launcher. The test host provides + # its stdlib; production's package stage provides the complete distribution. + python = root / "tools" / "python" / ("python.exe" if os.name == "nt" else "bin/python") + python.parent.mkdir(parents=True) + if os.name == "nt": + shutil.copytree(Path(sys.base_prefix), python.parent, dirs_exist_ok=True) + else: + shutil.copy2(Path(sys._base_executable).resolve(), python) + stage = getattr(native, "stage_pm_runtime", None) + assert callable(stage), "native payload has no isolated PM runtime stage" + stage(root, Path(uv), python, repo) + stage(root, Path(uv), python, repo, offline=True) + (root / "manifest.json").write_text(json.dumps({"repo": "hermes-agent"})) + assert not (repo / ".venv").exists() + moved = tmp_path / "installed elsewhere" + root.rename(moved) + runtime = moved / "pm-runtime" + manifest = json.loads((runtime / "pm-runtime.json").read_text()) + base = runtime / manifest["python"] + site = runtime / manifest["sitePackages"] + assert base.is_file() and site.is_dir() + assert not Path(manifest["python"]).is_absolute() + probe = """ +import importlib.util, json, sys +sys.path.insert(0, sys.argv[1]) +from ruamel.yaml import YAML +import packaging, tomli_w +assert importlib.util.find_spec('openai') is None +assert importlib.util.find_spec('yaml') is None +print(json.dumps(YAML(typ='safe').load('isolated: true'))) +""" + checked = subprocess.run([str(base), "-I", "-S", "-B", "-c", probe, str(site)], + cwd=tmp_path, capture_output=True, text=True, timeout=30) + assert checked.returncode == 0, checked.stderr + assert json.loads(checked.stdout) == {"isolated": True} + from pm import runtime as runtime_api, paths + monkeypatch.setattr(paths, "repo_root", lambda: moved / "hermes-agent") + command = runtime_api.runtime_command(moved / "hermes-agent/pm/launch.py", ["status"]) + checked = subprocess.run(command, cwd=tmp_path, env=runtime_api.runtime_environment(), + capture_output=True, text=True, timeout=30) + assert checked.returncode == 0, checked.stderr + assert "no pm sync receipt" in checked.stdout + assert str(root) not in (runtime / "pyvenv.cfg").read_text() + for link in (runtime / "bin").glob("python*"): + if link.is_symlink(): + assert not os.path.isabs(os.readlink(link)) + assert link.exists() + + +@pytest.mark.platforms("posix") +def test_native_pm_runtime_survives_payload_move(tmp_path, monkeypatch): + _exercise_relocated_pm_runtime(tmp_path, monkeypatch) + + +@pytest.mark.platforms("windows") +def test_resident_pm_bypasses_windows_redirector_after_move(tmp_path, monkeypatch): + _exercise_relocated_pm_runtime(tmp_path, monkeypatch) + + +@pytest.mark.parametrize("poison", ["cwd", "global"]) +def test_pm_builder_ignores_ambient_uv_configuration(tmp_path, monkeypatch, poison): + from pm.runtime_stage import stage_runtime + + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setenv("APPDATA", str(tmp_path / "config")) + monkeypatch.chdir(tmp_path) + config = tmp_path / "uv.toml" if poison == "cwd" else tmp_path / "config/uv/uv.toml" + config.parent.mkdir(parents=True, exist_ok=True) + config.write_text('required-version = "<0.1"\n', encoding="utf-8") + uv = shutil.which("uv") + assert uv + executable = stage_runtime(Path(uv), Path(sys.executable), tmp_path / "runtime") + assert executable.is_file() + + +def test_native_stage_builds_pm_before_application_environment(tmp_path, monkeypatch): + from scripts.bundles import native, payload + from types import SimpleNamespace + + class StopAfterPM(Exception): + pass + + class Facts: + def __init__(self, *args, **kwargs): + pass + + def retain(self, names): + pass + + def entries_in_use(self): + return set() + + def get(self, name): + return {"entry": "python"} + + calls = [] + monkeypatch.setattr(payload, "snapshot", lambda *args: None) + monkeypatch.setattr(native, "_bundle_package_names", lambda: []) + monkeypatch.setattr(native, "_install_names", lambda names: 0) + monkeypatch.setattr(native, "Facts", Facts) + monkeypatch.setattr(native, "_facts", Facts) + monkeypatch.setattr(native, "pm_uv", lambda: ("uv", {})) + monkeypatch.setattr(native, "_store", lambda: SimpleNamespace(entry=lambda name: tmp_path / "tools" / name)) + monkeypatch.setattr(native, "get_package", lambda name: SimpleNamespace(binary=lambda path, target: path / "python")) + + def staged(root, uv, python, repo): + calls.append((root, uv, python, repo)) + raise StopAfterPM + + monkeypatch.setattr(native, "stage_pm_runtime", staged) + monkeypatch.setattr(native, "_run_live", lambda *args, **kwargs: pytest.fail("app sync ran before PM stage")) + with pytest.raises(StopAfterPM): + native.stage_native(SimpleNamespace(out=str(tmp_path), ref="HEAD")) + assert calls == [(tmp_path, Path("uv"), tmp_path / "tools/python/python", tmp_path / "hermes-agent")] diff --git a/tests/scripts/test_release_darwin.py b/tests/scripts/test_release_darwin.py index 4f818e6394..195bd03197 100644 --- a/tests/scripts/test_release_darwin.py +++ b/tests/scripts/test_release_darwin.py @@ -10,7 +10,7 @@ import os import tempfile import pytest -import yaml +import hermes_yaml as yaml from scripts.releases import darwin, r2 from tests.scripts.test_release_r2 import r2_server # noqa: F401 — loopback fixture diff --git a/tests/scripts/test_setup_toolchain.py b/tests/scripts/test_setup_toolchain.py index 8ade61c765..e7c30f5a30 100644 --- a/tests/scripts/test_setup_toolchain.py +++ b/tests/scripts/test_setup_toolchain.py @@ -46,6 +46,9 @@ def test_development_setup_keeps_test_groups_out_of_the_runtime(tmp_path, monkey import importlib engine = importlib.import_module("pm.ensure") monkeypatch.setattr(engine, "uv", lambda **kwargs: (uv, dict(environment))) + # This test exercises the worker-side CI environment split with offline uv. + # Dispatch into that worker is covered by test_runtime_entrypoints. + monkeypatch.setattr("pm.runtime.is_runtime", lambda: True) files = {name: tmp_path / name for name in ("GITHUB_ENV", "GITHUB_OUTPUT", "GITHUB_PATH")} for name, file in files.items(): monkeypatch.setenv(name, str(file)) diff --git a/tests/scripts/test_validate_plugin_catalog.py b/tests/scripts/test_validate_plugin_catalog.py index 7b369c96ec..3305f5d59a 100644 --- a/tests/scripts/test_validate_plugin_catalog.py +++ b/tests/scripts/test_validate_plugin_catalog.py @@ -1,7 +1,7 @@ """Behavior tests for scripts/validate_plugin_catalog.py. The script is the no-install structural validator used by the plugin-catalog -admission CI: it must run with only stdlib + pyyaml, take file paths or a +admission CI: it must run with only stdlib + ruamel.yaml, take file paths or a directory, exit 0/1, and support --json machine output. These tests exercise the CLI contract via subprocess (the same way CI invokes it). """ @@ -11,7 +11,7 @@ import subprocess import sys from pathlib import Path -import yaml +import hermes_yaml as yaml REPO_ROOT = Path(__file__).resolve().parents[2] SCRIPT = REPO_ROOT / "scripts" / "validate_plugin_catalog.py" diff --git a/tests/skills/test_actual_setup_skill.py b/tests/skills/test_actual_setup_skill.py index f2a110e418..36c890e027 100644 --- a/tests/skills/test_actual_setup_skill.py +++ b/tests/skills/test_actual_setup_skill.py @@ -14,7 +14,7 @@ import re from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml SKILL_DIR = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_authoring_standards.py b/tests/skills/test_authoring_standards.py index d92652d7c8..16dbf18b06 100644 --- a/tests/skills/test_authoring_standards.py +++ b/tests/skills/test_authoring_standards.py @@ -13,7 +13,7 @@ import re from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml REPO = Path(__file__).resolve().parents[2] MARKETING = re.compile( diff --git a/tests/skills/test_collective_wisdom_install_skill.py b/tests/skills/test_collective_wisdom_install_skill.py index 46132c3632..9f36f705ef 100644 --- a/tests/skills/test_collective_wisdom_install_skill.py +++ b/tests/skills/test_collective_wisdom_install_skill.py @@ -1,6 +1,6 @@ from pathlib import Path -import yaml +import hermes_yaml as yaml SKILL = Path("skills/productivity/collective-wisdom-install/SKILL.md") diff --git a/tests/skills/test_competitor_news_monitor_skill.py b/tests/skills/test_competitor_news_monitor_skill.py index 101851e2e6..5bbd5c79bc 100644 --- a/tests/skills/test_competitor_news_monitor_skill.py +++ b/tests/skills/test_competitor_news_monitor_skill.py @@ -2,7 +2,7 @@ import re from pathlib import Path -import yaml +import hermes_yaml as yaml REPO_ROOT = Path(__file__).resolve().parents[2] SKILL_PATH = ( diff --git a/tests/skills/test_darwinian_evolver_skill.py b/tests/skills/test_darwinian_evolver_skill.py index 14a447ffef..d9380b3504 100644 --- a/tests/skills/test_darwinian_evolver_skill.py +++ b/tests/skills/test_darwinian_evolver_skill.py @@ -14,7 +14,7 @@ import re from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml SKILL_DIR = Path(__file__).resolve().parents[2] / "optional-skills" / "research" / "darwinian-evolver" diff --git a/tests/skills/test_decision_questionnaire_skill.py b/tests/skills/test_decision_questionnaire_skill.py index 94a11bd5f2..d32c471094 100644 --- a/tests/skills/test_decision_questionnaire_skill.py +++ b/tests/skills/test_decision_questionnaire_skill.py @@ -4,7 +4,7 @@ import re from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml SKILL_MD = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_document_to_action_items_skill.py b/tests/skills/test_document_to_action_items_skill.py index 376b732e87..a3e1239a3a 100644 --- a/tests/skills/test_document_to_action_items_skill.py +++ b/tests/skills/test_document_to_action_items_skill.py @@ -2,7 +2,7 @@ import re from pathlib import Path -import yaml +import hermes_yaml as yaml SKILL_PATH = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_email_inbox_triage_skill.py b/tests/skills/test_email_inbox_triage_skill.py index 7298b0aecc..6c855274f6 100644 --- a/tests/skills/test_email_inbox_triage_skill.py +++ b/tests/skills/test_email_inbox_triage_skill.py @@ -2,7 +2,7 @@ import re from pathlib import Path -import yaml +import hermes_yaml as yaml SKILL_PATH = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_github_skill.py b/tests/skills/test_github_skill.py index 8beba787e6..1b068c30b1 100644 --- a/tests/skills/test_github_skill.py +++ b/tests/skills/test_github_skill.py @@ -9,7 +9,7 @@ disciplines now check the reference body. import re from pathlib import Path -import yaml +import hermes_yaml as yaml SKILL_DIR = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_grounded_citations_skill.py b/tests/skills/test_grounded_citations_skill.py index 1dd1dd01a6..b6acd5aca2 100644 --- a/tests/skills/test_grounded_citations_skill.py +++ b/tests/skills/test_grounded_citations_skill.py @@ -15,7 +15,7 @@ import re from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml SKILL_DIR = Path(__file__).resolve().parents[2] / "skills" / "research" / "grounded-citations" SCRIPT = SKILL_DIR / "scripts" / "sources.py" diff --git a/tests/skills/test_mcp_oauth_remote_gateway_skill.py b/tests/skills/test_mcp_oauth_remote_gateway_skill.py index 88f4c48e6f..9b80a55f9b 100644 --- a/tests/skills/test_mcp_oauth_remote_gateway_skill.py +++ b/tests/skills/test_mcp_oauth_remote_gateway_skill.py @@ -145,7 +145,7 @@ def test_requests_send_httpx_user_agent(tmp_path): def test_skill_md_frontmatter_invariants(): - yaml = pytest.importorskip("yaml") + yaml = pytest.importorskip("hermes_yaml") content = SKILL_MD.read_text() assert content.startswith("---\n") fm = yaml.safe_load(re.search(r"^---\n(.*?)\n---", content, re.DOTALL).group(1)) diff --git a/tests/skills/test_meeting_action_items_skill.py b/tests/skills/test_meeting_action_items_skill.py index 54514ccac5..b8c79014fb 100644 --- a/tests/skills/test_meeting_action_items_skill.py +++ b/tests/skills/test_meeting_action_items_skill.py @@ -2,7 +2,7 @@ import re from pathlib import Path -import yaml +import hermes_yaml as yaml SKILL_PATH = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_office_document_skills.py b/tests/skills/test_office_document_skills.py index 8b145ac151..8fb78da7f9 100644 --- a/tests/skills/test_office_document_skills.py +++ b/tests/skills/test_office_document_skills.py @@ -12,7 +12,7 @@ import re from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml REPO = Path(__file__).resolve().parent.parent.parent SKILLS = REPO / "skills" diff --git a/tests/skills/test_openclaw_migration.py b/tests/skills/test_openclaw_migration.py index c456844fc0..7347ba41f7 100644 --- a/tests/skills/test_openclaw_migration.py +++ b/tests/skills/test_openclaw_migration.py @@ -213,7 +213,7 @@ def test_readable_config_keeps_every_pre_existing_key(tmp_path: Path): migrator.migrate() - import yaml + import hermes_yaml as yaml merged = yaml.safe_load(config_path.read_text(encoding="utf-8")) assert merged["model"] == "hermes-4-405b" @@ -632,7 +632,7 @@ def test_rebrand_text_replaces_openclaw_variants(): def _run_model_migration(tmp_path: Path, openclaw_json: dict) -> dict: """Helper: run just migrate_model_config on an openclaw.json and return the parsed destination config.yaml.""" - import yaml + import hermes_yaml as yaml mod = load_module() source = tmp_path / ".openclaw" diff --git a/tests/skills/test_pinecone_research_skill.py b/tests/skills/test_pinecone_research_skill.py index 6bf920841e..d8de13b776 100644 --- a/tests/skills/test_pinecone_research_skill.py +++ b/tests/skills/test_pinecone_research_skill.py @@ -12,7 +12,7 @@ import re from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml SKILL_DIR = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_product_price_monitor_skill.py b/tests/skills/test_product_price_monitor_skill.py index 4151c242fb..8f03440d13 100644 --- a/tests/skills/test_product_price_monitor_skill.py +++ b/tests/skills/test_product_price_monitor_skill.py @@ -2,7 +2,7 @@ import re from pathlib import Path -import yaml +import hermes_yaml as yaml SKILL_PATH = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_setup_wizard_generator_skill.py b/tests/skills/test_setup_wizard_generator_skill.py index 81ba0559a8..85c61b8774 100644 --- a/tests/skills/test_setup_wizard_generator_skill.py +++ b/tests/skills/test_setup_wizard_generator_skill.py @@ -5,7 +5,7 @@ import subprocess from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml SKILL_DIR = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_social_media_content_calendar_skill.py b/tests/skills/test_social_media_content_calendar_skill.py index 01c45aaafe..64cf30954b 100644 --- a/tests/skills/test_social_media_content_calendar_skill.py +++ b/tests/skills/test_social_media_content_calendar_skill.py @@ -2,7 +2,7 @@ import re from pathlib import Path -import yaml +import hermes_yaml as yaml SKILL_PATH = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_weekly_review_planning_skill.py b/tests/skills/test_weekly_review_planning_skill.py index 4fc5fd98b5..d5ccd4aff7 100644 --- a/tests/skills/test_weekly_review_planning_skill.py +++ b/tests/skills/test_weekly_review_planning_skill.py @@ -2,7 +2,7 @@ import re from pathlib import Path -import yaml +import hermes_yaml as yaml REPO_ROOT = Path(__file__).resolve().parents[2] SKILL_PATH = ( diff --git a/tests/test_atomic_replace_symlinks.py b/tests/test_atomic_replace_symlinks.py index d3fd4b575f..6a9cde7c1d 100644 --- a/tests/test_atomic_replace_symlinks.py +++ b/tests/test_atomic_replace_symlinks.py @@ -20,7 +20,7 @@ from pathlib import Path from unittest.mock import MagicMock import pytest -import yaml +import hermes_yaml as yaml # Ensure the repo root is importable when running via `pytest tests/...`. _REPO_ROOT = Path(__file__).resolve().parent.parent diff --git a/tests/test_fast_safe_load.py b/tests/test_fast_safe_load.py index 875752e04b..3cc9c159d4 100644 --- a/tests/test_fast_safe_load.py +++ b/tests/test_fast_safe_load.py @@ -1,25 +1,21 @@ -"""Invariants for utils.fast_safe_load. - -fast_safe_load is a drop-in for yaml.safe_load that prefers the libyaml -CSafeLoader C extension for speed. These tests assert the behavior contract -(it parses identically to safe_load across input shapes), not a snapshot of -any particular document. -""" +"""The fast-load entry point follows the shared YAML policy.""" import io -import yaml +import pytest -from utils import fast_safe_load, _fast_yaml_loader +import hermes_yaml as yaml +from utils import fast_safe_load _DOCS = [ - "", # empty document -> None + "", "a: 1\nb: two\nc: 3.5\n", "list: [1, 2, 3]\nnested:\n k: v\n flag: true\n empty: null\n", "name: skill-x\nmetadata:\n hermes:\n tags: [alpha, beta]\n category: devops\n", - "- one\n- two\n- three\n", # top-level sequence - "scalar string", # bare scalar + "- one\n- two\n- three\n", + "scalar string", + "flags: [on, off, yes, no, y, n]\n", ] @@ -34,29 +30,14 @@ def test_equivalent_to_safe_load_for_file_objects(): def test_empty_document_returns_none(): - # Callers rely on ``fast_safe_load(...) or {}`` — empty must be falsy. assert fast_safe_load("") is None -def test_prefers_c_loader_when_available(): - loader = _fast_yaml_loader - # If libyaml is compiled in, we must be using the C loader; otherwise the - # pure-Python SafeLoader is an acceptable fallback. Either way it must be a - # safe loader (never the unsafe full Loader). - c_loader = getattr(yaml, "CSafeLoader", None) - if c_loader is not None: - assert loader is c_loader - else: - assert loader is yaml.SafeLoader +def test_duplicate_keys_are_rejected_instead_of_silently_overwriting(): + with pytest.raises(yaml.YAMLError): + fast_safe_load("model: first\nmodel: second\n") def test_rejects_arbitrary_python_objects_like_safe_load(): - # Safe loaders must not construct arbitrary Python objects. This tag is - # accepted by the unsafe Loader but rejected by Safe/CSafe loaders. - dangerous = "!!python/object/apply:os.system ['echo pwned']\n" - try: - fast_safe_load(dangerous) - raised = False - except yaml.YAMLError: - raised = True - assert raised, "fast_safe_load must reject python/object tags like safe_load" + with pytest.raises(yaml.YAMLError): + fast_safe_load("!!python/object/apply:builtins.str ['must not construct']\n") diff --git a/tests/test_gateway_streaming_nested_config.py b/tests/test_gateway_streaming_nested_config.py index b4658b755a..2ff2ae611b 100644 --- a/tests/test_gateway_streaming_nested_config.py +++ b/tests/test_gateway_streaming_nested_config.py @@ -1,39 +1,31 @@ """Regression test for #25676 — nested gateway.streaming config must be loaded.""" -from pathlib import Path -from unittest.mock import patch, MagicMock +from unittest.mock import patch + +import hermes_yaml as yaml -def _load_with_yaml_dict(yaml_dict: dict): - """Patch filesystem so load_gateway_config() sees *yaml_dict* as config.yaml.""" +def _load_with_yaml_dict(yaml_dict: dict, tmp_path): + """Load a real config.yaml through the gateway's shared YAML reader.""" from gateway.config import load_gateway_config - fake_home = Path("/tmp/fake_hermes_home_25676") - - def fake_exists(self): - return str(self).endswith("config.yaml") - - with patch("gateway.config.get_hermes_home", return_value=fake_home), \ - patch.object(Path, "exists", fake_exists), \ - patch("builtins.open", create=True) as mock_file: - mock_file.return_value.__enter__ = lambda s: s - mock_file.return_value.__exit__ = MagicMock(return_value=False) - with patch("yaml.safe_load", return_value=yaml_dict): - return load_gateway_config() + (tmp_path / "config.yaml").write_text(yaml.safe_dump(yaml_dict), encoding="utf-8") + with patch("gateway.config.get_hermes_home", return_value=tmp_path): + return load_gateway_config() class TestStreamingConfigNested: - def test_top_level_streaming(self): - cfg = _load_with_yaml_dict({"streaming": {"enabled": True, "transport": "draft"}}) + def test_top_level_streaming(self, tmp_path): + cfg = _load_with_yaml_dict({"streaming": {"enabled": True, "transport": "draft"}}, tmp_path) assert cfg.streaming.enabled is True assert cfg.streaming.transport == "draft" - def test_top_level_takes_precedence(self): + def test_top_level_takes_precedence(self, tmp_path): cfg = _load_with_yaml_dict({ "streaming": {"enabled": True, "transport": "edit"}, "gateway": {"streaming": {"enabled": False, "transport": "draft"}}, - }) + }, tmp_path) assert cfg.streaming.enabled is True assert cfg.streaming.transport == "edit" @@ -105,10 +97,14 @@ class TestStreamingYamlBooleanQuirk: assert sc.transport == "auto" - def test_loader_normalizes_bare_yaml_off(self): + def test_loader_normalizes_bare_yaml_off(self, tmp_path): """End-to-end through load_gateway_config(): unquoted ``mode: off`` (a YAML boolean) must keep streaming disabled.""" - cfg = _load_with_yaml_dict({"streaming": {"mode": False}}) + from gateway.config import load_gateway_config + + (tmp_path / "config.yaml").write_text("streaming:\n mode: off\n", encoding="utf-8") + with patch("gateway.config.get_hermes_home", return_value=tmp_path): + cfg = load_gateway_config() assert cfg.streaming.enabled is False assert cfg.streaming.transport == "off" diff --git a/tests/test_hermes_logging.py b/tests/test_hermes_logging.py index 0188accbc6..9c56d08859 100644 --- a/tests/test_hermes_logging.py +++ b/tests/test_hermes_logging.py @@ -146,9 +146,9 @@ class TestSetupLogging: def test_explicit_params_override_config(self, hermes_home): """Explicit function params take precedence over config.yaml.""" - import yaml + import hermes_yaml as yaml config = {"logging": {"level": "DEBUG"}} - (hermes_home / "config.yaml").write_text(yaml.dump(config)) + (hermes_home / "config.yaml").write_text(yaml.safe_dump(config)) hermes_logging.setup_logging(hermes_home=hermes_home, log_level="WARNING") @@ -475,9 +475,9 @@ class TestReadLoggingConfig: assert backup is None def test_reads_logging_section(self, hermes_home): - import yaml + import hermes_yaml as yaml config = {"logging": {"level": "DEBUG", "max_size_mb": 10, "backup_count": 5}} - (hermes_home / "config.yaml").write_text(yaml.dump(config)) + (hermes_home / "config.yaml").write_text(yaml.safe_dump(config)) level, max_size, backup = hermes_logging._read_logging_config() assert level == "DEBUG" diff --git a/tests/test_hermes_yaml.py b/tests/test_hermes_yaml.py new file mode 100644 index 0000000000..f4c727afc6 --- /dev/null +++ b/tests/test_hermes_yaml.py @@ -0,0 +1,89 @@ +"""Contracts for Hermes' shared YAML reader and writer.""" + +import io +from concurrent.futures import ThreadPoolExecutor + +import pytest + +import hermes_yaml as yaml + + +def test_safe_load_accepts_existing_config_boolean_spellings(): + document = "flags: [on, off, yes, no, true, false]\nquoted: ['off', 'yes']\n" + expected = {"flags": [True, False, True, False, True, False], "quoted": ["off", "yes"]} + for stream in (document, document.encode(), io.StringIO(document), io.BytesIO(document.encode())): + assert yaml.safe_load(stream) == expected + assert yaml.safe_load("") is None + + +def test_safe_load_rejects_python_object_construction(): + with pytest.raises(yaml.YAMLError): + yaml.safe_load("!!python/object/apply:builtins.str ['must not construct']") + + +def test_safe_dump_preserves_data_and_readable_block_layout(): + data = {"z": [{"mode": "off", "choice": "y", "label": "こんにちは 🦀"}], "a": "yes"} + text = yaml.safe_dump(data, sort_keys=False) + assert yaml.safe_load(text) == data + assert text.startswith("z:\n - ") + assert "こんにちは 🦀" in text + stream = io.StringIO() + assert yaml.safe_dump(data, stream, sort_keys=False) is None + assert stream.getvalue() == text + with pytest.raises(yaml.YAMLError): + yaml.safe_dump({"object": object()}) + + +def test_safe_dump_honors_the_options_used_by_callers(): + data = {"zebra": {"zed": 1, "alpha": 2}, "alpha": "hé"} + for sort_keys in (False, True): + loaded = yaml.safe_load(yaml.safe_dump(data, sort_keys=sort_keys)) + assert list(loaded) == (sorted(data) if sort_keys else list(data)) + assert list(loaded["zebra"]) == (sorted(data["zebra"]) if sort_keys else list(data["zebra"])) + escaped = yaml.safe_dump(data, allow_unicode=False) + assert "hé" not in escaped + assert yaml.safe_load(escaped) == data + flow = yaml.safe_dump(data, default_flow_style=True, width=100000) + assert flow.startswith("{") and len(flow.splitlines()) == 1 + assert yaml.safe_load(flow) == data + + +def test_roundtrip_preserves_comments_quotes_and_scalar_types(): + editor = yaml.roundtrip_yaml() + original = '# keep this\nname: "hello 🦀" # note\nflag: off\n' + data = editor.load(original) + assert data["flag"] is False + data["mode"] = "off" + stream = io.StringIO() + editor.dump(data, stream) + text = stream.getvalue() + assert text.startswith('# keep this\nname: "hello 🦀" # note\n') + assert yaml.safe_load(text) == {"name": "hello 🦀", "flag": False, "mode": "off"} + + +def test_native_yaml11_scalars_and_duplicate_key_policy(): + for load in (yaml.safe_load, yaml.roundtrip_yaml().load): + assert load("[y, n, Y, N, 'y', 'n']") == [True, False, True, False, "y", "n"] + with pytest.raises(yaml.YAMLError): + load("model: first\nmodel: second\n") + # Merge keys override defaults, not duplicates in the mapping itself. + merged = yaml.safe_load("defaults: &defaults {enabled: true}\nlocal: {<<: *defaults, enabled: false}\n") + assert merged["local"]["enabled"] is False + + +def test_parallel_calls_do_not_share_parser_or_emitter_state(): + def roundtrip(index): + data = {"index": index, "words": ["yes", "no", "on", "off", "y", "n"]} + text = yaml.safe_dump(data, sort_keys=bool(index % 2)) + assert yaml.safe_load(text) == data + assert yaml.roundtrip_yaml().load(text) == data + return data + + with ThreadPoolExecutor(max_workers=8) as pool: + assert [data["index"] for data in pool.map(roundtrip, range(32))] == list(range(32)) + # A failed parse/dump must not poison the next operation. + with pytest.raises(yaml.YAMLError): + yaml.safe_load("key: [unterminated") + with pytest.raises(yaml.YAMLError): + yaml.safe_dump(object()) + assert yaml.safe_load(yaml.safe_dump({"healthy": True})) == {"healthy": True} diff --git a/tests/test_journal_mode_config.py b/tests/test_journal_mode_config.py index b0b865c7e3..1aa7e6dfcb 100644 --- a/tests/test_journal_mode_config.py +++ b/tests/test_journal_mode_config.py @@ -7,7 +7,7 @@ import sqlite3 import pytest import hermes_state_wal -import yaml +import hermes_yaml as yaml def _write_config(monkeypatch: pytest.MonkeyPatch, tmp_path, config: object) -> None: diff --git a/tests/test_journal_mode_upgrade_warning.py b/tests/test_journal_mode_upgrade_warning.py index 9341a8699f..b01f83dadf 100644 --- a/tests/test_journal_mode_upgrade_warning.py +++ b/tests/test_journal_mode_upgrade_warning.py @@ -28,7 +28,7 @@ import sqlite3 import pytest import hermes_state_wal -import yaml +import hermes_yaml as yaml def _write_config(monkeypatch: pytest.MonkeyPatch, tmp_path, config: object) -> None: diff --git a/tests/test_session_vacuum_config.py b/tests/test_session_vacuum_config.py index 43adfdacba..67edc39204 100644 --- a/tests/test_session_vacuum_config.py +++ b/tests/test_session_vacuum_config.py @@ -72,7 +72,7 @@ def test_shipped_template_does_not_pin_sessions_keys(): uncommented ``sessions:`` value there becomes an EXPLICIT user setting that would freeze the retention defaults. The template must leave them commented so code defaults (and future flips) apply.""" - import yaml + import hermes_yaml as yaml template = Path(__file__).resolve().parents[1] / "cli-config.yaml.example" data = yaml.safe_load(template.read_text(encoding="utf-8")) or {} diff --git a/tests/test_state_db_malformed_repair.py b/tests/test_state_db_malformed_repair.py index 02bdff0a30..18510f06be 100644 --- a/tests/test_state_db_malformed_repair.py +++ b/tests/test_state_db_malformed_repair.py @@ -658,7 +658,7 @@ def _mode_of(db_path) -> str: def _configure_journal_mode(monkeypatch, tmp_path, mode) -> None: - import yaml + import hermes_yaml as yaml home = tmp_path / "hermes-home" home.mkdir(exist_ok=True) diff --git a/tests/test_transform_llm_output_hook.py b/tests/test_transform_llm_output_hook.py index 78946316dd..490b460439 100644 --- a/tests/test_transform_llm_output_hook.py +++ b/tests/test_transform_llm_output_hook.py @@ -17,7 +17,7 @@ contract for the generic tool-result seam. from pathlib import Path -import yaml +import hermes_yaml as yaml import hermes_cli.plugins as plugins_mod from hermes_cli.plugins import PluginManager, VALID_HOOKS diff --git a/tests/test_transform_tool_result_hook.py b/tests/test_transform_tool_result_hook.py index b6cdbfba7e..810fd962a6 100644 --- a/tests/test_transform_tool_result_hook.py +++ b/tests/test_transform_tool_result_hook.py @@ -134,7 +134,7 @@ def test_transform_tool_result_runs_after_post_tool_call(monkeypatch): def test_transform_tool_result_integration_with_real_plugin(monkeypatch, tmp_path): """End-to-end: load a real plugin from HERMES_HOME and verify it rewrites results.""" - import yaml + import hermes_yaml as yaml hermes_home = Path(os.environ["HERMES_HOME"]) plugins_dir = hermes_home / "plugins" diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 683034c7a5..701916e203 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -812,7 +812,7 @@ def test_session_context_explicit_cwd_for_ephemeral_task(monkeypatch, tmp_path): def _write_profile_cfg(home: Path, cwd: str | None) -> Path: - import yaml + import hermes_yaml as yaml home.mkdir(parents=True, exist_ok=True) cfg = {"terminal": {"cwd": cwd}} if cwd is not None else {} @@ -8312,7 +8312,7 @@ def test_config_set_yolo_toggles_session_scope(): def test_config_set_yolo_global_scope_writes_approvals_mode(tmp_path, monkeypatch): """Shift+click the desktop zap -> scope="global" flips persistent approvals.mode.""" - import yaml + import hermes_yaml as yaml cfg_path = tmp_path / "config.yaml" cfg_path.write_text(yaml.safe_dump({"approvals": {"mode": "manual"}})) @@ -8343,7 +8343,7 @@ def test_config_set_yolo_global_scope_writes_approvals_mode(tmp_path, monkeypatc def test_config_get_approval_mode_uses_smart_default_when_key_is_missing( tmp_path, monkeypatch ): - import yaml + import hermes_yaml as yaml monkeypatch.setattr(server, "_hermes_home", tmp_path) # Point the canonical resolver (load_config → env HERMES_HOME) at the @@ -8363,7 +8363,7 @@ def test_config_get_approval_mode_uses_smart_default_when_key_is_missing( def test_config_get_approval_mode_fails_safe_to_manual_for_invalid_explicit_value( tmp_path, monkeypatch ): - import yaml + import hermes_yaml as yaml monkeypatch.setattr(server, "_hermes_home", tmp_path) # _load_approval_mode delegates to the canonical resolver in @@ -8382,7 +8382,7 @@ def test_config_get_approval_mode_fails_safe_to_manual_for_invalid_explicit_valu def test_config_get_approval_mode_normalizes_yaml_off(tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml monkeypatch.setattr(server, "_hermes_home", tmp_path) # See fail-safe test above: the canonical resolver reads via @@ -8401,7 +8401,7 @@ def test_config_get_approval_mode_normalizes_yaml_off(tmp_path, monkeypatch): def test_config_set_approval_mode_persists_three_way_value_and_emits_live_status( tmp_path, monkeypatch ): - import yaml + import hermes_yaml as yaml monkeypatch.setattr(server, "_hermes_home", tmp_path) # config.set writes via server._hermes_home, but the post-write @@ -8436,7 +8436,7 @@ def test_pet_gallery_quoted_false_enabled_reports_disabled(tmp_path, monkeypatch quoted YAML value kept the petdex mascot enabled against the operator's explicit intent. """ - import yaml + import hermes_yaml as yaml monkeypatch.setattr(server, "_hermes_home", tmp_path) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) @@ -8517,7 +8517,7 @@ def test_config_set_approval_mode_rejects_unknown_value(): def test_config_set_yolo_global_scope_honors_explicit_value(tmp_path, monkeypatch): """An explicit value pins global approvals.mode regardless of prior state.""" - import yaml + import hermes_yaml as yaml cfg_path = tmp_path / "config.yaml" cfg_path.write_text(yaml.safe_dump({"approvals": {"mode": "manual"}})) @@ -8761,7 +8761,7 @@ def test_config_get_busy_survives_non_dict_display(monkeypatch): def test_config_set_statusbar_survives_non_dict_display(tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml cfg_path = tmp_path / "config.yaml" cfg_path.write_text(yaml.safe_dump({"display": "broken"})) @@ -8781,7 +8781,7 @@ def test_config_set_statusbar_survives_non_dict_display(tmp_path, monkeypatch): def test_config_set_details_mode_pins_all_sections(tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml cfg_path = tmp_path / "config.yaml" cfg_path.write_text( @@ -8811,7 +8811,7 @@ def test_config_set_details_mode_pins_all_sections(tmp_path, monkeypatch): def test_config_set_section_writes_per_section_override(tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml cfg_path = tmp_path / "config.yaml" monkeypatch.setattr(server, "_hermes_home", tmp_path) @@ -8830,7 +8830,7 @@ def test_config_set_section_writes_per_section_override(tmp_path, monkeypatch): def test_config_set_section_clears_override_on_empty_value(tmp_path, monkeypatch): - import yaml + import hermes_yaml as yaml cfg_path = tmp_path / "config.yaml" cfg_path.write_text( @@ -20098,7 +20098,7 @@ def test_persist_model_switch_preserves_sibling_model_keys(tmp_path, monkeypatch `model:` (model_slots, model_fallback, etc.). _persist_model_switch now uses targeted save_config_value writes instead of rewriting the whole block.""" import types - import yaml + import hermes_yaml as yaml import cli cfg_path = tmp_path / "config.yaml" @@ -20138,7 +20138,7 @@ def test_persist_model_switch_clears_stale_base_url(tmp_path, monkeypatch): provider with no base_url must CLEAR the stale base_url, not leave it pointing at the old host.""" import types - import yaml + import hermes_yaml as yaml import cli cfg_path = tmp_path / "config.yaml" @@ -21352,7 +21352,7 @@ def test_save_cfg_preserves_user_comments(tmp_path, monkeypatch): assert "# provider rationale" in text assert "# trailing skin note" in text - import yaml as _yaml + import hermes_yaml as _yaml parsed = _yaml.safe_load(text) assert parsed["display"]["skin"] == "mono" diff --git a/tests/test_utils_atomic_roundtrip_yaml_save.py b/tests/test_utils_atomic_roundtrip_yaml_save.py index ff89258b81..72775fe3b6 100644 --- a/tests/test_utils_atomic_roundtrip_yaml_save.py +++ b/tests/test_utils_atomic_roundtrip_yaml_save.py @@ -10,7 +10,7 @@ from pathlib import Path from unittest.mock import patch import pytest -import yaml +import hermes_yaml as yaml class TestAtomicRoundtripYamlSave: diff --git a/tests/test_yaml_indent_consistency_31999.py b/tests/test_yaml_indent_consistency_31999.py index bda5e3bfcd..13f4adada6 100644 --- a/tests/test_yaml_indent_consistency_31999.py +++ b/tests/test_yaml_indent_consistency_31999.py @@ -1,120 +1,70 @@ -"""Regression tests for issue #31999. +"""All YAML write paths use indented block sequences (#31999).""" -All YAML config write paths must produce 2-space-indented list items -(matching ruamel.yaml's layout). Mixing 0-indent (default PyYAML) and -2-indent (ruamel.yaml) in the same config.yaml produces a file that -stricter parsers like js-yaml reject with "bad indentation of a mapping -entry", silently dropping custom_providers and breaking model switching. -""" +import io -import yaml -from utils import IndentDumper, atomic_yaml_write +import pytest + +import hermes_yaml as yaml +from utils import atomic_roundtrip_yaml_update, atomic_yaml_write -class TestIndentDumperShape: - """IndentDumper emits 2-space-indented list items under mapping keys.""" - - def test_indent_dumper_produces_2_indent_lists(self): - """List items under a mapping key must start at column 2, not 0.""" - data = { - "custom_providers": [ - {"name": "NVIDIA", "base_url": "https://api.nvidia.com"}, - ], - } - out = yaml.dump(data, Dumper=IndentDumper, default_flow_style=False) - # The list item should be indented 2 spaces under the key - assert " - " in out, f"Expected 2-indent list, got:\n{out}" - - def test_default_pyyaml_produces_0_indent_lists(self): - """Default PyYAML (the buggy baseline) emits 0-indent lists.""" - data = { - "custom_providers": [ - {"name": "NVIDIA", "base_url": "https://api.nvidia.com"}, - ], - } - out = yaml.dump(data, default_flow_style=False) - # The list item should be at column 0 (no leading spaces) - lines = out.strip().split("\n") - list_lines = [l for l in lines if l.lstrip().startswith("- ")] - assert all(not l.startswith(" - ") for l in list_lines), \ - f"Expected 0-indent list (buggy baseline), got:\n{out}" - - def test_indent_dumper_matches_ruamel_layout(self): - """IndentDumper output should match ruamel.yaml's list-under-mapping layout.""" - data = { - "items": [ - {"key": "value1"}, - {"key": "value2"}, - ], - } - pyyaml_out = yaml.dump(data, Dumper=IndentDumper, default_flow_style=False) - # ruamel.yaml with indent(mapping=2, sequence=4, offset=2) produces: - # items: - # - key: value1 - # - key: value2 - # The key check: list items are NOT at column 0 - lines = pyyaml_out.strip().split("\n") - list_lines = [l for l in lines if l.lstrip().startswith("- ")] - assert all(l.startswith(" - ") for l in list_lines), \ - f"List items not 2-indent:\n{pyyaml_out}" +def test_safe_dump_produces_indented_lists(): + data = {"custom_providers": [{"name": "NVIDIA", "base_url": "https://api.nvidia.com"}]} + out = yaml.safe_dump(data) + assert "\n - " in out + assert yaml.safe_load(out) == data -class TestAtomicYamlWriteUsesIndentDumper: - """atomic_yaml_write must produce 2-indent lists via IndentDumper.""" - - def test_atomic_yaml_write_produces_2_indent_lists(self, tmp_path): - """The file written by atomic_yaml_write must have 2-indent list items.""" - data = { - "custom_providers": [ - {"name": "Test", "base_url": "https://example.com"}, - ], - } - path = tmp_path / "config.yaml" - atomic_yaml_write(path, data) - - content = path.read_text(encoding="utf-8") - assert " - " in content, \ - f"Expected 2-indent list in file, got:\n{content}" - - def test_atomic_yaml_write_preserves_unicode(self, tmp_path): - """allow_unicode=True should write real UTF-8, not escape sequences.""" - data = {"name": "Tëst Näme"} - path = tmp_path / "config.yaml" - atomic_yaml_write(path, data) - - content = path.read_text(encoding="utf-8") - assert "Tëst Näme" in content - - def test_atomic_yaml_write_is_atomic(self, tmp_path): - """atomic_yaml_write should create the file and clean up temp files.""" - data = {"key": "value"} - path = tmp_path / "config.yaml" - atomic_yaml_write(path, data) - - assert path.exists() - assert path.read_text(encoding="utf-8").strip().endswith("value") - # No leftover temp files - temp_files = list(tmp_path.glob(".config_*.tmp")) - assert len(temp_files) == 0 +def test_safe_and_roundtrip_writers_use_the_same_layout(): + data = {"items": [{"key": "value1"}, {"key": "value2"}]} + stream = io.StringIO() + yaml.roundtrip_yaml().dump(data, stream) + assert yaml.safe_dump(data, sort_keys=False) == stream.getvalue() -class TestRoundtripConsistency: - """Output of atomic_yaml_write should round-trip through ruamel.yaml.""" +def test_atomic_write_then_key_update_keeps_layout_and_values(tmp_path): + data = {"custom_providers": [{"name": "Test", "base_url": "https://example.com"}]} + path = tmp_path / "config.yaml" + atomic_yaml_write(path, data) + initial = path.read_text(encoding="utf-8") + atomic_roundtrip_yaml_update(path, "approvals.mode", "off") + content = path.read_text(encoding="utf-8") + assert content.startswith(initial) + assert "\n - " in content + assert yaml.safe_load(content) == {**data, "approvals": {"mode": "off"}} - def test_pyyaml_output_loads_in_ruamel(self, tmp_path): - """File written by atomic_yaml_write should load in ruamel.yaml without errors.""" - data = { - "custom_providers": [ - {"name": "Provider A", "base_url": "https://a.example.com"}, - {"name": "Provider B", "base_url": "https://b.example.com"}, - ], - "fallback_providers": ["backup1", "backup2"], - } - path = tmp_path / "config.yaml" - atomic_yaml_write(path, data) - from ruamel.yaml import YAML - yaml_rt = YAML(typ="rt") - loaded = yaml_rt.load(path.read_text(encoding="utf-8")) - assert loaded["custom_providers"][0]["name"] == "Provider A" - assert loaded["fallback_providers"] == ["backup1", "backup2"] +def test_atomic_yaml_write_preserves_unicode(tmp_path): + path = tmp_path / "config.yaml" + atomic_yaml_write(path, {"name": "Tëst Näme 🦀"}) + assert "Tëst Näme 🦀" in path.read_text(encoding="utf-8") + + +def test_atomic_yaml_write_is_atomic(tmp_path): + path = tmp_path / "config.yaml" + atomic_yaml_write(path, {"key": "value"}) + assert yaml.safe_load(path.read_text(encoding="utf-8")) == {"key": "value"} + assert not list(tmp_path.glob(".config_*.tmp")) + + +def test_failed_atomic_yaml_write_keeps_original(tmp_path): + path = tmp_path / "config.yaml" + original = "# keep original\nkey: value\n" + path.write_text(original, encoding="utf-8") + with pytest.raises(yaml.YAMLError): + atomic_yaml_write(path, {"object": object()}) + assert path.read_text(encoding="utf-8") == original + assert not list(tmp_path.glob(".config_*.tmp")) + + +def test_atomic_yaml_write_loads_in_roundtrip_editor(tmp_path): + data = { + "custom_providers": [ + {"name": "Provider A", "base_url": "https://a.example.com"}, + {"name": "Provider B", "base_url": "https://b.example.com"}, + ], + "fallback_providers": ["backup1", "backup2"], + } + path = tmp_path / "config.yaml" + atomic_yaml_write(path, data) + assert yaml.roundtrip_yaml().load(path.read_text(encoding="utf-8")) == data diff --git a/tests/tools/test_allowlist_legacy_config.py b/tests/tools/test_allowlist_legacy_config.py index ff91f28c36..76f1db091d 100644 --- a/tests/tools/test_allowlist_legacy_config.py +++ b/tests/tools/test_allowlist_legacy_config.py @@ -1,4 +1,4 @@ -import yaml +import hermes_yaml as yaml from tools import approval diff --git a/tests/tools/test_credential_files.py b/tests/tools/test_credential_files.py index 9a6d5887d9..b52dd86d62 100644 --- a/tests/tools/test_credential_files.py +++ b/tests/tools/test_credential_files.py @@ -328,9 +328,9 @@ class TestConfigPathTraversal: """terminal.credential_files in config.yaml must also reject traversal.""" def _write_config(self, hermes_home: Path, cred_files: list): - import yaml + import hermes_yaml as yaml config_path = hermes_home / "config.yaml" - config_path.write_text(yaml.dump({"terminal": {"credential_files": cred_files}})) + config_path.write_text(yaml.safe_dump({"terminal": {"credential_files": cred_files}})) def test_config_traversal_rejected(self, tmp_path, monkeypatch): """'../secret' in config.yaml must not escape HERMES_HOME.""" diff --git a/tests/tools/test_delegate_fallback_matrix.py b/tests/tools/test_delegate_fallback_matrix.py index a5237c30f5..d6393fa822 100644 --- a/tests/tools/test_delegate_fallback_matrix.py +++ b/tests/tools/test_delegate_fallback_matrix.py @@ -75,7 +75,7 @@ def test_declared_chain_flows_through_real_profile_config_loader( ): """The public key must survive DEFAULT_CONFIG/profile loading without patching ``_load_config`` and reach the child constructor.""" - import yaml + import hermes_yaml as yaml from hermes_constants import ( reset_hermes_home_override, @@ -112,7 +112,7 @@ def test_declared_chain_flows_through_real_profile_config_loader( def test_explicit_empty_chain_survives_real_profile_config_loader(tmp_path, monkeypatch): """An explicit [] remains an authoritative disable after config loading.""" - import yaml + import hermes_yaml as yaml from hermes_constants import reset_hermes_home_override, set_hermes_home_override @@ -143,7 +143,7 @@ def test_explicit_empty_chain_survives_real_profile_config_loader(tmp_path, monk def test_pinned_review_does_not_borrow_general_worker_chain(tmp_path, monkeypatch): """The public /review route owns its fallback policy as well as its model.""" - import yaml + import hermes_yaml as yaml from agent.review_engine import start_review from hermes_constants import reset_hermes_home_override, set_hermes_home_override diff --git a/tests/tools/test_docker_config_migrate.py b/tests/tools/test_docker_config_migrate.py index ac9d3040be..ff80e86ba8 100644 --- a/tests/tools/test_docker_config_migrate.py +++ b/tests/tools/test_docker_config_migrate.py @@ -7,7 +7,7 @@ import sys from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml from hermes_cli.config import DEFAULT_CONFIG diff --git a/tests/tools/test_env_passthrough.py b/tests/tools/test_env_passthrough.py index 56eab9ba2b..8cccda656b 100644 --- a/tests/tools/test_env_passthrough.py +++ b/tests/tools/test_env_passthrough.py @@ -2,7 +2,7 @@ import os import pytest -import yaml +import hermes_yaml as yaml from agent import secret_scope as ss import tools.env_passthrough as _ep_mod @@ -44,7 +44,7 @@ class TestConfigPassthrough: def test_reads_from_config(self, tmp_path, monkeypatch): config = {"terminal": {"env_passthrough": ["MY_CUSTOM_KEY", "ANOTHER_TOKEN"]}} config_path = tmp_path / "config.yaml" - config_path.write_text(yaml.dump(config), encoding="utf-8") + config_path.write_text(yaml.safe_dump(config), encoding="utf-8") monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _ep_mod._config_passthrough = None @@ -56,7 +56,7 @@ class TestConfigPassthrough: def test_union_of_skill_and_config(self, tmp_path, monkeypatch): config = {"terminal": {"env_passthrough": ["CONFIG_KEY"]}} config_path = tmp_path / "config.yaml" - config_path.write_text(yaml.dump(config), encoding="utf-8") + config_path.write_text(yaml.safe_dump(config), encoding="utf-8") monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _ep_mod._config_passthrough = None diff --git a/tests/tools/test_image_generation_image_to_image.py b/tests/tools/test_image_generation_image_to_image.py index 5dd3ea7591..cafd126cb7 100644 --- a/tests/tools/test_image_generation_image_to_image.py +++ b/tests/tools/test_image_generation_image_to_image.py @@ -17,7 +17,7 @@ import json from typing import Any, Dict, List, Optional import pytest -import yaml +import hermes_yaml as yaml from agent import image_gen_registry from agent.image_gen_provider import ImageGenProvider diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 9d98bc8cf7..e677537654 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -158,7 +158,7 @@ class TestLoadMCPConfig: self, tmp_path, monkeypatch ): import json - import yaml + import hermes_yaml as yaml from hermes_cli.agent_plugins import MCP_SCHEMA_V1, PLUGIN_SCHEMA_V1 from hermes_cli import plugins as plugins_mod diff --git a/tests/tools/test_pre_transcription_hook.py b/tests/tools/test_pre_transcription_hook.py index 259793b279..a038fe8ed2 100644 --- a/tests/tools/test_pre_transcription_hook.py +++ b/tests/tools/test_pre_transcription_hook.py @@ -582,7 +582,7 @@ def test_real_fixture_plugins_thread_prompt_in_registration_order( import os from pathlib import Path - import yaml + import hermes_yaml as yaml hermes_home = Path(os.environ["HERMES_HOME"]) plugin_dir = hermes_home / "plugins" / "stt_vocab" diff --git a/tests/tools/test_terminal_output_transform_hook.py b/tests/tools/test_terminal_output_transform_hook.py index 0453ef68e8..f90786b80c 100644 --- a/tests/tools/test_terminal_output_transform_hook.py +++ b/tests/tools/test_terminal_output_transform_hook.py @@ -173,7 +173,7 @@ def test_terminal_output_transform_does_not_change_approval_or_exit_code_meaning def test_terminal_output_transform_integration_with_real_plugin(monkeypatch, tmp_path): - import yaml + import hermes_yaml as yaml hermes_home = Path(os.environ["HERMES_HOME"]) plugins_dir = hermes_home / "plugins" diff --git a/tests/tools/test_tts_kittentts.py b/tests/tools/test_tts_kittentts.py index 44fd7cc41c..9cce08ac60 100644 --- a/tests/tools/test_tts_kittentts.py +++ b/tests/tools/test_tts_kittentts.py @@ -120,7 +120,7 @@ class TestDispatcherBranch: from tools.tts_tool import text_to_speech_tool # Write a config telling it to use kittentts - import yaml + import hermes_yaml as yaml (tmp_path / "config.yaml").write_text( yaml.safe_dump({"tts": {"provider": "kittentts"}}) ) diff --git a/tests/tools/test_video_generation_dynamic_schema.py b/tests/tools/test_video_generation_dynamic_schema.py index 55de50afb5..9ebdc38727 100644 --- a/tests/tools/test_video_generation_dynamic_schema.py +++ b/tests/tools/test_video_generation_dynamic_schema.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any, Dict, List, Optional import pytest -import yaml +import hermes_yaml as yaml from agent import video_gen_registry from agent.video_gen_provider import VideoGenProvider diff --git a/tests/tools/test_video_generation_tool_surface_matrix.py b/tests/tools/test_video_generation_tool_surface_matrix.py index 96f7e74f12..231c0f30c4 100644 --- a/tests/tools/test_video_generation_tool_surface_matrix.py +++ b/tests/tools/test_video_generation_tool_surface_matrix.py @@ -19,7 +19,7 @@ import types from typing import Any, Dict, List import pytest -import yaml +import hermes_yaml as yaml @pytest.fixture(autouse=True) diff --git a/tests/tools/test_voice_client_config.py b/tests/tools/test_voice_client_config.py index ac9a0d4674..ea656cd338 100644 --- a/tests/tools/test_voice_client_config.py +++ b/tests/tools/test_voice_client_config.py @@ -9,7 +9,7 @@ import importlib import sys import pytest -import yaml +import hermes_yaml as yaml @pytest.fixture() diff --git a/tests/tools/test_xai_http_storage.py b/tests/tools/test_xai_http_storage.py index dca24e0b11..452f494065 100644 --- a/tests/tools/test_xai_http_storage.py +++ b/tests/tools/test_xai_http_storage.py @@ -2,7 +2,7 @@ from __future__ import annotations -import yaml +import hermes_yaml as yaml def _invalidate_config_cache(): diff --git a/tests/tui_gateway/test_config_profile_scope.py b/tests/tui_gateway/test_config_profile_scope.py index d7d1a8e02e..64d9b7890f 100644 --- a/tests/tui_gateway/test_config_profile_scope.py +++ b/tests/tui_gateway/test_config_profile_scope.py @@ -10,7 +10,7 @@ from __future__ import annotations from pathlib import Path -import yaml +import hermes_yaml as yaml import tui_gateway.server as server diff --git a/tests/tui_gateway/test_config_set_display_toggles.py b/tests/tui_gateway/test_config_set_display_toggles.py index 2139314f26..85633b2cae 100644 --- a/tests/tui_gateway/test_config_set_display_toggles.py +++ b/tests/tui_gateway/test_config_set_display_toggles.py @@ -7,7 +7,7 @@ shipped with a toggle that never reached the backend gating the tool. """ import pytest -import yaml +import hermes_yaml as yaml from tui_gateway import server diff --git a/tests/tui_gateway/test_mcp_profile_rpcs.py b/tests/tui_gateway/test_mcp_profile_rpcs.py index 17708893fc..45f6dafc87 100644 --- a/tests/tui_gateway/test_mcp_profile_rpcs.py +++ b/tests/tui_gateway/test_mcp_profile_rpcs.py @@ -51,7 +51,7 @@ def _result(resp): def _read_yaml(path: Path) -> dict: """Read a config.yaml directly for assertions (test-side, not the guarded loader).""" - import yaml + import hermes_yaml as yaml if not path.is_file(): return {} diff --git a/tests/tui_gateway/test_personality_clobbers_system_prompt.py b/tests/tui_gateway/test_personality_clobbers_system_prompt.py index 36a99c1167..df5d4edd15 100644 --- a/tests/tui_gateway/test_personality_clobbers_system_prompt.py +++ b/tests/tui_gateway/test_personality_clobbers_system_prompt.py @@ -34,7 +34,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch import tui_gateway.server as server -import yaml +import hermes_yaml as yaml MANUAL_PROMPT = "manual_prompt_1" diff --git a/tests/tui_gateway/test_profile_rebuild_commit.py b/tests/tui_gateway/test_profile_rebuild_commit.py index 2825f1f375..376e1f5e95 100644 --- a/tests/tui_gateway/test_profile_rebuild_commit.py +++ b/tests/tui_gateway/test_profile_rebuild_commit.py @@ -3,7 +3,7 @@ import threading from types import SimpleNamespace import pytest -import yaml +import hermes_yaml as yaml @pytest.mark.parametrize("explicit_profile", [None, "default"]) diff --git a/tests/tui_gateway/test_profile_shell_hooks.py b/tests/tui_gateway/test_profile_shell_hooks.py index 5530bd985f..0fd6e46eac 100644 --- a/tests/tui_gateway/test_profile_shell_hooks.py +++ b/tests/tui_gateway/test_profile_shell_hooks.py @@ -5,7 +5,7 @@ import subprocess import sys from pathlib import Path -import yaml +import hermes_yaml as yaml def test_agent_build_arms_only_consented_profile_policy(tmp_path, monkeypatch): diff --git a/tests/tui_gateway/test_profiles_configure_model_guard.py b/tests/tui_gateway/test_profiles_configure_model_guard.py index 51cd8f21c8..2152774b45 100644 --- a/tests/tui_gateway/test_profiles_configure_model_guard.py +++ b/tests/tui_gateway/test_profiles_configure_model_guard.py @@ -21,7 +21,7 @@ from pathlib import Path from types import SimpleNamespace import pytest -import yaml +import hermes_yaml as yaml import hermes_cli.model_selection_guards as guards import tui_gateway.server as srv diff --git a/tests/tui_gateway/test_slash_worker_mcp_discovery.py b/tests/tui_gateway/test_slash_worker_mcp_discovery.py index 5050eb5a15..959257ac77 100644 --- a/tests/tui_gateway/test_slash_worker_mcp_discovery.py +++ b/tests/tui_gateway/test_slash_worker_mcp_discovery.py @@ -12,7 +12,7 @@ import textwrap import threading import pytest -import yaml +import hermes_yaml as yaml _mcp_server_mod = pytest.importorskip("mcp.server") diff --git a/tests/tui_gateway/test_stale_provider_resume_live.py b/tests/tui_gateway/test_stale_provider_resume_live.py index 4b947f6002..ddfdb8173d 100644 --- a/tests/tui_gateway/test_stale_provider_resume_live.py +++ b/tests/tui_gateway/test_stale_provider_resume_live.py @@ -32,7 +32,7 @@ import uuid from pathlib import Path import pytest -import yaml +import hermes_yaml as yaml OLD_URL = "https://old-endpoint.invalid/v1" NEW_URL = "https://new-endpoint.invalid/v1" diff --git a/tests/website/test_extract_plugins.py b/tests/website/test_extract_plugins.py index c531ae0ff7..726207f0ed 100644 --- a/tests/website/test_extract_plugins.py +++ b/tests/website/test_extract_plugins.py @@ -35,7 +35,7 @@ def mod(): def _write_entry(catalog_dir: Path, name: str, **overrides) -> Path: - import yaml + import hermes_yaml as yaml entry = { "name": name, diff --git a/tools/approval.py b/tools/approval.py index 503ab01d8f..b160673307 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -334,7 +334,7 @@ def load_permanent_allowlist() -> set: legacy = isinstance(raw, str) if legacy: # Old config-set versions serialized list values as scalar strings. - import yaml + import hermes_yaml as yaml try: raw = yaml.safe_load(raw) except yaml.YAMLError: diff --git a/tools/blueprints.py b/tools/blueprints.py index 2c5997b3ed..39ae437990 100644 --- a/tools/blueprints.py +++ b/tools/blueprints.py @@ -47,7 +47,7 @@ def _split_frontmatter(text: str) -> Optional[Dict[str, Any]]: if not stripped.startswith("---") or (end := stripped.find("\n---", 3)) == -1: return None try: - import yaml + import hermes_yaml as yaml data = yaml.safe_load(stripped[3:end]) except Exception as e: # pragma: no cover - malformed YAML @@ -161,7 +161,7 @@ def export_blueprint(job: Dict[str, Any], body: str, *, blueprint_name: Optional """Inverse of ``create_blueprint_job``: render a cron job as a SKILL.md (with a ``metadata.hermes.blueprint`` block) ready for ``hermes skills publish``. ``body`` becomes the SKILL.md body; its first line is the description.""" - import yaml + import hermes_yaml as yaml # Sanitize to a valid skill identifier. name = str(blueprint_name or job.get("name") or "shared-blueprint").lower() diff --git a/tools/bot_mode_probe.py b/tools/bot_mode_probe.py index 1dac59cd1f..8aa8fa6e1d 100644 --- a/tools/bot_mode_probe.py +++ b/tools/bot_mode_probe.py @@ -84,7 +84,7 @@ def _read_yaml_dict(path: Path, needle: str | None = None) -> dict | None: raw = path.read_text(encoding="utf-8-sig", errors="replace") if needle is not None and needle not in raw: return None - import yaml + import hermes_yaml as yaml data = yaml.safe_load(raw) return data if isinstance(data, dict) else None diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 87a9c267ff..204f3b2ac0 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -100,7 +100,7 @@ def _manifest_is_mode_independent(path: str) -> bool: mode. Unreadable / unparseable -> False (forwarding one would turn a working session into a hard startup failure; bounded forwards unconditionally anyway).""" try: - import yaml + import hermes_yaml as yaml with open(path, "r", encoding="utf-8-sig") as handle: parsed = yaml.safe_load(handle) diff --git a/tools/file_operations_lint.py b/tools/file_operations_lint.py index edc4e4e1f1..fb5d23da8a 100644 --- a/tools/file_operations_lint.py +++ b/tools/file_operations_lint.py @@ -68,18 +68,19 @@ def _lint_json_inproc(content: str) -> tuple[bool, str]: def _lint_yaml_inproc(content: str) -> tuple[bool, str]: - """In-process YAML syntax check; ``__SKIP__`` when PyYAML is missing. Syntax-only - (``yaml.parse``), NOT ``safe_load``: loading rejects valid multi-doc streams and + """In-process YAML syntax check; ``__SKIP__`` when ruamel.yaml is missing. Syntax-only + (``YAML.parse``), NOT ``safe_load``: loading rejects valid multi-doc streams and app tags (``!Sub``, ``!vault``), and this is a fail-closed WRITE gate.""" try: - import yaml as _yaml + from ruamel.yaml import YAML + from ruamel.yaml.error import YAMLError except ImportError: return True, "__SKIP__" try: - for _event in _yaml.parse(content): + for _event in YAML(typ="safe").parse(content): pass return True, "" - except _yaml.YAMLError as e: + except YAMLError as e: return False, f"YAMLError: {e}" except Exception as e: # noqa: BLE001 return False, f"{type(e).__name__}: {e}" diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index c445cf18f9..c996c74492 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -17,7 +17,7 @@ import threading from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -import yaml +import hermes_yaml as yaml from hermes_constants import get_hermes_home, display_hermes_home from utils import atomic_write_text, is_truthy_value diff --git a/tools/skills_hub.py b/tools/skills_hub.py index a6469f7282..733f058e86 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -339,7 +339,7 @@ from urllib.parse import unquote # noqa: F401,E402 from urllib.parse import urlparse # noqa: F401,E402 from urllib.parse import urlsplit # noqa: F401,E402 from urllib.parse import urlunparse # noqa: F401,E402 -import yaml # noqa: F401,E402 +import hermes_yaml as yaml # noqa: F401,E402 _PLUGIN_COMPAT_LAZY = { diff --git a/tools/skills_hub_models.py b/tools/skills_hub_models.py index e8184b3d78..68cf8fe5d8 100644 --- a/tools/skills_hub_models.py +++ b/tools/skills_hub_models.py @@ -11,7 +11,7 @@ from typing import Any, Callable, Dict, Iterable, List, Optional, Union from urllib.parse import unquote, urlsplit import httpx -import yaml +import hermes_yaml as yaml logger = logging.getLogger("tools.skills_hub") diff --git a/tools/website_policy.py b/tools/website_policy.py index 0c464b0a3c..4cce65e5b5 100644 --- a/tools/website_policy.py +++ b/tools/website_policy.py @@ -69,9 +69,9 @@ def _load_policy_config(config_path: Path) -> Dict[str, Any]: if not config_path.exists(): return dict(_DEFAULT_WEBSITE_BLOCKLIST) try: - import yaml + import hermes_yaml as yaml except ImportError: - logger.debug("PyYAML not installed — website blocklist disabled") + logger.debug("ruamel.yaml not installed — website blocklist disabled") return dict(_DEFAULT_WEBSITE_BLOCKLIST) try: config = yaml.safe_load(config_path.read_text(encoding="utf-8-sig")) or {} diff --git a/trajectory_compressor.py b/trajectory_compressor.py index 19892bb80f..5a4cc84633 100644 --- a/trajectory_compressor.py +++ b/trajectory_compressor.py @@ -17,7 +17,7 @@ import random import shutil import tempfile import time -import yaml +import hermes_yaml as yaml import logging import asyncio from pathlib import Path diff --git a/tui_gateway/methods_profiles.py b/tui_gateway/methods_profiles.py index f9ec13cf53..1b6d72931b 100644 --- a/tui_gateway/methods_profiles.py +++ b/tui_gateway/methods_profiles.py @@ -81,7 +81,7 @@ def _resolve_profile(rid, params): def _read_profile_yaml(profile_dir) -> dict: """profile.yaml as a mapping; ``{}`` when missing, unreadable, unparseable, or not a mapping.""" def load(): - import yaml + import hermes_yaml as yaml meta_path = profile_dir / "profile.yaml" return (yaml.safe_load(meta_path.read_text(encoding="utf-8")) or {}) if meta_path.is_file() else {} loaded = _try(load, {}) diff --git a/utils.py b/utils.py index ed9e35f37a..abf4e7b259 100644 --- a/utils.py +++ b/utils.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import Any, Union from urllib.parse import urlparse -import yaml +import hermes_yaml as yaml logger = logging.getLogger(__name__) @@ -244,31 +244,13 @@ def warn_if_credential_file_broadly_readable(path: Union[str, Path], *, label: s return True -class IndentDumper(yaml.SafeDumper): - """PyYAML dumper that indents list items under mapping keys (2-space). - - PyYAML emits "indentless" sequences while ruamel (:func:`atomic_roundtrip_yaml_update`) - indents them; mixing both in one ``config.yaml`` makes stricter parsers like ``js-yaml`` - reject it, so every write path is forced to the same shape. - - Forcing ``indentless=False`` aligns the two serializers so all write paths emit byte-identical layouts - (#31999). - """ - - def increase_indent(self, flow=False, indentless=False): # noqa: ARG002 - return super().increase_indent(flow, False) - - def atomic_yaml_write(path: Union[str, Path], data: Any, *, default_flow_style: bool = False, sort_keys: bool = False, extra_content: str | None = None, create_mode: "int | None" = None) -> None: """Write YAML to *path* atomically (temp file + fsync + replace).""" path = Path(path) def _write(f) -> None: - # allow_unicode=True writes emoji/kaomoji as real UTF-8. Without it PyYAML emits astral - # chars as `\UXXXXXXXX` escapes inside `\`-continued double-quoted strings — a structure - # stricter parsers and hand-edits routinely break into unclosed quotes, corrupting the config. - yaml.dump(data, f, Dumper=IndentDumper, default_flow_style=default_flow_style, sort_keys=sort_keys, allow_unicode=True) + yaml.safe_dump(data, f, default_flow_style=default_flow_style, sort_keys=sort_keys) if extra_content: f.write(extra_content) @@ -278,14 +260,9 @@ def atomic_yaml_write(path: Union[str, Path], data: Any, *, default_flow_style: def _roundtrip_load(path: Path): """``(yaml_rt, CommentedMap)``: a ruamel round-trip loader keeping quotes/Unicode with 2-space indents, plus *path* loaded through it (empty map when missing/blank).""" - from ruamel.yaml import YAML from ruamel.yaml.comments import CommentedMap - yaml_rt = YAML(typ="rt") - yaml_rt.preserve_quotes = True - yaml_rt.allow_unicode = True - yaml_rt.default_flow_style = False - yaml_rt.indent(mapping=2, sequence=4, offset=2) + yaml_rt = yaml.roundtrip_yaml() data = yaml_rt.load(path.read_text(encoding="utf-8")) if path.exists() else None return yaml_rt, data if isinstance(data, CommentedMap) else CommentedMap(data or {}) @@ -328,13 +305,6 @@ def atomic_roundtrip_yaml_update(path: Union[str, Path], key_path: str, value: A _roundtrip_dump(path, yaml_rt, config) -# ruamel's round-trip dumper resolves plain scalars under YAML 1.2, where only true/false/null are -# reserved — so a str like "off" or "yes" is emitted unquoted. Every other config reader here -# (PyYAML, yaml.safe_load sites) parses under YAML 1.1, where on/off/yes/no are booleans: an -# unquoted ``approvals.mode: off`` would silently round-trip back as ``False``. -_YAML11_AMBIGUOUS_WORDS = frozenset({"y", "n", "yes", "no", "true", "false", "on", "off", "null", "~"}) - - def atomic_roundtrip_yaml_save(path: Union[str, Path], new_state: dict) -> None: """Persist a full config-state dict while preserving comments and ordering. @@ -343,7 +313,7 @@ def atomic_roundtrip_yaml_save(path: Union[str, Path], new_state: dict) -> None: readable Unicode survive. """ from ruamel.yaml.comments import CommentedMap - from ruamel.yaml.scalarstring import DoubleQuotedScalarString + from hermes_cli.config import require_readable_config_before_write path = Path(path) @@ -359,8 +329,6 @@ def atomic_roundtrip_yaml_save(path: Union[str, Path], new_state: dict) -> None: current = CommentedMap() dst[key] = current _merge(current, value) - elif isinstance(value, str) and value.lower() in _YAML11_AMBIGUOUS_WORDS: - dst[key] = DoubleQuotedScalarString(value) else: dst[key] = value # Keys missing from src are deleted: ``cfg.pop("custom_prompt")`` then save must remove @@ -380,15 +348,9 @@ def safe_json_loads(text: str, default: Any = None) -> Any: return default -# libyaml's CSafeLoader is ~8x faster than the pure-Python SafeLoader and a true drop-in for -# ``safe_load`` (same restricted tag set); startup parses config.yaml and every plugin manifest, -# so the slow path cost ~0.9 s of cold start. -_fast_yaml_loader = getattr(yaml, "CSafeLoader", None) or yaml.SafeLoader - - def fast_safe_load(stream: Any) -> Any: - """``yaml.safe_load`` (same inputs, same result) using the libyaml C loader when available.""" - return yaml.load(stream, Loader=_fast_yaml_loader) + """Use the shared safe reader (which selects ruamel's C parser when available).""" + return yaml.safe_load(stream) def _env_number(key: str, default, cast): diff --git a/uv.lock b/uv.lock index 708a7e8567..69bd20e0c1 100644 --- a/uv.lock +++ b/uv.lock @@ -1593,7 +1593,6 @@ dependencies = [ { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, - { name = "pyyaml" }, { name = "requests" }, { name = "rich" }, { name = "ruamel-yaml" }, @@ -1970,7 +1969,6 @@ requires-dist = [ { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.8" }, { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=306,<312" }, { name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=3.0.5,<4" }, - { name = "pyyaml", specifier = "==6.0.3" }, { name = "qrcode", marker = "extra == 'dingtalk'", specifier = "==7.4.2" }, { name = "qrcode", marker = "extra == 'feishu'", specifier = "==7.4.2" }, { name = "qrcode", marker = "extra == 'messaging'", specifier = "==7.4.2" }, diff --git a/website/docs/developer-guide/plugins/index.md b/website/docs/developer-guide/plugins/index.md index 51ab6d7a35..15be810ee9 100644 --- a/website/docs/developer-guide/plugins/index.md +++ b/website/docs/developer-guide/plugins/index.md @@ -666,12 +666,13 @@ Put any files in your plugin directory and read them at import time: ```python # In tools.py or __init__.py from pathlib import Path +from ruamel.yaml import YAML _PLUGIN_DIR = Path(__file__).parent _DATA_FILE = _PLUGIN_DIR / "data" / "languages.yaml" with open(_DATA_FILE) as f: - _DATA = yaml.safe_load(f) + _DATA = YAML(typ="safe").load(f) ``` That's for files you *ship*. State you *write* is different — see the next diff --git a/website/docs/reference/package-management.md b/website/docs/reference/package-management.md index 0f26a26b1b..fa179e3ab1 100644 --- a/website/docs/reference/package-management.md +++ b/website/docs/reference/package-management.md @@ -16,6 +16,7 @@ Each file has a separate role: | File | Role | |---|---| | `pm/lock.json` | Exact managed-tool versions, target-specific URLs, and SHA-256 hashes. | +| `pm/pyproject.toml` and `pm/uv.lock` | The dependency manager's independent Python requirements and locked resolution. | | `pyproject.toml` and `uv.lock` | Python requirements, extras, platform markers, and the committed Python resolution. | | Tool-store `facts.json` | Installed tool entries, their identities, environment exports, and realized-file digests. | | Per-install `facts.json` | The selected Python environment, its input stamp, and enabled extras. | @@ -169,9 +170,28 @@ installation work: shell configuration, launchers, `.env`, and bundled skills. Run the setup script separately if you want that full installation workflow. The bootstrap uses uv to install and locate Python, then waits for uv to exit. -That Python runs PM directly. PM can then replace its uv entry without a running -bootstrap process holding the old executable. PM writes failure receipts without -PyYAML, including when dependency installation fails. +PM prepares its own small, locked Python environment before reading plugin +configuration or resolving application dependencies. Its project is deliberately +independent of the application workspace: a broken application dependency must +not prevent its dependency manager from starting. Each uv subprocess exits before +PM runs, so it cannot hold the uv executable that PM needs to replace. + +PM's runtime contains `ruamel.yaml`, `packaging`, and `tomli-w`, not the application +dependency tree. CLI commands and application-requested installs and repairs run +there; read-only path and installed-environment lookups remain local. PM never +adds its dependencies to an already-running agent's imports. First-party YAML +readers and writers use ruamel; third-party packages can still require PyYAML in +the application environment. Failure receipts remain stdlib-only. + +When lazy installs are disabled, an existing PM runtime can still check whether +the application environment is current. If PM itself is missing or outdated, +the request fails without downloading tools or dependencies. Run an explicit +`hermes pm install` to prepare PM first. + +Native bundles and Docker images stage this same PM lock through the shared +runtime builder. Termux supplies its verified offline wheelhouse to that +builder. Nix builds the PM lock as a separate derivation. Packaged workers use +only their recorded PM dependency directory, never the application's libraries. ### Activate an existing installation diff --git a/website/docs/user-guide/skills/bundled/software-development/software-development-hermes-agent-skill-authoring.md b/website/docs/user-guide/skills/bundled/software-development/software-development-hermes-agent-skill-authoring.md index c1d6299dfa..605b208e30 100644 --- a/website/docs/user-guide/skills/bundled/software-development/software-development-hermes-agent-skill-authoring.md +++ b/website/docs/user-guide/skills/bundled/software-development/software-development-hermes-agent-skill-authoring.md @@ -176,11 +176,12 @@ A skill exists to make the agent's process more predictable — the agent reliab 3. **Draft** with `write_file` to `skills///SKILL.md` (or `optional-skills/...`). 4. **Validate locally**: ```python - import yaml, re, pathlib + import re, pathlib + from ruamel.yaml import YAML content = pathlib.Path("skills///SKILL.md").read_text() assert content.startswith("---") m = re.search(r'\n---\s*\n', content[3:]) - fm = yaml.safe_load(content[3:m.start()+3]) + fm = YAML(typ="safe").load(content[3:m.start()+3]) assert "name" in fm and "description" in fm assert len(fm["description"]) <= 60, f"description {len(fm['description'])} chars — hardline is 60" assert fm["description"].endswith(".") diff --git a/website/docs/user-guide/skills/optional/migration/migration-openclaw-migration.md b/website/docs/user-guide/skills/optional/migration/migration-openclaw-migration.md index 43519fcac5..b8b0c2ccb9 100644 --- a/website/docs/user-guide/skills/optional/migration/migration-openclaw-migration.md +++ b/website/docs/user-guide/skills/optional/migration/migration-openclaw-migration.md @@ -244,6 +244,9 @@ The helper script still supports category-level `--include` / `--exclude`, but t ## Commands +Run the helper with Hermes' Python environment, which includes `ruamel.yaml`. +For a standalone Python environment, install `ruamel.yaml==0.18.17` first. + Dry run with full discovery: ```bash diff --git a/website/docs/user-guide/skills/optional/security/security-godmode.md b/website/docs/user-guide/skills/optional/security/security-godmode.md index 07eb995afb..12c6f6e26f 100644 --- a/website/docs/user-guide/skills/optional/security/security-godmode.md +++ b/website/docs/user-guide/skills/optional/security/security-godmode.md @@ -73,6 +73,9 @@ See `scripts/godmode_race.py` for the implementation. ## Step 0: Auto-Jailbreak (Recommended) +The helper requires `ruamel.yaml` (included with Hermes). In a standalone +Python environment, install `ruamel.yaml==0.18.17` before loading it. + The fastest path — auto-detect the model, test strategies, and lock in the winner: ```python diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md index b8d4e1dbad..039295d27a 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md @@ -370,12 +370,13 @@ hermes logs --level WARNING | grep -i plugin ```python # In tools.py or __init__.py from pathlib import Path +from ruamel.yaml import YAML _PLUGIN_DIR = Path(__file__).parent _DATA_FILE = _PLUGIN_DIR / "data" / "languages.yaml" with open(_DATA_FILE) as f: - _DATA = yaml.safe_load(f) + _DATA = YAML(typ="safe").load(f) ``` ### 捆绑技能 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index aeb25edee8..b92eca6522 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -698,10 +698,10 @@ mintty / git-bash 行为相同(Alt+Enter 全屏),除非你在选项 → ### 测试/贡献 -**`scripts/run_tests.sh` 在 Windows 上无法直接使用** — 它查找 POSIX venv 布局(`.venv/bin/activate`)。Hermes 安装的 venv 位于 `venv/Scripts/`,也没有 pip 或 pytest(为减小安装体积而精简)。解决方案:将 `pytest + pyyaml` 安装到系统 Python 3.11 用户站点,然后设置 `PYTHONPATH` 直接调用 pytest: +**`scripts/run_tests.sh` 在 Windows 上无法直接使用** — 它查找 POSIX venv 布局(`.venv/bin/activate`)。Hermes 安装的 venv 位于 `venv/Scripts/`,也没有 pip 或 pytest(为减小安装体积而精简)。解决方案:将 `pytest + ruamel.yaml` 安装到系统 Python 3.11 用户站点,然后设置 `PYTHONPATH` 直接调用 pytest: ```bash -"/c/Program Files/Python311/python" -m pip install --user pytest pyyaml +"/c/Program Files/Python311/python" -m pip install --user pytest ruamel.yaml==0.18.17 export PYTHONPATH="$(pwd)" "/c/Program Files/Python311/python" -m pytest tests/foo/test_bar.py -v --tb=short ``` @@ -891,7 +891,7 @@ python -m pytest tests/tools/ -q # 特定区域 - 推送任何变更前运行完整套件 - 使用 `-o 'addopts='` 清除任何内置的 pytest 标志 -**Windows 贡献者:** `scripts/run_tests.sh` 目前查找 POSIX venv(`.venv/bin/activate` / `venv/bin/activate`),在 Windows 上会报错,因为布局是 `venv/Scripts/activate` + `python.exe`。Hermes 安装的 venv 位于 `venv/Scripts/`,也没有 `pip` 或 `pytest`——为终端用户安装体积而精简。解决方案:将 pytest + pyyaml 安装到系统 Python 3.11 用户站点(`/c/Program Files/Python311/python -m pip install --user pytest pyyaml`),然后直接运行测试: +**Windows 贡献者:** `scripts/run_tests.sh` 目前查找 POSIX venv(`.venv/bin/activate` / `venv/bin/activate`),在 Windows 上会报错,因为布局是 `venv/Scripts/activate` + `python.exe`。Hermes 安装的 venv 位于 `venv/Scripts/`,也没有 `pip` 或 `pytest`——为终端用户安装体积而精简。解决方案:将 pytest + ruamel.yaml 安装到系统 Python 3.11 用户站点(`/c/Program Files/Python311/python -m pip install --user pytest ruamel.yaml==0.18.17`),然后直接运行测试: ```bash export PYTHONPATH="$(pwd)" diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-hermes-agent-skill-authoring.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-hermes-agent-skill-authoring.md index 3b0feb61e6..f8e438730a 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-hermes-agent-skill-authoring.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-hermes-agent-skill-authoring.md @@ -131,11 +131,12 @@ skills///SKILL.md 3. **起草**,使用 `write_file` 写入 `skills///SKILL.md`。 4. **本地验证**: ```python - import yaml, re, pathlib + import re, pathlib + from ruamel.yaml import YAML content = pathlib.Path("skills///SKILL.md").read_text() assert content.startswith("---") m = re.search(r'\n---\s*\n', content[3:]) - fm = yaml.safe_load(content[3:m.start()+3]) + fm = YAML(typ="safe").load(content[3:m.start()+3]) assert "name" in fm and "description" in fm assert len(fm["description"]) <= 1024 assert len(content) <= 100_000 diff --git a/website/scripts/extract-plugins.py b/website/scripts/extract-plugins.py index 7bed3fabad..c598137e37 100644 --- a/website/scripts/extract-plugins.py +++ b/website/scripts/extract-plugins.py @@ -33,9 +33,10 @@ from collections import Counter from datetime import datetime, timezone from pathlib import Path -import yaml - REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) +import hermes_yaml as yaml + DEFAULT_CATALOG_DIR = REPO_ROOT / "plugin-catalog" DEFAULT_OUTPUT_DIR = REPO_ROOT / "website" / "static" / "api" diff --git a/website/scripts/extract-skills.py b/website/scripts/extract-skills.py index a463d1e258..1a0ef30e88 100644 --- a/website/scripts/extract-skills.py +++ b/website/scripts/extract-skills.py @@ -20,12 +20,14 @@ the unified index existed). import json import os +import sys from collections import Counter from datetime import datetime, timezone -import yaml - REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, REPO_ROOT) +import hermes_yaml as yaml + LOCAL_SKILL_DIRS = [ ("skills", "built-in"), ("optional-skills", "optional"), diff --git a/website/scripts/generate-skill-docs.py b/website/scripts/generate-skill-docs.py index 7e10e858b6..fe801526e2 100755 --- a/website/scripts/generate-skill-docs.py +++ b/website/scripts/generate-skill-docs.py @@ -14,13 +14,15 @@ Sidebar is updated to nest all per-skill pages under Skills → Bundled / Option from __future__ import annotations import re +import sys from collections import defaultdict from pathlib import Path from typing import Any -import yaml - REPO = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(REPO)) +import hermes_yaml as yaml + DOCS = REPO / "website" / "docs" SKILLS_PAGES = DOCS / "user-guide" / "skills" diff --git a/website/scripts/prebuild.mjs b/website/scripts/prebuild.mjs index 48b258b053..7e699c1781 100644 --- a/website/scripts/prebuild.mjs +++ b/website/scripts/prebuild.mjs @@ -16,7 +16,7 @@ // several minutes and burns GitHub API quota — but still gets the same // 2000+ external skills the deployed site has. // -// If python3 or its deps (pyyaml) aren't available on the local machine, we +// If python3 or its deps (ruamel.yaml) aren't available on the local machine, we // fall back to writing an empty skills.json so `npm run build` still // succeeds — the Skills Hub page just shows an empty state, and llms.txt // generation is skipped. CI always has the deps installed, so production @@ -48,7 +48,7 @@ function writeEmptyFallback(reason) { writeFileSync(outputFile, "[]\n"); console.warn( `[prebuild] extract-skills.py skipped (${reason}); wrote empty skills.json. ` + - `Install python3 + pyyaml locally for a populated Skills Hub page.`, + `Install python3 + ruamel.yaml locally for a populated Skills Hub page.`, ); }