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
29 changes: 29 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,35 @@ def _start_hosted_rooms() -> None:
)
hosted_room_start_thread.start()

# Register declarative shell hooks from cli-config.yaml so Web UI chat
# sessions invoke the same configured lifecycle hooks as CLI, TUI and
# messaging sessions. The Web server has no TTY, so consent comes from
# the same opt-in channels gateway uses (--accept-hooks on launch,
# HERMES_ACCEPT_HOOKS env var, or hooks_auto_accept in config.yaml);
# pass accept_hooks=False and let register_from_config resolve the
# effective value. Failures are logged but must never block startup.
try:
from hermes_cli.config import load_config
from agent.shell_hooks import register_from_config

_web_hooks_cfg = load_config()
register_from_config(_web_hooks_cfg, accept_hooks=False)

from agent.outbound_webhooks import (
register_from_config as register_outbound_webhooks,
)

register_outbound_webhooks(_web_hooks_cfg)
except Exception:
# A configured hook that silently vanishes is the exact failure this
# PR exists to fix (Web UI sessions previously skipped configured
# hooks entirely). Log at WARNING so a misconfigured hook is visible
# in agent.log, not swallowed at debug; startup still never blocks.
_log.warning(
"shell-hook registration failed at web startup",
exc_info=True,
)

# Desktop-spawned backends fire cron jobs themselves, since the app has no
# gateway running the scheduler. Server `hermes dashboard` is unaffected —
# it relies on its own gateway.
Expand Down
72 changes: 72 additions & 0 deletions tests/hermes_cli/test_web_server_registers_config_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Web UI sessions must register configured shell hooks and outbound webhooks.

Regression for a Web UI gap where CLI, TUI and messaging sessions invoke the
configured lifecycle hooks but ordinary `hermes dashboard` Web UI sessions did
not — so the persistent Relay observability (and any config-defined shell
hook) silently disappeared for Web UI chats.

The fix registers declarative hooks from cli-config.yaml during the FastAPI
lifespan, mirroring the gateway/CLI call sites. These tests assert that the
web server calls register_from_config (and outbound-webhook registration) at
startup with the loaded config.
"""

import pytest

from hermes_cli import web_server

pytest.importorskip("starlette.testclient")
from starlette.testclient import TestClient # noqa: E402


@pytest.fixture
def client():
previous = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = False
test_client = TestClient(web_server.app)
test_client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
try:
yield test_client
finally:
if previous is None:
try:
delattr(web_server.app.state, "auth_required")
except AttributeError:
pass
else:
web_server.app.state.auth_required = previous


class TestWebSessionRegistersConfigHooks:
def test_lifespan_registers_shell_hooks_from_config(self, monkeypatch):
# The web server's lifespan imports register_from_config from the
# source module, so patch at the source to intercept the call.
captured = {}

def fake_register(cfg, *, accept_hooks):
captured["cfg"] = cfg
captured["accept_hooks"] = accept_hooks
return []

monkeypatch.setattr(
"agent.shell_hooks.register_from_config", fake_register
)
monkeypatch.setattr(
"agent.outbound_webhooks.register_from_config",
lambda cfg: captured.setdefault("outbound", cfg),
)

# Entering the TestClient runs the lifespan, which must call the hook
# registration with the loaded config and consent resolved internally
# (accept_hooks=False — the web server has no TTY, so consent comes
# from --accept-hooks / HERMES_ACCEPT_HOOKS / hooks_auto_accept).
with TestClient(web_server.app) as test_client:
test_client.headers[web_server._SESSION_HEADER_NAME] = (
web_server._SESSION_TOKEN
)
resp = test_client.get("/api/health")
assert resp.status_code == 200

assert captured["accept_hooks"] is False
assert isinstance(captured["cfg"], dict)
assert captured["outbound"] is captured["cfg"]