Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions agent/hook_registration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Once-per-process registration of user-configured hooks.

Every long-lived agent runtime must register the user's shell hooks and
outbound webhooks at startup, or events configured in config.yaml silently
never fire for sessions driven through that backend. The call sites:

* ``hermes --cli`` / oneshot — ``hermes_cli.main._prepare_agent_startup``
(registers inline, predates this module)
* messaging gateway — ``gateway/run.py`` (registers inline)
* TUI stdio backend — ``tui_gateway.entry.main`` → ``tui_gateway.server``
* TUI WebSocket sidecar (dashboard chat / desktop) —
``tui_gateway.ws.handle_ws`` → ``tui_gateway.server``
* ``hermes serve`` / dashboard backend — ``hermes_cli.web_server._lifespan``

The two inline call sites predate this module and behave identically; they
can migrate to :func:`ensure_hooks_registered` later without behavior
change. Consent semantics are owned by ``agent.shell_hooks`` (flag / env /
config opt-in, fail-closed on non-TTY stdin) and neither helper ever
prompts on a backend's piped stdio. Both registrations are idempotent and
fail-soft: a broken hook config must never take down a backend.
"""

from __future__ import annotations

import logging
import threading

logger = logging.getLogger(__name__)

_ensured_lock = threading.Lock()
_ensured = False
Comment on lines +30 to +31


def reset_for_tests() -> None:
"""Clear the once-per-process guard (test isolation only)."""
global _ensured
with _ensured_lock:
_ensured = False


def ensure_hooks_registered(cfg=None, *, accept_hooks: bool = False) -> None:
"""Register shell hooks + outbound webhooks exactly once per process.

*cfg* defaults to a fresh ``hermes_cli.config.load_config()`` read.
*accept_hooks* is passed through to shell-hook registration — callers
that own a CLI consent flag pass it; backend entry points keep the
default ``False`` and let the helper resolve opt-in from env/config.

Never raises. Repeat calls are no-ops (the underlying registrations
are independently idempotent too, so a caller that must bypass the
guard can invoke ``agent.shell_hooks`` / ``agent.outbound_webhooks``
directly, as the CLI and gateway already do).
"""
global _ensured
with _ensured_lock:
if _ensured:
return
_ensured = True
try:
if cfg is None:
from hermes_cli.config import load_config

cfg = load_config()
from agent import outbound_webhooks, shell_hooks

shell_hooks.register_from_config(cfg, accept_hooks=accept_hooks)
outbound_webhooks.register_from_config(cfg)
except Exception:
logger.debug(
"shell-hook / outbound-webhook registration failed at startup",
exc_info=True,
)
13 changes: 13 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,19 @@ async def _lifespan(app: "FastAPI"):

record_boot_fingerprint()

# Shell-hook / outbound-webhook registration — parity with the CLI
# (hermes_cli.main._prepare_agent_startup) and the messaging gateway
# (gateway/run.py). Once-per-process via agent.hook_registration;
# consent and failure semantics live inside. Without this, hooks
# configured in config.yaml fired on --cli but never for dashboard /
# Desktop sessions driven through this backend.
try:
from agent.hook_registration import ensure_hooks_registered

ensure_hooks_registered()
except Exception:
_log.warning("hook registration failed at serve startup", exc_info=True)

# Hosted Bot rooms belong to the backend process. Recovery may need a
# contended state.db migration, so keep it off the pre-yield path: Group
# Chat must degrade on its own rather than block every Desktop feature.
Expand Down
203 changes: 203 additions & 0 deletions tests/tui_gateway/test_hook_registration_parity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
"""Regression tests for backend hook-registration parity.

Every long-lived agent runtime must register user-configured shell hooks
and outbound webhooks at startup. The CLI (``_prepare_agent_startup``) and
the messaging gateway (``gateway/run.py``) always did; the TUI gateway
backends and the serve/dashboard backend historically did not, so a
webhook configured in config.yaml fired in ``hermes --cli`` but silently
never fired from ``hermes --tui``, the dashboard chat PTY, the desktop WS
sidecar, or ``hermes serve``.

Verifies:

