Files
hermes-agent/tests/hermes_cli/test_stale_pid_guard.py
ethernet 890bbbda1f Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts
#	apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts
#	apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts
#	apps/desktop/e2e/bot-mode-roster-localized.spec.ts
#	apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts
#	apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts
#	apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts
#	apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts
#	apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts
#	apps/desktop/e2e/bot-roster-user-sections.spec.ts
#	apps/desktop/e2e/bot-routines-pane-narrow.spec.ts
#	apps/desktop/e2e/bot-row-open-recent-session.spec.ts
#	apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts
#	apps/desktop/e2e/group-composer-auto-grow.spec.ts
#	apps/desktop/e2e/group-create-gate-remote-roster.spec.ts
#	apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts
#	apps/desktop/e2e/hosted-room-backend-continuity.spec.ts
#	apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts
#	apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts
#	apps/desktop/e2e/worktree-branch-status.spec.ts
#	apps/desktop/electron/backend-probes.test.ts
#	apps/desktop/electron/connection-apply.test.ts
#	apps/desktop/electron/desktop-electron-pin.test.ts
#	apps/desktop/electron/desktop-uninstall.test.ts
#	apps/desktop/electron/gateway-file-download-transport.test.ts
#	apps/desktop/electron/gateway-stop-before-update.test.ts
#	apps/desktop/electron/github-api-auth.test.ts
#	apps/desktop/electron/registry-primary-profile-scope.test.ts
#	apps/desktop/electron/update-api-check.test.ts
#	apps/desktop/electron/update-handoff-marker.test.ts
#	apps/desktop/electron/venv-blocker-scan.test.ts
#	apps/desktop/scripts/after-extract.test.mjs
#	apps/desktop/scripts/local-pack-publish.test.mjs
#	apps/desktop/scripts/tasks-scroll.test.mjs
#	apps/desktop/src/app/settings/model-settings.test.tsx
#	apps/desktop/src/app/updates-overlay.blockers.test.tsx
#	apps/desktop/src/components/desktop-install-overlay.test.tsx
#	apps/desktop/src/lib/update-copy.test.ts
#	scripts/ci/check_os_marker_fakes.py
#	tests-js/desktop-mac-usage-descriptions.test.ts
#	tests-js/node-engine-alignment.test.ts
#	tests/agent/lsp/test_install_and_lint_fixes.py
#	tests/agent/test_command_token_source.py
#	tests/agent/test_compression_boundary_hook.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/agent/test_custom_provider_ca_probes.py
#	tests/agent/test_endpoint_blackhole.py
#	tests/agent/test_estimator_parity.py
#	tests/agent/test_in_place_compaction.py
#	tests/agent/test_moa_loop_mode.py
#	tests/agent/test_model_metadata.py
#	tests/agent/test_skill_session_platform_gate.py
#	tests/agent/test_skill_utils.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/computer_use/test_doctor.py
#	tests/cron/test_codex_execution_paths.py
#	tests/cron/test_cron_bot_chat_delivery.py
#	tests/cron/test_cron_script.py
#	tests/cron/test_media_delivery_parity.py
#	tests/cron/test_misfire_catchup.py
#	tests/cron/test_parallel_pool.py
#	tests/cron/test_recurring_eagain_redispatch.py
#	tests/gateway/test_choice_picker.py
#	tests/gateway/test_control_socket_windows_live.py
#	tests/gateway/test_dingtalk.py
#	tests/gateway/test_feishu.py
#	tests/gateway/test_feishu_onboard.py
#	tests/gateway/test_gateway_shutdown.py
#	tests/gateway/test_matrix.py
#	tests/gateway/test_model_command_custom_providers.py
#	tests/gateway/test_reasoning_command.py
#	tests/gateway/test_runtime_footer.py
#	tests/gateway/test_session.py
#	tests/gateway/test_session_hygiene.py
#	tests/gateway/test_status.py
#	tests/gateway/test_teams.py
#	tests/gateway/test_turn_lease.py
#	tests/gateway/test_whatsapp_connect.py
#	tests/hermes_cli/test_approvals_command.py
#	tests/hermes_cli/test_auth_store_lock_concurrent.py
#	tests/hermes_cli/test_backup.py
#	tests/hermes_cli/test_banner_git_state.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_compat_manifest_targets.py
#	tests/hermes_cli/test_computer_use_cli.py
#	tests/hermes_cli/test_cpr_local_leak.py
#	tests/hermes_cli/test_dashboard_auth_gate.py
#	tests/hermes_cli/test_dashboard_procs_kill_grace.py
#	tests/hermes_cli/test_desktop_lifecycle_windows_live.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_command_install.py
#	tests/hermes_cli/test_fleet_config_migration_windows_live.py
#	tests/hermes_cli/test_gateway.py
#	tests/hermes_cli/test_gateway_platform_gating.py
#	tests/hermes_cli/test_gateway_restart_loop.py
#	tests/hermes_cli/test_gateway_task_probe.py
#	tests/hermes_cli/test_gateway_wsl.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_install_cua_driver.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_command_exports.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_linux_desktop_entry.py
#	tests/hermes_cli/test_local_runtime.py
#	tests/hermes_cli/test_local_runtime_updates.py
#	tests/hermes_cli/test_managed_uv.py
#	tests/hermes_cli/test_mcp_reload_confirm_gate.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_npm_engine.py
#	tests/hermes_cli/test_personality_none.py
#	tests/hermes_cli/test_pet_toggle.py
#	tests/hermes_cli/test_plan_reconciliation_windows_live.py
#	tests/hermes_cli/test_plugin_event_bus.py
#	tests/hermes_cli/test_plugin_manifest_v2.py
#	tests/hermes_cli/test_plugin_packs.py
#	tests/hermes_cli/test_plugins_cmd.py
#	tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py
#	tests/hermes_cli/test_process_identity.py
#	tests/hermes_cli/test_profiles.py
#	tests/hermes_cli/test_profiles_sidebar_cache.py
#	tests/hermes_cli/test_pty_bridge.py
#	tests/hermes_cli/test_resolve_turn_limit.py
#	tests/hermes_cli/test_serve_runtime_inventory.py
#	tests/hermes_cli/test_session_vacuum_config.py
#	tests/hermes_cli/test_set_config_value.py
#	tests/hermes_cli/test_signal_handler_kanban_worker.py
#	tests/hermes_cli/test_slash_confirm_windows.py
#	tests/hermes_cli/test_stale_pid_guard.py
#	tests/hermes_cli/test_startup_fast_guards.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_telegram_managed_bot.py
#	tests/hermes_cli/test_tools_config.py
#	tests/hermes_cli/test_update_apply_shallow_count.py
#	tests/hermes_cli/test_update_autostash.py
#	tests/hermes_cli/test_update_concurrent_quarantine.py
#	tests/hermes_cli/test_update_fetch_failure_classifier.py
#	tests/hermes_cli/test_update_fleet_probe_resume_token.py
#	tests/hermes_cli/test_update_handoff_backend_reap.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_host_obligation.py
#	tests/hermes_cli/test_update_import_guard.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_update_inventory.py
#	tests/hermes_cli/test_update_launchd_unloaded_gateway.py
#	tests/hermes_cli/test_update_missing_configured_deps.py
#	tests/hermes_cli/test_update_modified_notice.py
#	tests/hermes_cli/test_update_multiplex_migration_hook.py
#	tests/hermes_cli/test_update_no_gateway_restart.py
#	tests/hermes_cli/test_update_orphan_backend_reap.py
#	tests/hermes_cli/test_update_parked_branch_guard.py
#	tests/hermes_cli/test_update_post_pull_syntax_guard.py
#	tests/hermes_cli/test_update_receipt.py
#	tests/hermes_cli/test_update_self_lock.py
#	tests/hermes_cli/test_update_shim_fail_closed.py
#	tests/hermes_cli/test_update_shim_self_lock.py
#	tests/hermes_cli/test_update_sqlite_remediation.py
#	tests/hermes_cli/test_update_stale_dashboard.py
#	tests/hermes_cli/test_update_stale_virtualenv.py
#	tests/hermes_cli/test_update_venv_health.py
#	tests/hermes_cli/test_update_venv_ownership_preflight.py
#	tests/hermes_cli/test_update_wedged_gateway.py
#	tests/hermes_cli/test_update_yes_flag.py
#	tests/hermes_cli/test_update_zip_two_phase.py
#	tests/hermes_cli/test_urllib_security.py
#	tests/hermes_cli/test_ux_messages_auth_config.py
#	tests/hermes_cli/test_ux_messages_startup.py
#	tests/hermes_cli/test_venv_holder_classifier.py
#	tests/hermes_cli/test_verify_console_scripts.py
#	tests/hermes_cli/test_verify_core_dependencies.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_server_console_ws.py
#	tests/hermes_cli/test_web_server_ws_ping.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/hermes_state/test_fts_rebuild_admission.py
#	tests/hermes_state/test_hermes_state.py
#	tests/plugins/memory/test_memory_lazy_install.py
#	tests/plugins/test_google_meet_plugin.py
#	tests/plugins/test_langfuse_plugin.py
#	tests/plugins/test_security_guidance_plugin.py
#	tests/plugins/test_transform_llm_output_hook.py
#	tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_contributor_map.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/skills/test_competitor_news_monitor_skill.py
#	tests/skills/test_document_to_action_items_skill.py
#	tests/skills/test_google_workspace_setup.py
#	tests/skills/test_google_workspace_setup_deps.py
#	tests/skills/test_grounded_citations_skill.py
#	tests/skills/test_ip_as_logo_skill.py
#	tests/skills/test_live_dashboard_skill.py
#	tests/skills/test_mcp_oauth_remote_gateway_skill.py
#	tests/skills/test_office_document_skills.py
#	tests/skills/test_openclaw_migration.py
#	tests/skills/test_product_price_monitor_skill.py
#	tests/skills/test_scrollcraft_skill.py
#	tests/skills/test_setup_wizard_generator_skill.py
#	tests/skills/test_weekly_review_planning_skill.py
#	tests/test_engines_satisfiable.py
#	tests/test_fast_safe_load.py
#	tests/test_hermes_bootstrap.py
#	tests/test_hermes_constants.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_model_tools_async_bridge.py
#	tests/test_packaging_build_guard.py
#	tests/test_packaging_metadata.py
#	tests/test_yaml_indent_consistency.py
#	tests/tools/test_approval_timeout_overflow.py
#	tests/tools/test_base_environment.py
#	tests/tools/test_bot_mode_dm.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_hardening.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_clipboard.py
#	tests/tools/test_code_execution.py
#	tests/tools/test_code_execution_modes.py
#	tests/tools/test_code_execution_windows_env.py
#	tests/tools/test_computer_use.py
#	tests/tools/test_delegate_liveness_timeout.py
#	tests/tools/test_execute_code_approval_cluster.py
#	tests/tools/test_execution_flag_detection.py
#	tests/tools/test_fal_common.py
#	tests/tools/test_file_operations.py
#	tests/tools/test_file_tools.py
#	tests/tools/test_file_tools_cwd_resolution.py
#	tests/tools/test_file_tools_live.py
#	tests/tools/test_lazy_deps.py
#	tests/tools/test_lazy_deps_durable_target.py
#	tests/tools/test_lazy_deps_managed.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_local_tempdir.py
#	tests/tools/test_macos_protected_search.py
#	tests/tools/test_mcp_npx_cached_bin.py
#	tests/tools/test_oneshot_completion_linger.py
#	tests/tools/test_process_registry.py
#	tests/tools/test_read_file_schema_gating.py
#	tests/tools/test_skill_improvements.py
#	tests/tools/test_skills_sync.py
#	tests/tools/test_termux_api_detection.py
#	tests/tools/test_tirith_security.py
#	tests/tools/test_transcription_tools.py
#	tests/tools/test_tts_streaming.py
#	tests/tools/test_wake_word.py
#	tests/tui_gateway/test_compute_host_borrowed_lease.py
#	tests/tui_gateway/test_compute_host_turn_protocol.py
#	tests/tui_gateway/test_isolated_orphan_activity.py
#	tests/tui_gateway/test_protocol.py
#	tests/tui_gateway/test_slash_worker_profile_home.py
#	tests/tui_gateway/test_subprocess_encoding.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	ui-tui/src/__tests__/terminalParity.test.ts
#	ui-tui/src/__tests__/termuxComposerLayout.test.ts
#	ui-tui/src/__tests__/textInputFastEcho.test.ts
2026-09-23 07:02:44 -04:00

