From fc02b1c2766e4b52c30d5fb2353aa29ea81cfa7c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 13 Jun 2026 06:52:15 -0700 Subject: [PATCH] refactor(cli): simplify safe-mode startup wiring Since safe mode already landed on main via #45488, reduce this branch to cleanup: centralize env setup, remove duplicated comments, and tighten tests. --- hermes_cli/main.py | 24 ++-- hermes_cli/plugins.py | 4 - tests/hermes_cli/test_safe_mode.py | 178 +++++++++++++++-------------- tools/mcp_tool.py | 3 +- 4 files changed, 103 insertions(+), 106 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 6697435786b..e08246f1682 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2217,6 +2217,8 @@ def cmd_chat(args): """Run interactive chat CLI.""" use_tui = _resolve_use_tui(args) + _apply_safe_mode(args) + # Resolve --continue into --resume with the latest session or by name continue_val = getattr(args, "continue_last", None) if continue_val and not getattr(args, "resume", None): @@ -2327,18 +2329,6 @@ def cmd_chat(args): if getattr(args, "yolo", False): os.environ["HERMES_YOLO_MODE"] = "1" - # --safe-mode: troubleshooting mode that disables ALL customizations. - # Inspired by Claude Code v2.1.169's --safe-mode (June 2026): run with a - # pristine environment to isolate whether a problem comes from the user's - # setup (config, rules files, plugins, MCP servers) or from Hermes itself. - # Implemented as a superset of --ignore-user-config + --ignore-rules plus - # plugin/MCP discovery suppression (HERMES_SAFE_MODE is checked by - # hermes_cli/plugins.py and tools/mcp_tool.py). - if getattr(args, "safe_mode", False): - os.environ["HERMES_SAFE_MODE"] = "1" - os.environ["HERMES_IGNORE_USER_CONFIG"] = "1" - os.environ["HERMES_IGNORE_RULES"] = "1" - # --ignore-user-config: make load_cli_config() / load_config() skip the # user's ~/.hermes/config.yaml and return built-in defaults. Set BEFORE # importing cli (which runs `CLI_CONFIG = load_cli_config()` at module @@ -12351,6 +12341,8 @@ def _should_background_mcp_startup(args) -> bool: def _prepare_agent_startup(args) -> None: """Discover plugins/MCP/hooks for commands that can run an agent turn.""" + _apply_safe_mode(args) + _sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None)) if not ( args.command in _AGENT_COMMANDS @@ -12416,6 +12408,14 @@ def _prepare_agent_startup(args) -> None: ) +def _apply_safe_mode(args) -> None: + if not getattr(args, "safe_mode", False): + return + os.environ["HERMES_SAFE_MODE"] = "1" + os.environ["HERMES_IGNORE_USER_CONFIG"] = "1" + os.environ["HERMES_IGNORE_RULES"] = "1" + + def _set_chat_arg_defaults(args) -> None: for attr, default in [ ("query", None), diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index b95e8e3eeff..452eb6b7941 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -1283,10 +1283,6 @@ class PluginManager: """ if self._discovered and not force: return - # Safe mode (--safe-mode / HERMES_SAFE_MODE=1): troubleshooting run - # with all customizations disabled. Skip plugin discovery entirely so - # no third-party code (hooks, tools, platforms) loads. Mark as - # discovered so callers see a clean empty registry, not a retry loop. if env_var_enabled("HERMES_SAFE_MODE"): logger.info("HERMES_SAFE_MODE=1 — plugin discovery skipped") self._discovered = True diff --git a/tests/hermes_cli/test_safe_mode.py b/tests/hermes_cli/test_safe_mode.py index 2c52786facb..6ab0cf533fa 100644 --- a/tests/hermes_cli/test_safe_mode.py +++ b/tests/hermes_cli/test_safe_mode.py @@ -1,18 +1,10 @@ -"""Tests for `hermes chat --safe-mode` — pristine troubleshooting runs. - -Inspired by Claude Code v2.1.169's ``--safe-mode`` flag (June 2026), which -disables all customizations (CLAUDE.md, plugins, skills, hooks, MCP) for -troubleshooting. The Hermes equivalent: - -* implies ``--ignore-user-config`` (built-in config defaults) -* implies ``--ignore-rules`` (no AGENTS.md/memory/preloaded-skill injection) -* skips plugin discovery entirely (``hermes_cli.plugins``) -* loads zero MCP servers (``tools.mcp_tool._load_mcp_config``) -""" +"""Tests for `hermes chat --safe-mode` isolation.""" from __future__ import annotations import os +import sys +import types import pytest @@ -29,102 +21,112 @@ def _clean_env(monkeypatch): os.environ.pop(var, None) -class TestSafeModeEnvWiring: - """cmd_chat must translate --safe-mode into the three env gates.""" +def test_cmd_chat_safe_mode_sets_env_before_startup(monkeypatch): + import hermes_cli.main as main_mod + from hermes_cli._parser import build_top_level_parser - def test_safe_mode_sets_all_gates(self): - # Mirrors the cmd_chat logic in hermes_cli/main.py. - class Args: - safe_mode = True + parser, _subparsers, chat_parser = build_top_level_parser() + chat_parser.set_defaults(func=main_mod.cmd_chat) + args = parser.parse_args(["chat", "--safe-mode"]) + captured: dict[str, object] = {} + fake_cli = types.ModuleType("cli") - args = Args() - if getattr(args, "safe_mode", False): - os.environ["HERMES_SAFE_MODE"] = "1" - os.environ["HERMES_IGNORE_USER_CONFIG"] = "1" - os.environ["HERMES_IGNORE_RULES"] = "1" + def fake_has_provider() -> bool: + assert os.environ["HERMES_SAFE_MODE"] == "1" + assert os.environ["HERMES_IGNORE_USER_CONFIG"] == "1" + assert os.environ["HERMES_IGNORE_RULES"] == "1" + return True - assert os.environ.get("HERMES_SAFE_MODE") == "1" - assert os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1" - assert os.environ.get("HERMES_IGNORE_RULES") == "1" + def fake_main(**kwargs): + captured.update(kwargs) + + monkeypatch.setattr(main_mod, "_has_any_provider_configured", fake_has_provider) + monkeypatch.setattr(main_mod, "_pin_kanban_board_env", lambda: None) + monkeypatch.setattr(main_mod, "_sync_bundled_skills_for_startup", lambda: None) + monkeypatch.setattr(main_mod, "_termux_should_prefetch_update_check", lambda: False) + setattr(fake_cli, "main", fake_main) + monkeypatch.setitem(sys.modules, "cli", fake_cli) + + main_mod.cmd_chat(args) + + assert captured["ignore_user_config"] is True + assert captured["ignore_rules"] is True -class TestSafeModePluginDiscovery: - """Plugin discovery must be a no-op under HERMES_SAFE_MODE=1.""" +def test_prepare_agent_startup_applies_safe_mode_before_plugin_discovery(monkeypatch): + import hermes_cli.main as main_mod - def test_discovery_skipped(self, monkeypatch): - monkeypatch.setenv("HERMES_SAFE_MODE", "1") - from hermes_cli.plugins import PluginManager + args = types.SimpleNamespace(command="chat", safe_mode=True, tui=False) + plugins = types.ModuleType("hermes_cli.plugins") - mgr = PluginManager() - called = [] - monkeypatch.setattr( - mgr, "_discover_and_load_inner", lambda: called.append(True) - ) - mgr.discover_and_load() - assert called == [] # inner sweep never ran - assert mgr._discovered is True # registry settled as clean-empty - assert mgr._plugins == {} + def discover_plugins() -> None: + assert os.environ["HERMES_SAFE_MODE"] == "1" + assert os.environ["HERMES_IGNORE_USER_CONFIG"] == "1" + assert os.environ["HERMES_IGNORE_RULES"] == "1" - def test_discovery_runs_without_safe_mode(self, monkeypatch): - monkeypatch.delenv("HERMES_SAFE_MODE", raising=False) - from hermes_cli.plugins import PluginManager + setattr(plugins, "discover_plugins", discover_plugins) + monkeypatch.setitem(sys.modules, "hermes_cli.plugins", plugins) + monkeypatch.setattr(main_mod, "_should_background_mcp_startup", lambda _args: False) + monkeypatch.setattr(main_mod, "_command_has_dedicated_mcp_startup", lambda _args: True) - mgr = PluginManager() - called = [] - monkeypatch.setattr( - mgr, "_discover_and_load_inner", lambda: called.append(True) - ) - mgr.discover_and_load() - assert called == [True] + main_mod._prepare_agent_startup(args) -class TestSafeModeMCP: - """_load_mcp_config must return no servers under HERMES_SAFE_MODE=1.""" +def test_plugin_discovery_skipped(monkeypatch): + monkeypatch.setenv("HERMES_SAFE_MODE", "1") + from hermes_cli.plugins import PluginManager - def test_mcp_servers_empty(self, monkeypatch): - monkeypatch.setenv("HERMES_SAFE_MODE", "1") - from tools.mcp_tool import _load_mcp_config + mgr = PluginManager() + called = [] + monkeypatch.setattr(mgr, "_discover_and_load_inner", lambda: called.append(True)) - with pytest.MonkeyPatch.context() as mp: - mp.setattr( - "hermes_cli.config.load_config", - lambda: {"mcp_servers": {"github": {"url": "https://example.com/mcp"}}}, - ) - assert _load_mcp_config() == {} + mgr.discover_and_load() - def test_mcp_servers_load_without_safe_mode(self, monkeypatch): - monkeypatch.delenv("HERMES_SAFE_MODE", raising=False) - from tools.mcp_tool import _load_mcp_config - - with pytest.MonkeyPatch.context() as mp: - mp.setattr( - "hermes_cli.config.load_config", - lambda: {"mcp_servers": {"github": {"url": "https://example.com/mcp"}}}, - ) - servers = _load_mcp_config() - assert "github" in servers + assert called == [] + assert mgr._discovered is True + assert mgr._plugins == {} -class TestSafeModeParser: - """--safe-mode must parse on both the root parser and `hermes chat`.""" +def test_plugin_discovery_runs_without_safe_mode(monkeypatch): + from hermes_cli.plugins import PluginManager - def test_chat_subcommand_accepts_flag(self): - from hermes_cli._parser import build_top_level_parser + mgr = PluginManager() + called = [] + monkeypatch.setattr(mgr, "_discover_and_load_inner", lambda: called.append(True)) - parser, _subparsers, _chat = build_top_level_parser() - args = parser.parse_args(["chat", "--safe-mode"]) - assert getattr(args, "safe_mode", False) is True + mgr.discover_and_load() - def test_root_parser_accepts_flag(self): - from hermes_cli._parser import build_top_level_parser + assert called == [True] - parser, _subparsers, _chat = build_top_level_parser() - args = parser.parse_args(["--safe-mode"]) - assert getattr(args, "safe_mode", False) is True - def test_default_is_off(self): - from hermes_cli._parser import build_top_level_parser +def test_mcp_servers_empty(monkeypatch): + monkeypatch.setenv("HERMES_SAFE_MODE", "1") + from tools.mcp_tool import _load_mcp_config - parser, _subparsers, _chat = build_top_level_parser() - args = parser.parse_args(["chat"]) - assert getattr(args, "safe_mode", False) is False + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"mcp_servers": {"github": {"url": "https://example.com/mcp"}}}, + ) + + assert _load_mcp_config() == {} + + +def test_mcp_servers_load_without_safe_mode(monkeypatch): + from tools.mcp_tool import _load_mcp_config + + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"mcp_servers": {"github": {"url": "https://example.com/mcp"}}}, + ) + + assert "github" in _load_mcp_config() + + +def test_parser_accepts_safe_mode_on_root_and_chat(): + from hermes_cli._parser import build_top_level_parser + + parser, _subparsers, _chat = build_top_level_parser() + + assert parser.parse_args(["--safe-mode"]).safe_mode is True + assert parser.parse_args(["chat", "--safe-mode"]).safe_mode is True + assert parser.parse_args(["chat"]).safe_mode is False diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 71c8a635555..329ddebdd73 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -3531,9 +3531,8 @@ def _load_mcp_config() -> Dict[str, dict]: """ try: from hermes_cli.config import load_config - # Safe mode (--safe-mode / HERMES_SAFE_MODE=1): troubleshooting run - # with all customizations disabled — no MCP servers connect. from utils import env_var_enabled as _env_enabled + if _env_enabled("HERMES_SAFE_MODE"): return {} config = load_config()