* ``agent.hook_registration.ensure_hooks_registered`` registers both
shell hooks and outbound webhooks from the loaded config.
* It is once-per-process (repeat calls are no-ops).
* A config/load failure never breaks backend startup.
* All three backend entry points call it: ``tui_gateway.entry.main``
(stdio TUI + dashboard chat PTY), ``tui_gateway.ws.handle_ws``
(dashboard / desktop WS sidecar), and ``web_server._lifespan``
(serve / dashboard backend).
"""

from __future__ import annotations

import asyncio
import io

import pytest

import agent.hook_registration as hook_registration


@pytest.fixture(autouse=True)
def _fresh_hook_registration_guard():
"""Start every test with a clean once-per-process guard."""
hook_registration.reset_for_tests()
yield
hook_registration.reset_for_tests()


class TestEnsureHooksRegistered:
def test_registers_both_shell_hooks_and_outbound_webhooks(self, monkeypatch):
import agent.outbound_webhooks as ow
import agent.shell_hooks as sh

calls: dict[str, object] = {}

def _shell(cfg, *, accept_hooks):
calls["shell"] = (cfg, accept_hooks)
return []

def _outbound(cfg):
calls["outbound"] = cfg
return []

monkeypatch.setattr(sh, "register_from_config", _shell)
monkeypatch.setattr(ow, "register_from_config", _outbound)

cfg = {"hooks": {}}
monkeypatch.setattr("hermes_cli.config.load_config", lambda: cfg)

hook_registration.ensure_hooks_registered()

# Consent is delegated to the helper's own resolution (env/config),
# never force-enabled from a backend.
assert calls["shell"] == (cfg, False)
assert calls["outbound"] is cfg

def test_repeat_calls_are_noops(self, monkeypatch):
import agent.outbound_webhooks as ow
import agent.shell_hooks as sh

n = {"shell": 0, "outbound": 0}
monkeypatch.setattr(
sh, "register_from_config",
lambda cfg, *, accept_hooks: n.__setitem__("shell", n["shell"] + 1),
)
monkeypatch.setattr(
ow, "register_from_config", lambda cfg: n.__setitem__("outbound", n["outbound"] + 1)
)

hook_registration.ensure_hooks_registered()
hook_registration.ensure_hooks_registered()
hook_registration.ensure_hooks_registered()

assert n == {"shell": 1, "outbound": 1}

def test_failure_does_not_raise(self, monkeypatch):
"""A broken config read must never take down backend startup."""

def _boom():
raise RuntimeError("malformed config")

monkeypatch.setattr("hermes_cli.config.load_config", _boom)

# Must not raise.
hook_registration.ensure_hooks_registered()

def test_explicit_cfg_skips_config_read(self, monkeypatch):
"""A caller-provided cfg is used as-is (no load_config round trip)."""
import agent.outbound_webhooks as ow
import agent.shell_hooks as sh

seen: dict[str, object] = {}
monkeypatch.setattr(sh, "register_from_config", lambda cfg, *, accept_hooks: seen.setdefault("shell", cfg))
monkeypatch.setattr(ow, "register_from_config", lambda cfg: seen.setdefault("outbound", cfg))
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: (_ for _ in ()).throw(AssertionError("load_config must not be called")),
)

cfg = {"hooks": {}}
hook_registration.ensure_hooks_registered(cfg)
assert seen == {"shell": cfg, "outbound": cfg}


class TestEntryPointWiring:
"""All three backends must call the registration helper at startup —
the same wiring pattern the heartbeat refresher and orphan sweep
follow."""

def _stub_entry_main_common(self, monkeypatch):
from tui_gateway import entry, server

monkeypatch.setattr(entry, "_install_sidecar_publisher", lambda: None)
monkeypatch.setattr(entry, "ensure_mcp_discovery_started", lambda: None)
monkeypatch.setattr(entry, "resolve_skin", lambda: "default")
monkeypatch.setattr(entry.server, "_ensure_skin_watcher", lambda: None)
monkeypatch.setattr(entry.server, "_schedule_startup_orphan_sweep", lambda: None)
monkeypatch.setattr(entry, "_log_exit", lambda reason: None)
monkeypatch.setattr(entry, "handle_spurious_eof", lambda *a: False)
monkeypatch.setattr(entry, "write_json", lambda _payload: True)
monkeypatch.setattr(entry.sys, "stdin", io.StringIO(""))

import hermes_cli.model_switch as ms

monkeypatch.setattr(ms, "prewarm_picker_cache_async", lambda: None)
return entry, server

def test_entry_main_registers_hooks(self, monkeypatch):
entry, server = self._stub_entry_main_common(monkeypatch)

started = {"n": 0}
monkeypatch.setattr(
server, "_register_hooks_from_config",
lambda: started.__setitem__("n", started["n"] + 1),
)

entry.main()
assert started["n"] == 1

def test_handle_ws_registers_hooks(self, monkeypatch):
from tui_gateway import server
from tui_gateway import ws as ws_mod

started = {"n": 0}
monkeypatch.setattr(
server, "_register_hooks_from_config",
lambda: started.__setitem__("n", started["n"] + 1),
)
monkeypatch.setattr(server, "resolve_skin", lambda: "default")
monkeypatch.setattr(server, "_ensure_skin_watcher", lambda: None)
monkeypatch.setattr(server, "register_live_transport", lambda *_a, **_k: None)
monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0)

class FakeWS:
async def accept(self):
pass

async def send_text(self, line):
pass

async def receive_text(self):
raise ws_mod._WebSocketDisconnect()

async def close(self):
pass

asyncio.run(ws_mod.handle_ws(FakeWS()))
assert started["n"] == 1

def test_serve_lifespan_registers_hooks(self, monkeypatch):
"""The dashboard/serve backend registers hooks during startup."""
import hermes_cli.web_server as web_server_mod

started = {"n": 0}

def _ensure(*_a, **_k):
started["n"] += 1

# The lifespan does a lazy `from agent.hook_registration import
# ensure_hooks_registered` — patch the module attribute the import
# resolves against.
monkeypatch.setattr(hook_registration, "ensure_hooks_registered", _ensure)
# Neutralize the other lifespan startup work we don't assert on.
monkeypatch.setattr(web_server_mod, "_warm_gateway_module", lambda: None)

from fastapi.testclient import TestClient

with TestClient(web_server_mod.app, raise_server_exceptions=False):
pass

assert started["n"] == 1
11 changes: 11 additions & 0 deletions tui_gateway/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,17 @@ def main():
logger.warning("%s failed", what, exc_info=True)

# Backgrounded so a dead MCP server can't freeze startup; _make_agent briefly joins it.

# Shell-hook / outbound-webhook registration — parity with the CLI
# (hermes_cli.main._prepare_agent_startup) and the messaging gateway
# (gateway/run.py). Idempotent + once-per-process; consent and failure
# semantics live inside. Without this, hooks configured in config.yaml
# fired on --cli but silently never fired from --tui sessions.
try:
server._register_hooks_from_config()
except Exception:
logger.warning("hook registration failed at TUI gateway startup", exc_info=True)

ensure_mcp_discovery_started()

# change_events: clients demote legacy polls; replay_epoch: WS restart detection.
Expand Down
23 changes: 23 additions & 0 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,29 @@ def _loop():
threading.Thread(target=_loop, daemon=True).start()


# Hook-registration parity with the other backend entry points: the CLI
# (``hermes_cli.main._prepare_agent_startup``) and the messaging gateway
# (``gateway/run.py``) both register user-configured shell hooks and
# outbound webhooks at startup. The TUI gateway backends historically did
# neither, so a webhook configured in config.yaml fired in ``hermes --cli``
# but silently never fired from ``hermes --tui`` — or from the dashboard
# chat PTY / desktop WS sidecar, which share these backends.
#
# The once-per-process guard, consent semantics (flag / env / config
# opt-in, fail-closed on non-TTY stdin) and failure isolation live in
# :mod:`agent.hook_registration`, shared with the serve/dashboard path.

def _register_hooks_from_config() -> None:
"""Register user shell hooks + outbound webhooks for this backend.

Thin delegate to ``agent.hook_registration.ensure_hooks_registered``;
called from both TUI-gateway entry points (``entry.main`` and
``ws.handle_ws``). Never raises.
"""
from agent.hook_registration import ensure_hooks_registered

ensure_hooks_registered()
Comment on lines +389 to +391

atexit.register(_shutdown_sessions)
_start_idle_reaper()

Expand Down
9 changes: 9 additions & 0 deletions tui_gateway/ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,15 @@ def _error(code: int, message: str, req_id: Any) -> dict:
start()
except Exception:
_log.warning("%s failed", what, exc_info=True)
# Shell-hook / outbound-webhook registration — parity with the CLI
# and messaging-gateway startup paths. Same once-per-process pass;
# a stdio TUI that already registered is a no-op here. Without
# this, hooks configured in config.yaml never fired for sessions
# driven through the dashboard / desktop WS sidecar.
try:
server._register_hooks_from_config()
except Exception:
_log.warning("hook registration failed at TUI WS startup", exc_info=True)
if not ready_ok:
disconnect_reason = "ready_send_failed"
send_failures += 1
Expand Down