# 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
635 lines
26 KiB
Python
635 lines
26 KiB
Python
"""Tests for /update gateway slash command.
|
|
|
|
Tests both the _handle_update_command handler (spawns update process) and
|
|
the _send_update_notification startup hook (sends results after restart).
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock, AsyncMock
|
|
|
|
import pytest
|
|
|
|
from gateway.config import Platform
|
|
from gateway.platforms.event import MessageEvent
|
|
from gateway.session import SessionSource
|
|
|
|
|
|
def _make_event(text="/update", platform=Platform.TELEGRAM,
|
|
user_id="12345", chat_id="67890", thread_id=None):
|
|
"""Build a MessageEvent for testing."""
|
|
source = SessionSource(
|
|
platform=platform,
|
|
user_id=user_id,
|
|
chat_id=chat_id,
|
|
user_name="testuser",
|
|
thread_id=thread_id,
|
|
)
|
|
return MessageEvent(text=text, source=source)
|
|
|
|
|
|
def _make_runner():
|
|
"""Create a bare GatewayRunner without calling __init__."""
|
|
from gateway.run import GatewayRunner
|
|
runner = object.__new__(GatewayRunner)
|
|
runner.adapters = {}
|
|
runner._voice_mode = {}
|
|
runner._update_prompt_pending = {}
|
|
return runner
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _handle_update_command
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHandleUpdateCommand:
|
|
"""Tests for GatewayRunner._handle_update_command."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_git_directory(self, tmp_path):
|
|
"""Returns an error when .git does not exist."""
|
|
runner = _make_runner()
|
|
event = _make_event()
|
|
# Point _hermes_home to tmp_path and project_root to a dir without .git
|
|
fake_root = tmp_path / "project"
|
|
fake_root.mkdir()
|
|
with patch("gateway.run._hermes_home", tmp_path), \
|
|
patch("gateway.run.Path") as MockPath:
|
|
# Path(__file__).parent.parent.resolve() -> fake_root
|
|
MockPath.return_value = MagicMock()
|
|
MockPath.__truediv__ = Path.__truediv__
|
|
# Easier: just patch the __file__ resolution in the method
|
|
pass
|
|
|
|
# Simpler approach — mock at method level using a wrapper
|
|
runner = _make_runner()
|
|
|
|
with patch("gateway.run._hermes_home", tmp_path):
|
|
# The handler does Path(__file__).parent.parent.resolve()
|
|
# We need to make project_root / '.git' not exist.
|
|
# Since Path(__file__) resolves to the real gateway/run.py,
|
|
# project_root will be the real hermes-agent dir (which HAS .git).
|
|
# Patch Path to control this.
|
|
original_path = Path
|
|
|
|
class FakePath(type(Path())):
|
|
pass
|
|
|
|
# Actually, simplest: just patch the specific file attr.
|
|
# The _handle_update_command handler lives in gateway/slash_commands.py
|
|
# (extracted from run.py in the god-file decomposition); it resolves
|
|
# project_root via Path(__file__).parent.parent, so fake that file.
|
|
fake_file = str(fake_root / "gateway" / "slash_commands.py")
|
|
(fake_root / "gateway").mkdir(parents=True)
|
|
(fake_root / "gateway" / "slash_commands.py").touch()
|
|
|
|
with patch("gateway.slash_commands.__file__", fake_file):
|
|
result = await runner._handle_update_command(event)
|
|
|
|
assert "Not a git repository" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_hermes_bin_module_argv(self):
|
|
"""_resolve_hermes_bin uses the running interpreter's module argv when hermes_cli is
|
|
importable, even when PATH also offers a ``hermes`` binary (#111569: a PATH-first
|
|
lookup would re-exec an attacker-planted executable on /update and /restart)."""
|
|
import sys
|
|
from gateway.run import _resolve_hermes_bin
|
|
|
|
fake_spec = MagicMock()
|
|
with patch("shutil.which", return_value="/tmp/attacker/hermes"), \
|
|
patch("importlib.util.find_spec", return_value=fake_spec):
|
|
result = _resolve_hermes_bin()
|
|
|
|
assert result == [sys.executable, "-m", "hermes_cli.main"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_hermes_bin_falls_back_to_path_then_none(self):
|
|
"""Without an importable hermes_cli the argv degrades to PATH, then to None — never a
|
|
bare ``hermes`` string that a hostile PATH entry could shadow."""
|
|
from gateway.run import _resolve_hermes_bin
|
|
|
|
with patch("shutil.which", return_value="/usr/local/bin/hermes"), \
|
|
patch("importlib.util.find_spec", return_value=None):
|
|
assert _resolve_hermes_bin() == ["/usr/local/bin/hermes"]
|
|
|
|
with patch("shutil.which", return_value=None), \
|
|
patch("importlib.util.find_spec", side_effect=ImportError):
|
|
assert _resolve_hermes_bin() is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_writes_pending_marker(self, tmp_path):
|
|
"""Writes .update_pending.json with correct platform and chat info."""
|
|
runner = _make_runner()
|
|
event = _make_event(platform=Platform.TELEGRAM, chat_id="99999")
|
|
event.message_id = "m-update"
|
|
|
|
fake_root = tmp_path / "project"
|
|
fake_root.mkdir()
|
|
(fake_root / ".git").mkdir()
|
|
(fake_root / "gateway").mkdir()
|
|
(fake_root / "gateway" / "run.py").touch()
|
|
fake_file = str(fake_root / "gateway" / "run.py")
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home), \
|
|
patch("gateway.run.__file__", fake_file), \
|
|
patch("hermes_cli.config.detect_install_method", return_value="git"), \
|
|
patch("shutil.which", side_effect=lambda x: "/usr/bin/hermes" if x == "hermes" else "/usr/bin/setsid"), \
|
|
patch("subprocess.Popen"):
|
|
result = await runner._handle_update_command(event)
|
|
|
|
pending_path = hermes_home / ".update_pending.json"
|
|
assert pending_path.exists()
|
|
data = json.loads(pending_path.read_text())
|
|
assert data["platform"] == "telegram"
|
|
assert data["chat_id"] == "99999"
|
|
assert data["chat_type"] == "dm"
|
|
assert data["message_id"] == "m-update"
|
|
assert "timestamp" in data
|
|
assert not (hermes_home / ".update_exit_code").exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.platforms("linux")
|
|
async def test_fallback_when_no_setsid(self, tmp_path):
|
|
"""Falls back to start_new_session=True when setsid is not available."""
|
|
runner = _make_runner()
|
|
event = _make_event()
|
|
|
|
fake_root = tmp_path / "project"
|
|
fake_root.mkdir()
|
|
(fake_root / ".git").mkdir()
|
|
(fake_root / "gateway").mkdir()
|
|
(fake_root / "gateway" / "run.py").touch()
|
|
fake_file = str(fake_root / "gateway" / "run.py")
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
mock_popen = MagicMock()
|
|
|
|
def which_no_setsid(x):
|
|
if x == "hermes":
|
|
return "/usr/bin/hermes"
|
|
if x == "setsid":
|
|
return None
|
|
return None
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home), \
|
|
patch("gateway.run.__file__", fake_file), \
|
|
patch("hermes_cli.config.detect_install_method", return_value="git"), \
|
|
patch("shutil.which", side_effect=which_no_setsid), \
|
|
patch("subprocess.Popen", mock_popen):
|
|
await runner._handle_update_command(event)
|
|
|
|
# Verify plain bash -c fallback (no nohup, no setsid)
|
|
call_args = mock_popen.call_args[0][0]
|
|
assert call_args[0] == "bash"
|
|
assert "nohup" not in call_args[2]
|
|
assert ".update_exit_code" in call_args[2]
|
|
# start_new_session=True should be in kwargs
|
|
call_kwargs = mock_popen.call_args[1]
|
|
assert call_kwargs.get("start_new_session") is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Platform allowlist gate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUpdateCommandPlatformGate:
|
|
"""Tests for the platform-allowlist gate at the top of
|
|
``_handle_update_command``. Built-in messaging platforms are listed in
|
|
``_UPDATE_ALLOWED_PLATFORMS``; plugin-migrated platforms (discord,
|
|
mattermost, teams, …) are NOT in the frozenset and rely on the
|
|
registry's ``allow_update_command=True`` fallback. Programmatic
|
|
interfaces (ACP, API server, webhooks) must be blocked.
|
|
"""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allows_plugin_platform_via_registry_fallback(self, monkeypatch):
|
|
"""A plugin-migrated platform (DISCORD) is no longer in
|
|
``_UPDATE_ALLOWED_PLATFORMS`` but must still pass the gate via
|
|
the registry's ``allow_update_command=True`` flag.
|
|
|
|
This test is the empirical guarantee that removing DISCORD from
|
|
the hardcoded frozenset does not regress the /update command for
|
|
Discord users.
|
|
"""
|
|
|
|
# Make sure the plugin registry is populated so the fallback fires.
|
|
from hermes_cli.plugins import PluginManager
|
|
PluginManager().discover_and_load(force=True)
|
|
from gateway.platform_registry import platform_registry
|
|
discord_entry = platform_registry.get("discord")
|
|
assert discord_entry is not None
|
|
assert discord_entry.allow_update_command is True
|
|
|
|
runner = _make_runner()
|
|
event = _make_event(platform=Platform.DISCORD)
|
|
monkeypatch.setenv("HERMES_MANAGED", "")
|
|
|
|
with patch("subprocess.Popen"):
|
|
result = await runner._handle_update_command(event)
|
|
|
|
# The gate must NOT have rejected us — anything other than the
|
|
# ``platform_not_messaging`` rejection string is acceptable here.
|
|
# Later steps may legitimately return success ("Starting Hermes
|
|
# update…") or fail for environment reasons.
|
|
assert "only available from messaging platforms" not in result
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _send_update_notification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSendUpdateNotification:
|
|
"""Tests for GatewayRunner._send_update_notification."""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defers_notification_while_update_still_running(self, tmp_path):
|
|
"""Returns False and keeps marker files when the update has not exited yet."""
|
|
runner = _make_runner()
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
pending_path = hermes_home / ".update_pending.json"
|
|
pending_path.write_text(json.dumps({
|
|
"platform": "telegram", "chat_id": "67890", "user_id": "12345",
|
|
}))
|
|
(hermes_home / ".update_output.txt").write_text("still running")
|
|
|
|
mock_adapter = AsyncMock()
|
|
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home):
|
|
result = await runner._send_update_notification()
|
|
|
|
assert result is False
|
|
mock_adapter.send.assert_not_called()
|
|
assert pending_path.exists()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_recovers_from_claimed_pending_file(self, tmp_path):
|
|
"""A claimed pending file from a crashed notifier is still deliverable."""
|
|
runner = _make_runner()
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
claimed_path = hermes_home / ".update_pending.claimed.json"
|
|
claimed_path.write_text(json.dumps({
|
|
"platform": "telegram", "chat_id": "67890", "user_id": "12345",
|
|
}))
|
|
(hermes_home / ".update_output.txt").write_text("done")
|
|
(hermes_home / ".update_exit_code").write_text("0")
|
|
|
|
mock_adapter = AsyncMock()
|
|
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home):
|
|
result = await runner._send_update_notification()
|
|
|
|
assert result is True
|
|
mock_adapter.send.assert_called_once()
|
|
assert not claimed_path.exists()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sends_notification_with_output(self, tmp_path):
|
|
"""Sends update output to the correct platform and chat."""
|
|
runner = _make_runner()
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
# Write pending marker
|
|
pending = {
|
|
"platform": "telegram",
|
|
"chat_id": "67890",
|
|
"user_id": "12345",
|
|
"timestamp": "2026-03-04T21:00:00",
|
|
}
|
|
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
|
|
(hermes_home / ".update_output.txt").write_text(
|
|
"→ Found 3 new commit(s)\n✓ Code updated!\n✓ Update complete!"
|
|
)
|
|
(hermes_home / ".update_exit_code").write_text("0")
|
|
|
|
# Mock the adapter
|
|
mock_adapter = AsyncMock()
|
|
mock_adapter.send = AsyncMock()
|
|
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home):
|
|
await runner._send_update_notification()
|
|
|
|
mock_adapter.send.assert_called_once()
|
|
call_args = mock_adapter.send.call_args
|
|
assert call_args[0][0] == "67890" # chat_id
|
|
assert "Update complete" in call_args[0][1] or "update finished" in call_args[0][1].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drops_stale_marker_when_the_platform_never_connects(self, tmp_path, caplog):
|
|
"""A marker past the wait cap is abandoned instead of deferred forever.
|
|
|
|
Regression: an update notice addressed to a platform that has no adapter —
|
|
and never will, because the platform is not configured at all — kept its
|
|
markers on disk and re-logged a deferred line on every poll. The startup
|
|
path reschedules the watcher for as long as the markers exist, so the
|
|
notice outlived every restart, in every process.
|
|
"""
|
|
runner = _make_runner()
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
pending_path = hermes_home / ".update_pending.json"
|
|
pending_path.write_text(json.dumps({
|
|
"platform": "telegram",
|
|
"chat_id": "67890",
|
|
"user_id": "12345",
|
|
"timestamp": (datetime.now() - timedelta(hours=2)).isoformat(),
|
|
}))
|
|
(hermes_home / ".update_exit_code").write_text("0")
|
|
# runner.adapters stays empty: no adapter for the target platform, ever.
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home):
|
|
result = await runner._send_update_notification()
|
|
|
|
# True is the definitive answer the startup caller keys off to stop rescheduling.
|
|
assert result is True
|
|
assert not pending_path.exists()
|
|
assert not (hermes_home / ".update_pending.claimed.json").exists()
|
|
assert not (hermes_home / ".update_output.txt").exists()
|
|
assert not (hermes_home / ".update_exit_code").exists()
|
|
assert any("adapter never connected" in r.getMessage() for r in caplog.records)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_keeps_waiting_for_a_recent_marker(self, tmp_path):
|
|
"""A recent marker is still held: the cap must not swallow its own notice.
|
|
|
|
Right after the update's restart the adapter is legitimately absent for a
|
|
while, which is the case the defer path exists to cover.
|
|
"""
|
|
runner = _make_runner()
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
pending_path = hermes_home / ".update_pending.json"
|
|
pending_path.write_text(json.dumps({
|
|
"platform": "telegram",
|
|
"chat_id": "67890",
|
|
"user_id": "12345",
|
|
"timestamp": (datetime.now() - timedelta(minutes=5)).isoformat(),
|
|
}))
|
|
(hermes_home / ".update_exit_code").write_text("0")
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home):
|
|
result = await runner._send_update_notification()
|
|
|
|
assert result is False
|
|
assert pending_path.exists(), "marker kept so a later poll can still deliver it"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleans_up_on_error(self, tmp_path):
|
|
"""Files are cleaned up even if notification fails."""
|
|
runner = _make_runner()
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
pending_path = hermes_home / ".update_pending.json"
|
|
output_path = hermes_home / ".update_output.txt"
|
|
exit_code_path = hermes_home / ".update_exit_code"
|
|
pending_path.write_text(json.dumps({
|
|
"platform": "telegram", "chat_id": "111", "user_id": "222",
|
|
}))
|
|
output_path.write_text("✓ Done")
|
|
exit_code_path.write_text("0")
|
|
|
|
# Adapter send raises
|
|
mock_adapter = AsyncMock()
|
|
mock_adapter.send.side_effect = RuntimeError("network error")
|
|
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home):
|
|
await runner._send_update_notification()
|
|
|
|
# Files should still be cleaned up (finally block)
|
|
assert not pending_path.exists()
|
|
assert not output_path.exists()
|
|
assert not exit_code_path.exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_adapter_for_platform_preserves_markers(self, tmp_path):
|
|
"""A finished update whose platform is offline keeps its markers.
|
|
|
|
When the target platform's adapter has not reconnected yet, dropping
|
|
the completion markers would silently lose the notification. Instead the
|
|
call defers (returns False) and leaves every marker on disk so a later
|
|
retry can deliver once the platform is back.
|
|
"""
|
|
runner = _make_runner()
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
pending = {"platform": "discord", "chat_id": "111", "user_id": "222"}
|
|
pending_path = hermes_home / ".update_pending.json"
|
|
output_path = hermes_home / ".update_output.txt"
|
|
exit_code_path = hermes_home / ".update_exit_code"
|
|
pending_path.write_text(json.dumps(pending))
|
|
output_path.write_text("Done")
|
|
exit_code_path.write_text("0")
|
|
|
|
# Only telegram adapter available, but pending says discord
|
|
mock_adapter = AsyncMock()
|
|
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home):
|
|
result = await runner._send_update_notification()
|
|
|
|
# No send (wrong platform offline) and the result is deferred.
|
|
assert result is False
|
|
mock_adapter.send.assert_not_called()
|
|
# Markers are preserved for a later retry — NOT cleaned up.
|
|
assert pending_path.exists()
|
|
assert output_path.exists()
|
|
assert exit_code_path.exists()
|
|
# The marker stays in its canonical pending location (claim restored).
|
|
assert not (hermes_home / ".update_pending.claimed.json").exists()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deferred_notification_delivers_after_reconnect(self, tmp_path):
|
|
"""A deferred completion is delivered once the platform reconnects.
|
|
|
|
Regression for the late-reconnect /update bug: the update finishes while
|
|
the target platform is offline, the markers survive the deferral, and
|
|
the next call (after the adapter is registered) delivers the result and
|
|
cleans up — exactly once.
|
|
"""
|
|
runner = _make_runner()
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
pending = {"platform": "discord", "chat_id": "111", "user_id": "222"}
|
|
pending_path = hermes_home / ".update_pending.json"
|
|
output_path = hermes_home / ".update_output.txt"
|
|
exit_code_path = hermes_home / ".update_exit_code"
|
|
pending_path.write_text(json.dumps(pending))
|
|
output_path.write_text("✓ Update complete!")
|
|
exit_code_path.write_text("0")
|
|
|
|
# First pass: target platform (discord) is still offline → defer.
|
|
with patch("gateway.run._hermes_home", hermes_home):
|
|
first = await runner._send_update_notification()
|
|
|
|
assert first is False
|
|
assert pending_path.exists()
|
|
|
|
# Platform reconnects: the reconnect watcher adds the adapter back.
|
|
mock_adapter = AsyncMock()
|
|
runner.adapters = {Platform.DISCORD: mock_adapter}
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home):
|
|
second = await runner._send_update_notification()
|
|
|
|
assert second is True
|
|
mock_adapter.send.assert_called_once()
|
|
sent_text = mock_adapter.send.call_args[0][1]
|
|
assert "Update complete" in sent_text
|
|
# Now everything is cleaned up — no duplicate deliveries possible.
|
|
assert not pending_path.exists()
|
|
assert not output_path.exists()
|
|
assert not exit_code_path.exists()
|
|
assert not (hermes_home / ".update_pending.claimed.json").exists()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_completion_notification_tolerates_invalid_utf8_output(self, tmp_path):
|
|
"""Completion-only update notifications must not crash on bad bytes."""
|
|
runner = _make_runner()
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
pending = {"platform": "discord", "chat_id": "111", "user_id": "222"}
|
|
pending_path = hermes_home / ".update_pending.json"
|
|
output_path = hermes_home / ".update_output.txt"
|
|
exit_code_path = hermes_home / ".update_exit_code"
|
|
pending_path.write_text(json.dumps(pending))
|
|
output_path.write_bytes(b"ok before\ninvalid byte: \x96\ncontinued after\n")
|
|
exit_code_path.write_text("0")
|
|
|
|
mock_adapter = AsyncMock()
|
|
runner.adapters = {Platform.DISCORD: mock_adapter}
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home):
|
|
delivered = await runner._send_update_notification()
|
|
|
|
assert delivered is True
|
|
mock_adapter.send.assert_called_once()
|
|
sent_text = mock_adapter.send.call_args[0][1]
|
|
assert "ok before" in sent_text
|
|
assert "invalid byte" in sent_text
|
|
assert "continued after" in sent_text
|
|
assert "Hermes update finished" in sent_text
|
|
assert not pending_path.exists()
|
|
assert not output_path.exists()
|
|
assert not exit_code_path.exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_update_notice_says_still_running_and_trims_log(self, tmp_path):
|
|
"""A failed update must tell the chat the old version still runs and where to see the
|
|
full error; the raw log is quoted only as a short tail, never the whole 3500-char dump."""
|
|
runner = _make_runner()
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
(hermes_home / ".update_pending.json").write_text(
|
|
json.dumps({"platform": "discord", "chat_id": "111", "user_id": "222"}))
|
|
(hermes_home / ".update_output.txt").write_text("x" * 3000 + "\nERROR: pip failed\n")
|
|
(hermes_home / ".update_exit_code").write_text("1")
|
|
mock_adapter = AsyncMock()
|
|
runner.adapters = {Platform.DISCORD: mock_adapter}
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home):
|
|
await runner._send_update_notification()
|
|
|
|
sent_text = mock_adapter.send.call_args[0][1]
|
|
assert "ERROR: pip failed" in sent_text
|
|
assert len(sent_text) < 1200
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /update in help and known_commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
class TestWatchUpdateProgress:
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_utf8_update_output_does_not_crash_watcher(self, tmp_path):
|
|
runner = _make_runner()
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
(hermes_home / ".update_pending.json").write_text(json.dumps({
|
|
"platform": "telegram",
|
|
"chat_id": "67890",
|
|
"user_id": "12345",
|
|
}))
|
|
(hermes_home / ".update_output.txt").write_bytes(
|
|
b"ok before\n\xe2\x9c invalid-continuation: \x96\ncontinued after\n"
|
|
)
|
|
(hermes_home / ".update_exit_code").write_text("0")
|
|
|
|
mock_adapter = AsyncMock()
|
|
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home):
|
|
await runner._watch_update_progress(poll_interval=0.01, stream_interval=0.01, timeout=1.0)
|
|
|
|
sent = "\n".join(call.args[1] for call in mock_adapter.send.call_args_list)
|
|
assert "ok before" in sent
|
|
assert "continued after" in sent
|
|
assert "Hermes update finished" in sent
|
|
assert not (hermes_home / ".update_pending.json").exists()
|
|
# ---------------------------------------------------------------------------
|
|
# Install-method refusal gate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUpdateCommandInstallMethodRefusal:
|
|
"""/update on a non-git install refuses with the steward's own update
|
|
command instead of attempting a git-based `hermes update`."""
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("method", ["docker", "nix"])
|
|
async def test_refuses_non_git_install(self, tmp_path, method):
|
|
runner = _make_runner()
|
|
event = _make_event()
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
mock_popen = MagicMock()
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home), \
|
|
patch("hermes_cli.config.detect_install_method",
|
|
return_value=method), \
|
|
patch("hermes_cli.config.recommended_update_command_for_method",
|
|
return_value=f"steward-update --{method}"), \
|
|
patch("subprocess.Popen", mock_popen):
|
|
result = await runner._handle_update_command(event)
|
|
|
|
assert f"does not apply to this install ({method})" in result
|
|
assert f"Update with: steward-update --{method}" in result
|
|
# No update attempt: nothing spawned, no pending marker written.
|
|
mock_popen.assert_not_called()
|
|
assert not (hermes_home / ".update_pending.json").exists()
|