From 633be45930f6042fa3fa63735716052b3de2cd9f Mon Sep 17 00:00:00 2001 From: Guilherme Cunha Rodrigues Date: Mon, 13 Jul 2026 11:12:24 -0300 Subject: [PATCH] fix(gateway): register declarative shell hooks in the Desktop/TUI backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shell hooks declared in config (`hooks:`) are registered onto the plugin manager by `agent.shell_hooks.register_from_config()`, and fired from the shared AIAgent turn path (`agent/turn_context.py` for `pre_llm_call`, `cli.py` for the session-finalize events). Registration was wired into three frontends — `cli.py`, `hermes_cli/main.py`, `gateway/run.py` — but NOT into `tui_gateway`, which is the only backend behind Hermes Desktop and the TUI (both the stdio `tui_gateway.entry` host and the `hermes serve` WS host build every agent through `_make_agent`). Consequence: in a Desktop/TUI session the entire `hooks:` block silently never runs. The firing sites are present in the process (turn_context, session lifecycle), so nothing errors — the callbacks were simply never attached. This is the same retrofit-one-frontend-at-a-time gap that #38945 fixed for MCP discovery on the WS path; `acp_adapter` has the same omission for the same reason. It is an oversight, not a security boundary: the desktop dashboard itself ships a shell-hook admin surface (`GET/POST /api/ops/hooks`), and the consent model (`_prompt_and_record` fails closed when stdin is not a TTY) was built precisely so a headless frontend can register. Registration goes in `_make_agent` — the single chokepoint both the stdio and WS hosts route through — rather than `tui_gateway/entry.py`, which is only the stdio transport (the Electron/dashboard path reaches the agent via `tui_gateway/ws.py`, never `entry.main()`; see #38945). It mirrors `gateway/run.py`: `accept_hooks=False` lets `register_from_config` resolve consent from `HERMES_ACCEPT_HOOKS` / `hooks_auto_accept` (no TTY here either), and a try/except logs but never blocks an agent build. The registration is gated on `get_hermes_home_override() is None` so only the launch profile binds the process's hooks. The plugin manager is a process-global singleton and its shell-hook callbacks carry no profile guard; in app-global remote mode one backend serves every local profile, and a non-launch profile builds here under `set_hermes_home_override(profile)`. Registering its config would append that profile's commands onto the shared manager and fire them on every other profile's turns — cross-profile command execution plus double context injection. The process's hooks belong to the launch profile alone. Tests: `test_make_agent_registers_configured_shell_hooks` asserts the Desktop backend's agent-build path registers a configured `pre_llm_call` hook onto a real plugin manager (RED before the fix: zero callbacks registered); `test_make_agent_skips_shell_hooks_for_non_launch_profile` asserts a build under an active HERMES_HOME override registers nothing, pinning the cross-profile guard. --- tests/test_tui_gateway_server.py | 133 +++++++++++++++++++++++++++++++ tui_gateway/server.py | 31 +++++++ 2 files changed, 164 insertions(+) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index b8b0bbd773221..aea5b5a82d978 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -9001,3 +9001,136 @@ def test_get_usage_clamps_post_compression_sentinel(): usage = server._get_usage(agent) assert "context_used" not in usage assert "context_percent" not in usage + + +def test_make_agent_registers_configured_shell_hooks(monkeypatch, tmp_path): + """Desktop/TUI backend must wire ``hooks:`` onto the plugin manager. + + ``pre_llm_call`` is fired from agent/turn_context.py by every AIAgent, + so a frontend that never calls ``register_from_config`` silently drops + every configured shell hook. ``_make_agent`` is the single agent-build + chokepoint shared by the stdio TUI host and the ``hermes serve`` WS host. + """ + from unittest.mock import MagicMock + + from agent import shell_hooks + from hermes_cli.plugins import get_plugin_manager + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("HERMES_SAFE_MODE", raising=False) + + hooks_cfg = { + "hooks_auto_accept": True, + "hooks": {"pre_llm_call": [{"command": "/bin/true", "timeout": 5}]}, + } + fake_runtime = { + "provider": "openrouter", + "base_url": "https://api.synthetic.new/v1", + "api_key": "sk-test", + "api_mode": "chat_completions", + "command": None, + "args": None, + "credential_pool": None, + } + + manager = get_plugin_manager() + before = list(manager._hooks.get("pre_llm_call", [])) + shell_hooks.reset_for_tests() + try: + with ( + patch("hermes_cli.config.load_config", return_value=hooks_cfg), + patch( + "tui_gateway.server._load_cfg", + return_value={"agent": {"system_prompt": ""}, + "model": {"default": "glm-5"}}, + ), + patch("tui_gateway.server._get_db", return_value=MagicMock()), + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value=fake_runtime, + ), + patch("run_agent.AIAgent"), + ): + server._make_agent("sid-hooks", "key-hooks") + + after = manager._hooks.get("pre_llm_call", []) + assert len(after) == len(before) + 1, ( + "no shell hook registered on the plugin manager by the Desktop " + "backend's agent-build path" + ) + finally: + manager._hooks["pre_llm_call"] = before + shell_hooks.reset_for_tests() + + +def test_make_agent_skips_shell_hooks_for_non_launch_profile(monkeypatch, tmp_path): + """A non-launch-profile build must NOT register its ``hooks:`` block. + + The plugin manager is a process-global singleton and its shell-hook + callbacks carry no profile guard, yet every AIAgent fires pre_llm_call + through it. In the desktop's app-global remote mode one backend serves + every local profile, building a non-launch profile's agent under + ``set_hermes_home_override(that profile)``. If ``_make_agent`` registered + THAT profile's hooks here they would leak onto the shared manager and fire + on every OTHER profile's turns — cross-profile command execution and double + context injection. Only the launch profile (no active override) owns the + process's hooks. This fails on the pre-gate code (the hook registers under + the override) and passes once registration is gated on no active override. + """ + from unittest.mock import MagicMock + + from agent import shell_hooks + from hermes_cli.plugins import get_plugin_manager + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("HERMES_SAFE_MODE", raising=False) + + hooks_cfg = { + "hooks_auto_accept": True, + "hooks": {"pre_llm_call": [{"command": "/bin/false", "timeout": 5}]}, + } + fake_runtime = { + "provider": "openrouter", + "base_url": "https://api.synthetic.new/v1", + "api_key": "sk-test", + "api_mode": "chat_completions", + "command": None, + "args": None, + "credential_pool": None, + } + + profile_home = tmp_path / "profiles" / "worker" + profile_home.mkdir(parents=True) + + manager = get_plugin_manager() + before = list(manager._hooks.get("pre_llm_call", [])) + shell_hooks.reset_for_tests() + # Simulate the app-global remote build path: another profile's HERMES_HOME + # is bound for the duration of the build (server._build does this at :1376). + home_token = set_hermes_home_override(str(profile_home)) + try: + with ( + patch("hermes_cli.config.load_config", return_value=hooks_cfg), + patch( + "tui_gateway.server._load_cfg", + return_value={"agent": {"system_prompt": ""}, + "model": {"default": "glm-5"}}, + ), + patch("tui_gateway.server._get_db", return_value=MagicMock()), + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value=fake_runtime, + ), + patch("run_agent.AIAgent"), + ): + server._make_agent("sid-hooks-b", "key-hooks-b") + + after = manager._hooks.get("pre_llm_call", []) + assert len(after) == len(before), ( + "a non-launch profile's shell hook leaked onto the process-global " + "plugin manager during an app-global remote agent build" + ) + finally: + reset_hermes_home_override(home_token) + manager._hooks["pre_llm_call"] = before + shell_hooks.reset_for_tests() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 8f72de91691b5..a2e3504c48290 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4537,6 +4537,37 @@ def _make_agent( except Exception: pass + # Register declarative shell hooks from config, but ONLY for the launch + # profile (no active HERMES_HOME override). Every AIAgent fires + # pre_llm_call from agent/turn_context.py, and the callbacks only exist if + # some frontend called register_from_config — this package is the only + # backend behind Hermes Desktop / the TUI (both the stdio entry host and + # the `hermes serve` WS host build every agent through _make_agent), so + # without this the user's whole `hooks:` block silently never runs there. + # + # The gate matters because the plugin manager is a process-global singleton + # and its shell-hook callbacks carry no profile guard. In app-global remote + # mode one backend serves every local profile, and a non-launch profile's + # build runs here under set_hermes_home_override(profile) (see _build); + # registering ITS config would append that profile's commands onto the + # shared manager and fire them on every OTHER profile's turns — cross- + # profile execution plus double context injection. The process's hooks + # belong to the launch profile alone. No TTY here, same as gateway/run.py: + # accept_hooks=False lets register_from_config resolve consent from + # HERMES_ACCEPT_HOOKS / hooks_auto_accept. Registration is idempotent, and + # failures are logged but must never block an agent build. + if get_hermes_home_override() is None: + try: + from agent.shell_hooks import register_from_config + from hermes_cli.config import load_config + + register_from_config(load_config(), accept_hooks=False) + except Exception: + logger.debug( + "shell-hook registration failed at TUI agent build", + exc_info=True, + ) + cfg = _load_cfg() agent_cfg = cfg.get("agent") or {} system_prompt = _prompt_text(agent_cfg.get("system_prompt", ""))