268 lines
13 KiB
Python

# -*- coding: utf-8 -*-
"""Regression tests for the fail-closed PID-ownership guard.
Refs #90471 / #89614. The shared Windows ``taskkill`` boundaries:
- ``hermes_cli/_subprocess_compat.pid_is_hermes`` / ``kill_process_tree``
- ``hermes_cli/dashboard_procs._kill_stale_dashboard_processes`` (win32)
Acceptance from #90471:
1. missing / unreadable / non-matching identity fails closed -> no taskkill
2. a recycled or foreign PID control process remains untouched
3. probe failure or timeout is never converted into permission to kill
"""
from pathlib import Path
from unittest import mock
import pytest
from hermes_cli import _subprocess_compat
from hermes_cli import dashboard_procs
def _probe_stdout(value: str) -> mock.Mock:
return mock.Mock(stdout=value)
class TestPidIsHermes:
"""The shared identity probe must fail closed on every ambiguity."""
def test_non_windows_is_unconditional_pass(self):
# Non-Windows callers have no taskkill path at all.
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", False):
assert _subprocess_compat.pid_is_hermes(1234) is True
def test_non_windows_still_rejects_recycled_identity(self):
# An explicit fingerprint mismatch is a recycled PID on any platform.
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", False), mock.patch.object(
_subprocess_compat, "_process_start_time", return_value=456
):
assert _subprocess_compat.pid_is_hermes(
1234, expected_start_time=123
) is False
def test_hermes_match_requires_token_boundary(self):
# "hermes" buried inside an unrelated path segment must not match.
assert _subprocess_compat._text_names_hermes(
r"c:\users\shermesa\app.exe"
) is False
assert _subprocess_compat._text_names_hermes(
r"C:\Users\x\.hermes-runtime\python.exe -m hermes_cli.main"
) is True
assert _subprocess_compat._text_names_hermes(
"/opt/hermes-agent/venv/bin/python"
) is True
def test_invalid_pid_inputs_do_not_crash(self):
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", True):
assert _subprocess_compat.pid_is_hermes(-1) is False
assert _subprocess_compat.pid_is_hermes(0) is False
assert _subprocess_compat.pid_is_hermes("not-a-pid") is False
assert _subprocess_compat.pid_is_hermes(True) is False
def test_probe_matches_hermes_like_process(self):
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", True), mock.patch.object(
_subprocess_compat, "_process_start_time", return_value=123
), mock.patch.object(
_subprocess_compat, "_process_command_is_hermes", return_value=True
):
assert _subprocess_compat.pid_is_hermes(1234) is True
def test_probe_rejects_recycled_process_identity(self):
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", True), mock.patch.object(
_subprocess_compat, "_process_start_time", return_value=456
), mock.patch.object(
_subprocess_compat, "_process_command_is_hermes", return_value=True
):
assert _subprocess_compat.pid_is_hermes(
1234, expected_start_time=123
) is False
def test_probe_rejects_foreign_process(self):
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", True), mock.patch.object(
_subprocess_compat, "_process_start_time", return_value=123
), mock.patch.object(
_subprocess_compat, "_process_command_is_hermes", return_value=False
):
assert _subprocess_compat.pid_is_hermes(1234) is False
def test_probe_blank_stdout_fails_closed(self):
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", True), mock.patch.object(
_subprocess_compat, "_process_start_time", return_value=None
):
assert _subprocess_compat.pid_is_hermes(1234) is False
def test_probe_oserror_fails_closed(self):
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", True), mock.patch.object(
_subprocess_compat, "_process_start_time", side_effect=OSError("broken pipe")
):
assert _subprocess_compat.pid_is_hermes(1234) is False
@pytest.mark.platforms("windows") # real probe is windows-only
def test_missing_pid_real_probe_fails_closed(self):
# A PID that cannot exist must never be judged Hermes-owned.
assert _subprocess_compat.pid_is_hermes(2**24) is False
class TestKillProcessTree:
"""kill_process_tree operates on our own retained Popen handle.
A retained handle pins the PID (the child cannot be reaped while the
handle is open), so PID recycling is impossible there and the identity
guard deliberately does NOT apply — it could only false-refuse a
legitimate cleanup. These tests pin that contract for the legacy
Windows fallback path.
"""
def _proc(self, pid=4321):
return mock.Mock(pid=pid)
def test_retained_handle_is_taskkilled_without_probe(self):
with mock.patch.object(_subprocess_compat, "IS_WINDOWS", True), mock.patch.object(
_subprocess_compat, "pid_is_hermes"
) as guard, mock.patch.object(_subprocess_compat.subprocess, "run") as run:
_subprocess_compat._legacy_kill_process_tree(self._proc())
guard.assert_not_called()
run.assert_called_once()
argv = run.call_args.args[0]
assert argv[0] == "taskkill"
assert "/PID" in argv
assert str(4321) in argv
# taskkill dispatch must execute on Windows, not under a fake sys.platform.
@pytest.mark.platforms("windows")
class TestKillStaleDashboardProcesses:
"""dashboard_procs win32 kill branch guard behaviour."""
def _patch_find(self, pids=(12345,)):
from hermes_cli import main_dashboard
return mock.patch.object(main_dashboard, "_find_stale_dashboard_pids", return_value=list(pids))
def test_foreign_pid_reported_not_killed(self):
with self._patch_find(), mock.patch(
"gateway.status.get_process_start_time", return_value=123
), mock.patch(
"hermes_cli._subprocess_compat.pid_is_hermes", return_value=False
), mock.patch.object(dashboard_procs.subprocess, "run") as run:
result = dashboard_procs._kill_stale_dashboard_processes()
assert result["killed"] == []
assert result["failed"] == [
(12345, "not hermes-owned or process identity changed")
]
run.assert_not_called()
def test_hermes_pid_killed(self):
with self._patch_find(), mock.patch(
"gateway.status.get_process_start_time", return_value=123
), mock.patch(
"hermes_cli._subprocess_compat.pid_is_hermes", return_value=True
), mock.patch.object(
dashboard_procs.subprocess, "run", return_value=mock.Mock(
returncode=0, stderr="", stdout=""
)
) as run:
result = dashboard_procs._kill_stale_dashboard_processes()
taskkill_calls = [c for c in run.call_args_list if c.args[0][0] == "taskkill"]
assert len(taskkill_calls) == 1
assert result["killed"] == [12345]
assert result["failed"] == []
# The POSIX kill path (the Windows class above is host-gated on taskkill).
@pytest.mark.platforms("posix")
def test_stop_only_targets_the_invoking_hermes_home(monkeypatch):
"""An argv match from another profile is never a ``--stop`` target."""
own_home = "/tmp/hermes-own"
foreign_home = "/tmp/hermes-foreign"
monkeypatch.setenv("HERMES_HOME", own_home)
with mock.patch.object(
dashboard_procs, "_scan_dashboard_processes",
return_value=[(12345, "hermes serve"), (12346, "hermes serve"), (12347, "hermes serve")],
), mock.patch.object(dashboard_procs, "_caller_ancestor_pids", return_value=set()), mock.patch.object(
dashboard_procs, "_hermes_home_for_pid",
side_effect=lambda pid: {
12345: own_home,
12346: foreign_home,
12347: None,
}[pid],
), mock.patch.object(
dashboard_procs, "_kill_pids_posix"
) as kill:
result = dashboard_procs._kill_stale_dashboard_processes(scope_home=own_home)
kill.assert_called_once()
assert kill.call_args.args[0] == [12345]
assert result["matched"] == [12345]
class TestHermesHomeForPid:
"""Tri-state owner resolution: a readable environment always names a home."""
@pytest.mark.platforms("posix") # POSIX default home is $HOME/.hermes
def test_readable_env_without_var_resolves_to_that_process_default_home(self, monkeypatch, tmp_path):
"""The common install shape exports no HERMES_HOME: the backend lives in its user's
platform default home, and a default-home ``--stop`` must still find it (#113978)."""
home = str(tmp_path / "alice")
monkeypatch.setattr(dashboard_procs, "_pid_environ", lambda pid: {"HOME": home})
from hermes_cli import main_dashboard
monkeypatch.setattr(main_dashboard, "_dashboard_cmdline_for_pid",
lambda pid: ["hermes", "--profile", "work", "serve"] if pid == 2 else ["hermes", "serve"])
assert dashboard_procs._hermes_home_for_pid(1) == f"{home}/.hermes"
# ``-p``/``--profile`` is applied to os.environ after exec, invisible in /proc environ.
assert dashboard_procs._hermes_home_for_pid(2) == f"{home}/.hermes/profiles/work"
assert dashboard_procs._pids_owned_by_hermes_home([1, 2], f"{home}/.hermes") == [1]
# REGRESSION (#116906): a systemd/launchd unit with a scrubbed environment exports no HOME.
# The target resolves its own default home from the password database, so attributing it to
# the INSPECTING process's home named another user's directory — and `hermes update` /
# `--stop` then acted on the wrong profile root.
def _posix_scrubbed_unit(self, monkeypatch, tmp_path, passwd_home):
"""A unit whose environment carries neither HOME nor HERMES_HOME, on the POSIX branch."""
monkeypatch.setattr(dashboard_procs.sys, "platform", "linux")
monkeypatch.setattr(dashboard_procs, "_pid_environ", lambda pid: {})
monkeypatch.setattr(dashboard_procs, "_pid_passwd_home", lambda pid: passwd_home)
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path / "inspecting-user"))
from hermes_cli import main_dashboard
monkeypatch.setattr(main_dashboard, "_dashboard_cmdline_for_pid", lambda pid: ["hermes", "serve"])
def test_scrubbed_unit_env_resolves_to_the_owners_passwd_home(self, monkeypatch, tmp_path):
service_home = tmp_path / "hermes-service"
self._posix_scrubbed_unit(monkeypatch, tmp_path, str(service_home))
assert Path(dashboard_procs._hermes_home_for_pid(1)) == service_home / ".hermes"
def test_unreadable_passwd_entry_keeps_the_existing_fallback(self, monkeypatch, tmp_path):
"""No owner, no entry: the previous behaviour stands rather than resolving to nothing."""
self._posix_scrubbed_unit(monkeypatch, tmp_path, None)
assert Path(dashboard_procs._hermes_home_for_pid(1)) == tmp_path / "inspecting-user" / ".hermes"
def test_root_shaped_hermes_home_follows_the_flag_and_the_sticky_active_profile(self, monkeypatch, tmp_path):
"""Mirror ``_apply_profile_override``: an exported root ``HERMES_HOME`` is the root, not the
home — ``-p work`` and ``hermes profile use work`` both land in ``<root>/profiles/work``."""
root = tmp_path / ".hermes"
root.mkdir()
monkeypatch.setattr(dashboard_procs, "_pid_environ",
lambda pid: {"HOME": str(tmp_path), "HERMES_HOME": str(root)})
from hermes_cli import main_dashboard
monkeypatch.setattr(main_dashboard, "_dashboard_cmdline_for_pid",
lambda pid: ["hermes", "-p", "work", "serve"] if pid == 2 else ["hermes", "serve"])
assert dashboard_procs._hermes_home_for_pid(2) == str(root / "profiles" / "work")
assert dashboard_procs._hermes_home_for_pid(1) == str(root) # no flag, no active_profile
(root / "active_profile").write_text("work", encoding="utf-8")
assert dashboard_procs._hermes_home_for_pid(1) == str(root / "profiles" / "work")
# A profile-shaped HERMES_HOME without a flag is the home itself (root = its grandparent).
monkeypatch.setattr(dashboard_procs, "_pid_environ",
lambda pid: {"HERMES_HOME": str(root / "profiles" / "ops")})
assert dashboard_procs._hermes_home_for_pid(1) == str(root / "profiles" / "ops")
def test_unreadable_env_is_none_and_spared(self, monkeypatch):
monkeypatch.setattr(dashboard_procs, "_pid_environ", lambda pid: None)
assert dashboard_procs._hermes_home_for_pid(7) is None
assert dashboard_procs._pids_owned_by_hermes_home([7], "/home/alice/.hermes") == []