mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
The inverse of the inbound webhook platform: hooks.outbound in config.yaml lists HTTP targets + the plugin-hook events they subscribe to (on_session_end, subagent_stop, post_tool_call, ...). Each firing POSTs a JSON payload (same top-level shape as shell hooks' stdin wire) signed GitHub-style with HMAC-SHA256 (X-Hermes-Signature-256). Rides the existing hook bus — notify-only callbacks registered on the plugin manager at the same CLI/gateway/main entry points as shell hooks. Delivery is fire-and-forget via a bounded queue + single daemon worker thread, so a dead endpoint can never stall a tool call. Bounded retries (5xx/conn errors once; 4xx never). secret_env preferred over inline secret. HERMES_SAFE_MODE skips registration. hermes hooks list shows outbound targets with signed/UNSIGNED status. Zero new model tools, zero new subsystems.
437 lines
15 KiB
Python
437 lines
15 KiB
Python
"""Tests for the outbound webhook dispatcher (agent.outbound_webhooks).
|
|
|
|
Covers config parsing, matcher behaviour, HMAC signing, payload shape,
|
|
idempotent registration on the plugin manager, and end-to-end delivery
|
|
against a real local HTTP server (no mocks on the network path).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
|
import pytest
|
|
|
|
from agent import outbound_webhooks
|
|
|
|
|
|
# ── helpers ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_registration_state():
|
|
_strip_outbound_callbacks()
|
|
outbound_webhooks.reset_for_tests()
|
|
yield
|
|
_strip_outbound_callbacks()
|
|
outbound_webhooks.reset_for_tests()
|
|
|
|
|
|
def _strip_outbound_callbacks():
|
|
"""Remove outbound-webhook callbacks from the shared plugin manager.
|
|
|
|
``reset_for_tests`` clears the idempotence set but the manager singleton
|
|
keeps previously-registered callbacks; without this, a target registered
|
|
in one test would fire (real network!) in every later test in this file.
|
|
"""
|
|
from hermes_cli.plugins import get_plugin_manager
|
|
|
|
manager = get_plugin_manager()
|
|
for event, callbacks in list(manager._hooks.items()):
|
|
manager._hooks[event] = [
|
|
cb for cb in callbacks
|
|
if not getattr(cb, "__name__", "").startswith("outbound_webhook[")
|
|
]
|
|
|
|
|
|
class _CapturingHandler(BaseHTTPRequestHandler):
|
|
"""Records every POST (path, headers, body) on the server instance."""
|
|
|
|
def do_POST(self): # noqa: N802 — http.server naming
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
body = self.rfile.read(length)
|
|
self.server.captured.append( # type: ignore[attr-defined]
|
|
{
|
|
"path": self.path,
|
|
"headers": dict(self.headers),
|
|
"body": body,
|
|
}
|
|
)
|
|
status = getattr(self.server, "respond_status", 200)
|
|
self.send_response(status)
|
|
self.end_headers()
|
|
|
|
def log_message(self, format, *args): # noqa: A002 — http.server naming
|
|
pass
|
|
|
|
|
|
@pytest.fixture()
|
|
def http_server():
|
|
server = HTTPServer(("127.0.0.1", 0), _CapturingHandler)
|
|
server.captured = [] # type: ignore[attr-defined]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
yield server
|
|
server.shutdown()
|
|
server.server_close()
|
|
|
|
|
|
def _url(server: HTTPServer, path: str = "/hook") -> str:
|
|
return f"http://127.0.0.1:{server.server_address[1]}{path}"
|
|
|
|
|
|
def _cfg(*entries):
|
|
return {"hooks": {"outbound": list(entries)}}
|
|
|
|
|
|
# ── config parsing ────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestParseConfig:
|
|
def test_missing_block_is_empty(self):
|
|
assert outbound_webhooks.iter_configured_targets({}) == []
|
|
assert outbound_webhooks.iter_configured_targets(None) == []
|
|
assert outbound_webhooks.iter_configured_targets({"hooks": {}}) == []
|
|
assert outbound_webhooks.iter_configured_targets(
|
|
{"hooks": "not-a-dict"}
|
|
) == []
|
|
|
|
def test_non_list_outbound_is_empty(self):
|
|
assert outbound_webhooks.iter_configured_targets(
|
|
{"hooks": {"outbound": {"url": "https://x"}}}
|
|
) == []
|
|
|
|
def test_valid_entry_parses(self):
|
|
targets = outbound_webhooks.iter_configured_targets(
|
|
_cfg(
|
|
{
|
|
"url": "https://example.com/hook",
|
|
"events": ["on_session_end"],
|
|
"name": "ci",
|
|
"timeout": 5,
|
|
}
|
|
)
|
|
)
|
|
assert len(targets) == 1
|
|
t = targets[0]
|
|
assert t.url == "https://example.com/hook"
|
|
assert t.events == ["on_session_end"]
|
|
assert t.name == "ci"
|
|
assert t.timeout == 5
|
|
|
|
def test_missing_url_skipped(self):
|
|
assert outbound_webhooks.iter_configured_targets(
|
|
_cfg({"events": ["on_session_end"]})
|
|
) == []
|
|
|
|
def test_non_http_url_skipped(self):
|
|
assert outbound_webhooks.iter_configured_targets(
|
|
_cfg({"url": "ftp://example.com", "events": ["on_session_end"]})
|
|
) == []
|
|
assert outbound_webhooks.iter_configured_targets(
|
|
_cfg({"url": "file:///etc/passwd", "events": ["on_session_end"]})
|
|
) == []
|
|
|
|
def test_unknown_events_filtered_known_kept(self):
|
|
targets = outbound_webhooks.iter_configured_targets(
|
|
_cfg(
|
|
{
|
|
"url": "https://example.com/hook",
|
|
"events": ["on_session_end", "not_a_real_event"],
|
|
}
|
|
)
|
|
)
|
|
assert len(targets) == 1
|
|
assert targets[0].events == ["on_session_end"]
|
|
|
|
def test_all_unknown_events_skips_entry(self):
|
|
assert outbound_webhooks.iter_configured_targets(
|
|
_cfg({"url": "https://example.com", "events": ["bogus"]})
|
|
) == []
|
|
|
|
def test_empty_events_skips_entry(self):
|
|
assert outbound_webhooks.iter_configured_targets(
|
|
_cfg({"url": "https://example.com", "events": []})
|
|
) == []
|
|
|
|
def test_timeout_clamped(self):
|
|
targets = outbound_webhooks.iter_configured_targets(
|
|
_cfg(
|
|
{
|
|
"url": "https://example.com",
|
|
"events": ["on_session_end"],
|
|
"timeout": 9999,
|
|
}
|
|
)
|
|
)
|
|
assert targets[0].timeout == outbound_webhooks.MAX_TIMEOUT_SECONDS
|
|
|
|
def test_bad_timeout_falls_back_to_default(self):
|
|
targets = outbound_webhooks.iter_configured_targets(
|
|
_cfg(
|
|
{
|
|
"url": "https://example.com",
|
|
"events": ["on_session_end"],
|
|
"timeout": "soon",
|
|
}
|
|
)
|
|
)
|
|
assert targets[0].timeout == outbound_webhooks.DEFAULT_TIMEOUT_SECONDS
|
|
|
|
def test_matcher_dropped_for_non_tool_events(self):
|
|
targets = outbound_webhooks.iter_configured_targets(
|
|
_cfg(
|
|
{
|
|
"url": "https://example.com",
|
|
"events": ["on_session_end"],
|
|
"matcher": "terminal",
|
|
}
|
|
)
|
|
)
|
|
assert targets[0].matcher is None
|
|
|
|
def test_matcher_kept_for_tool_events(self):
|
|
targets = outbound_webhooks.iter_configured_targets(
|
|
_cfg(
|
|
{
|
|
"url": "https://example.com",
|
|
"events": ["post_tool_call"],
|
|
"matcher": "terminal|delegate_task",
|
|
}
|
|
)
|
|
)
|
|
assert targets[0].matcher == "terminal|delegate_task"
|
|
|
|
def test_secret_env_wins_over_literal(self, monkeypatch):
|
|
monkeypatch.setenv("MY_HOOK_SECRET", "from-env")
|
|
targets = outbound_webhooks.iter_configured_targets(
|
|
_cfg(
|
|
{
|
|
"url": "https://example.com",
|
|
"events": ["on_session_end"],
|
|
"secret_env": "MY_HOOK_SECRET",
|
|
"secret": "literal",
|
|
}
|
|
)
|
|
)
|
|
assert targets[0].secret == "from-env"
|
|
|
|
def test_unset_secret_env_means_unsigned(self, monkeypatch):
|
|
monkeypatch.delenv("MISSING_SECRET_VAR", raising=False)
|
|
targets = outbound_webhooks.iter_configured_targets(
|
|
_cfg(
|
|
{
|
|
"url": "https://example.com",
|
|
"events": ["on_session_end"],
|
|
"secret_env": "MISSING_SECRET_VAR",
|
|
}
|
|
)
|
|
)
|
|
assert targets[0].secret is None
|
|
|
|
|
|
# ── matcher behaviour ─────────────────────────────────────────────────────
|
|
|
|
|
|
class TestMatcher:
|
|
def _target(self, matcher):
|
|
return outbound_webhooks.WebhookTarget(
|
|
url="https://example.com",
|
|
events=["post_tool_call"],
|
|
matcher=matcher,
|
|
)
|
|
|
|
def test_no_matcher_matches_everything(self):
|
|
assert self._target(None).matches_tool("terminal")
|
|
assert self._target(None).matches_tool(None)
|
|
|
|
def test_regex_fullmatch(self):
|
|
t = self._target("terminal|delegate_task")
|
|
assert t.matches_tool("terminal")
|
|
assert t.matches_tool("delegate_task")
|
|
assert not t.matches_tool("terminal_extra")
|
|
assert not t.matches_tool(None)
|
|
|
|
def test_invalid_regex_falls_back_to_equality(self):
|
|
t = self._target("terminal(")
|
|
assert t.matches_tool("terminal(")
|
|
assert not t.matches_tool("terminal")
|
|
|
|
|
|
# ── payload shape ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPayload:
|
|
def test_top_level_shape_matches_shell_hooks_wire(self):
|
|
body = outbound_webhooks._serialize_payload(
|
|
"post_tool_call",
|
|
{
|
|
"tool_name": "terminal",
|
|
"args": {"command": "ls"},
|
|
"session_id": "sess_1",
|
|
"status": "ok",
|
|
"duration_ms": 42,
|
|
},
|
|
)
|
|
payload = json.loads(body)
|
|
assert payload["hook_event_name"] == "post_tool_call"
|
|
assert payload["tool_name"] == "terminal"
|
|
assert payload["tool_input"] == {"command": "ls"}
|
|
assert payload["session_id"] == "sess_1"
|
|
assert payload["extra"]["status"] == "ok"
|
|
assert payload["extra"]["duration_ms"] == 42
|
|
assert payload["delivery_id"]
|
|
assert payload["timestamp"].endswith("Z")
|
|
|
|
def test_unserialisable_values_stringified(self):
|
|
body = outbound_webhooks._serialize_payload(
|
|
"on_session_end", {"weird": object()}
|
|
)
|
|
payload = json.loads(body)
|
|
assert isinstance(payload["extra"]["weird"], str)
|
|
|
|
|
|
# ── registration ──────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestRegistration:
|
|
def test_registration_is_idempotent(self):
|
|
cfg = _cfg(
|
|
{"url": "https://example.com/hook", "events": ["on_session_end"]}
|
|
)
|
|
first = outbound_webhooks.register_from_config(cfg)
|
|
second = outbound_webhooks.register_from_config(cfg)
|
|
assert len(first) == 1
|
|
assert second == []
|
|
|
|
def test_safe_mode_skips_registration(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_SAFE_MODE", "1")
|
|
cfg = _cfg(
|
|
{"url": "https://example.com/hook", "events": ["on_session_end"]}
|
|
)
|
|
assert outbound_webhooks.register_from_config(cfg) == []
|
|
|
|
def test_callbacks_never_return_directives(self, http_server):
|
|
"""Outbound webhooks are notify-only — invoke_hook must see None."""
|
|
cfg = _cfg({"url": _url(http_server), "events": ["pre_tool_call"]})
|
|
outbound_webhooks.register_from_config(cfg)
|
|
|
|
from hermes_cli.plugins import get_plugin_manager
|
|
|
|
results = get_plugin_manager().invoke_hook(
|
|
"pre_tool_call", tool_name="terminal", args={"command": "ls"},
|
|
)
|
|
assert outbound_webhooks.flush()
|
|
# No block/context directives from the webhook callback.
|
|
assert results == []
|
|
assert len(http_server.captured) == 1
|
|
|
|
|
|
# ── E2E delivery against a real HTTP server ──────────────────────────────
|
|
|
|
|
|
class TestDelivery:
|
|
def test_delivery_with_hmac_signature(self, http_server):
|
|
secret = "s3cret"
|
|
cfg = _cfg(
|
|
{
|
|
"url": _url(http_server),
|
|
"events": ["on_session_end"],
|
|
"secret": secret,
|
|
"name": "e2e",
|
|
}
|
|
)
|
|
registered = outbound_webhooks.register_from_config(cfg)
|
|
assert len(registered) == 1
|
|
|
|
from hermes_cli.plugins import get_plugin_manager
|
|
|
|
get_plugin_manager().invoke_hook(
|
|
"on_session_end",
|
|
session_id="sess_e2e",
|
|
completed=True,
|
|
interrupted=False,
|
|
model="test-model",
|
|
platform="cli",
|
|
)
|
|
assert outbound_webhooks.flush()
|
|
|
|
assert len(http_server.captured) == 1
|
|
req = http_server.captured[0]
|
|
|
|
payload = json.loads(req["body"])
|
|
assert payload["hook_event_name"] == "on_session_end"
|
|
assert payload["session_id"] == "sess_e2e"
|
|
assert payload["extra"]["completed"] is True
|
|
assert payload["extra"]["model"] == "test-model"
|
|
|
|
assert req["headers"]["X-Hermes-Event"] == "on_session_end"
|
|
assert req["headers"]["X-Hermes-Delivery"]
|
|
expected = hmac.new(
|
|
secret.encode(), req["body"], hashlib.sha256
|
|
).hexdigest()
|
|
assert req["headers"]["X-Hermes-Signature-256"] == f"sha256={expected}"
|
|
|
|
def test_unsigned_delivery_has_no_signature_header(self, http_server):
|
|
cfg = _cfg({"url": _url(http_server), "events": ["on_session_end"]})
|
|
outbound_webhooks.register_from_config(cfg)
|
|
|
|
from hermes_cli.plugins import get_plugin_manager
|
|
|
|
get_plugin_manager().invoke_hook("on_session_end", session_id="s")
|
|
assert outbound_webhooks.flush()
|
|
|
|
assert len(http_server.captured) == 1
|
|
assert "X-Hermes-Signature-256" not in http_server.captured[0]["headers"]
|
|
|
|
def test_matcher_filters_tool_events(self, http_server):
|
|
cfg = _cfg(
|
|
{
|
|
"url": _url(http_server),
|
|
"events": ["post_tool_call"],
|
|
"matcher": "terminal",
|
|
}
|
|
)
|
|
outbound_webhooks.register_from_config(cfg)
|
|
|
|
from hermes_cli.plugins import get_plugin_manager
|
|
|
|
manager = get_plugin_manager()
|
|
manager.invoke_hook(
|
|
"post_tool_call", tool_name="web_search", args={}, status="ok",
|
|
)
|
|
manager.invoke_hook(
|
|
"post_tool_call", tool_name="terminal", args={}, status="ok",
|
|
)
|
|
assert outbound_webhooks.flush()
|
|
|
|
assert len(http_server.captured) == 1
|
|
payload = json.loads(http_server.captured[0]["body"])
|
|
assert payload["tool_name"] == "terminal"
|
|
|
|
def test_4xx_not_retried(self, http_server):
|
|
http_server.respond_status = 400
|
|
target = outbound_webhooks.WebhookTarget(
|
|
url=_url(http_server), events=["on_session_end"],
|
|
)
|
|
delivery = outbound_webhooks._build_delivery(
|
|
"on_session_end", target, b"{}",
|
|
)
|
|
outbound_webhooks._deliver(delivery)
|
|
assert len(http_server.captured) == 1
|
|
|
|
def test_connection_error_does_not_raise(self):
|
|
target = outbound_webhooks.WebhookTarget(
|
|
# Port 9 (discard) — nothing listening.
|
|
url="http://127.0.0.1:9/unreachable",
|
|
events=["on_session_end"],
|
|
timeout=1,
|
|
)
|
|
delivery = outbound_webhooks._build_delivery(
|
|
"on_session_end", target, b"{}",
|
|
)
|
|
# Must swallow the failure (logged), never raise into the agent loop.
|
|
outbound_webhooks._deliver(delivery)
|