From e140c02d514c30e3a0644abf027e5c452350b397 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 20 Mar 2026 06:33:36 -0700 Subject: [PATCH] feat(gateway): add webhook platform adapter for external event triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a generic webhook platform adapter that receives HTTP POSTs from external services (GitHub, GitLab, JIRA, Stripe, etc.), validates HMAC signatures, transforms payloads into agent prompts, and routes responses back to the source or to another platform. Features: - Configurable routes with per-route HMAC secrets, event filters, prompt templates with dot-notation payload access, skill loading, and pluggable delivery (github_comment, telegram, discord, log) - HMAC signature validation (GitHub SHA-256, GitLab token, generic) - Rate limiting (30 req/min per route, configurable) - Idempotency cache (1hr TTL, prevents duplicate runs on retries) - Body size limits (1MB default, checked before reading payload) - Setup wizard integration with security warnings and docs links - 33 tests (29 unit + 4 integration), all passing Security: - HMAC secret required per route (startup validation) - Setup wizard warns about internet exposure for webhook/SMS platforms - Sandboxing (Docker/VM) recommended in docs for public-facing deployments Files changed: - gateway/config.py — Platform.WEBHOOK enum + env var overrides - gateway/platforms/webhook.py — WebhookAdapter (~420 lines) - gateway/run.py — factory wiring + auth bypass for webhook events - hermes_cli/config.py — WEBHOOK_* env var definitions - hermes_cli/setup.py — webhook section in setup_gateway() - tests/gateway/test_webhook_adapter.py — 29 unit tests - tests/gateway/test_webhook_integration.py — 4 integration tests - website/docs/user-guide/messaging/webhooks.md — full user docs - website/docs/reference/environment-variables.md — WEBHOOK_* vars - website/sidebars.ts — nav entry --- gateway/config.py | 20 + gateway/platforms/webhook.py | 557 ++++++++++++++++ gateway/run.py | 13 +- hermes_cli/config.py | 21 + hermes_cli/setup.py | 56 ++ tests/gateway/test_webhook_adapter.py | 619 ++++++++++++++++++ tests/gateway/test_webhook_integration.py | 337 ++++++++++ .../docs/reference/environment-variables.md | 3 + website/docs/user-guide/messaging/webhooks.md | 310 +++++++++ website/sidebars.ts | 1 + 10 files changed, 1936 insertions(+), 1 deletion(-) create mode 100644 gateway/platforms/webhook.py create mode 100644 tests/gateway/test_webhook_adapter.py create mode 100644 tests/gateway/test_webhook_integration.py create mode 100644 website/docs/user-guide/messaging/webhooks.md diff --git a/gateway/config.py b/gateway/config.py index 85b3ec54e..c8a65ab92 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -56,6 +56,7 @@ class Platform(Enum): SMS = "sms" DINGTALK = "dingtalk" API_SERVER = "api_server" + WEBHOOK = "webhook" @dataclass @@ -254,6 +255,9 @@ class GatewayConfig: # API Server uses enabled flag only (no token needed) elif platform == Platform.API_SERVER: connected.append(platform) + # Webhook uses enabled flag only (secrets are per-route) + elif platform == Platform.WEBHOOK: + connected.append(platform) return connected def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]: @@ -734,6 +738,22 @@ def _apply_env_overrides(config: GatewayConfig) -> None: if api_server_host: config.platforms[Platform.API_SERVER].extra["host"] = api_server_host + # Webhook platform + webhook_enabled = os.getenv("WEBHOOK_ENABLED", "").lower() in ("true", "1", "yes") + webhook_port = os.getenv("WEBHOOK_PORT") + webhook_secret = os.getenv("WEBHOOK_SECRET", "") + if webhook_enabled: + if Platform.WEBHOOK not in config.platforms: + config.platforms[Platform.WEBHOOK] = PlatformConfig() + config.platforms[Platform.WEBHOOK].enabled = True + if webhook_port: + try: + config.platforms[Platform.WEBHOOK].extra["port"] = int(webhook_port) + except ValueError: + pass + if webhook_secret: + config.platforms[Platform.WEBHOOK].extra["secret"] = webhook_secret + # Session settings idle_minutes = os.getenv("SESSION_IDLE_MINUTES") if idle_minutes: diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py new file mode 100644 index 000000000..4a4bbfbac --- /dev/null +++ b/gateway/platforms/webhook.py @@ -0,0 +1,557 @@ +"""Generic webhook platform adapter. + +Runs an aiohttp HTTP server that receives webhook POSTs from external +services (GitHub, GitLab, JIRA, Stripe, etc.), validates HMAC signatures, +transforms payloads into agent prompts, and routes responses back to the +source or to another configured platform. + +Configuration lives in config.yaml under platforms.webhook.extra.routes. +Each route defines: + - events: which event types to accept (header-based filtering) + - secret: HMAC secret for signature validation (REQUIRED) + - prompt: template string formatted with the webhook payload + - skills: optional list of skills to load for the agent + - deliver: where to send the response (github_comment, telegram, etc.) + - deliver_extra: additional delivery config (repo, pr_number, chat_id) + +Security: + - HMAC secret is required per route (validated at startup) + - Rate limiting per route (fixed-window, configurable) + - Idempotency cache prevents duplicate agent runs on webhook retries + - Body size limits checked before reading payload + - Set secret to "INSECURE_NO_AUTH" to skip validation (testing only) +""" + +import asyncio +import hashlib +import hmac +import json +import logging +import re +import subprocess +import time +from typing import Any, Dict, List, Optional + +try: + from aiohttp import web + + AIOHTTP_AVAILABLE = True +except ImportError: + AIOHTTP_AVAILABLE = False + web = None # type: ignore[assignment] + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) + +logger = logging.getLogger(__name__) + +DEFAULT_HOST = "0.0.0.0" +DEFAULT_PORT = 8644 +_INSECURE_NO_AUTH = "INSECURE_NO_AUTH" + + +def check_webhook_requirements() -> bool: + """Check if webhook adapter dependencies are available.""" + return AIOHTTP_AVAILABLE + + +class WebhookAdapter(BasePlatformAdapter): + """Generic webhook receiver that triggers agent runs from HTTP POSTs.""" + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.WEBHOOK) + self._host: str = config.extra.get("host", DEFAULT_HOST) + self._port: int = int(config.extra.get("port", DEFAULT_PORT)) + self._global_secret: str = config.extra.get("secret", "") + self._routes: Dict[str, dict] = config.extra.get("routes", {}) + self._runner = None + + # Delivery info keyed by session chat_id — consumed by send() + self._delivery_info: Dict[str, dict] = {} + + # Reference to gateway runner for cross-platform delivery (set externally) + self.gateway_runner = None + + # Idempotency: TTL cache of recently processed delivery IDs. + # Prevents duplicate agent runs when webhook providers retry. + self._seen_deliveries: Dict[str, float] = {} + self._idempotency_ttl: int = 3600 # 1 hour + + # Rate limiting: per-route timestamps in a fixed window. + self._rate_counts: Dict[str, List[float]] = {} + self._rate_limit: int = int(config.extra.get("rate_limit", 30)) # per minute + + # Body size limit (auth-before-body pattern) + self._max_body_bytes: int = int( + config.extra.get("max_body_bytes", 1_048_576) + ) # 1MB + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + # Validate routes at startup — secret is required per route + for name, route in self._routes.items(): + secret = route.get("secret", self._global_secret) + if not secret: + raise ValueError( + f"[webhook] Route '{name}' has no HMAC secret. " + f"Set 'secret' on the route or globally. " + f"For testing without auth, set secret to '{_INSECURE_NO_AUTH}'." + ) + + app = web.Application() + app.router.add_get("/health", self._handle_health) + app.router.add_post("/webhooks/{route_name}", self._handle_webhook) + + self._runner = web.AppRunner(app) + await self._runner.setup() + site = web.TCPSite(self._runner, self._host, self._port) + await site.start() + self._mark_connected() + + route_names = ", ".join(self._routes.keys()) or "(none configured)" + logger.info( + "[webhook] Listening on %s:%d — routes: %s", + self._host, + self._port, + route_names, + ) + return True + + async def disconnect(self) -> None: + if self._runner: + await self._runner.cleanup() + self._runner = None + self._mark_disconnected() + logger.info("[webhook] Disconnected") + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Deliver the agent's response to the configured destination. + + chat_id is ``webhook:{route}:{delivery_id}`` — we pop the delivery + info stored during webhook receipt so it doesn't leak memory. + """ + delivery = self._delivery_info.pop(chat_id, {}) + deliver_type = delivery.get("deliver", "log") + + if deliver_type == "log": + logger.info("[webhook] Response for %s: %s", chat_id, content[:200]) + return SendResult(success=True) + + if deliver_type == "github_comment": + return await self._deliver_github_comment(content, delivery) + + # Cross-platform delivery (telegram, discord, etc.) + if self.gateway_runner and deliver_type in ( + "telegram", + "discord", + "slack", + "signal", + "sms", + ): + return await self._deliver_cross_platform( + deliver_type, content, delivery + ) + + logger.warning("[webhook] Unknown deliver type: %s", deliver_type) + return SendResult( + success=False, error=f"Unknown deliver type: {deliver_type}" + ) + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + return {"name": chat_id, "type": "webhook"} + + # ------------------------------------------------------------------ + # HTTP handlers + # ------------------------------------------------------------------ + + async def _handle_health(self, request: "web.Request") -> "web.Response": + """GET /health — simple health check.""" + return web.json_response({"status": "ok", "platform": "webhook"}) + + async def _handle_webhook(self, request: "web.Request") -> "web.Response": + """POST /webhooks/{route_name} — receive and process a webhook event.""" + route_name = request.match_info.get("route_name", "") + route_config = self._routes.get(route_name) + + if not route_config: + return web.json_response( + {"error": f"Unknown route: {route_name}"}, status=404 + ) + + # ── Auth-before-body ───────────────────────────────────── + # Check Content-Length before reading the full payload. + content_length = request.content_length or 0 + if content_length > self._max_body_bytes: + return web.json_response( + {"error": "Payload too large"}, status=413 + ) + + # ── Rate limiting ──────────────────────────────────────── + now = time.time() + window = self._rate_counts.setdefault(route_name, []) + window[:] = [t for t in window if now - t < 60] + if len(window) >= self._rate_limit: + return web.json_response( + {"error": "Rate limit exceeded"}, status=429 + ) + window.append(now) + + # Read body + try: + raw_body = await request.read() + except Exception as e: + logger.error("[webhook] Failed to read body: %s", e) + return web.json_response({"error": "Bad request"}, status=400) + + # Validate HMAC signature (skip for INSECURE_NO_AUTH testing mode) + secret = route_config.get("secret", self._global_secret) + if secret and secret != _INSECURE_NO_AUTH: + if not self._validate_signature(request, raw_body, secret): + logger.warning( + "[webhook] Invalid signature for route %s", route_name + ) + return web.json_response( + {"error": "Invalid signature"}, status=401 + ) + + # Parse payload + try: + payload = json.loads(raw_body) + except json.JSONDecodeError: + # Try form-encoded as fallback + try: + import urllib.parse + + payload = dict( + urllib.parse.parse_qsl(raw_body.decode("utf-8")) + ) + except Exception: + return web.json_response( + {"error": "Cannot parse body"}, status=400 + ) + + # Check event type filter + event_type = ( + request.headers.get("X-GitHub-Event", "") + or request.headers.get("X-GitLab-Event", "") + or payload.get("event_type", "") + or "unknown" + ) + allowed_events = route_config.get("events", []) + if allowed_events and event_type not in allowed_events: + logger.debug( + "[webhook] Ignoring event %s for route %s (allowed: %s)", + event_type, + route_name, + allowed_events, + ) + return web.json_response( + {"status": "ignored", "event": event_type} + ) + + # Format prompt from template + prompt_template = route_config.get("prompt", "") + prompt = self._render_prompt( + prompt_template, payload, event_type, route_name + ) + + # Inject skill content if configured. + # We call build_skill_invocation_message() directly rather than + # using /skill-name slash commands — the gateway's command parser + # would intercept those and break the flow. + skills = route_config.get("skills", []) + if skills: + try: + from agent.skill_commands import ( + build_skill_invocation_message, + get_skill_commands, + ) + + skill_cmds = get_skill_commands() + for skill_name in skills: + cmd_key = f"/{skill_name}" + if cmd_key in skill_cmds: + skill_content = build_skill_invocation_message( + cmd_key, user_instruction=prompt + ) + if skill_content: + prompt = skill_content + break # Load the first matching skill + else: + logger.warning( + "[webhook] Skill '%s' not found", skill_name + ) + except Exception as e: + logger.warning("[webhook] Skill loading failed: %s", e) + + # Build a unique delivery ID + delivery_id = request.headers.get( + "X-GitHub-Delivery", + request.headers.get("X-Request-ID", str(int(time.time() * 1000))), + ) + + # ── Idempotency ───────────────────────────────────────── + # Skip duplicate deliveries (webhook retries). + now = time.time() + # Prune expired entries + self._seen_deliveries = { + k: v + for k, v in self._seen_deliveries.items() + if now - v < self._idempotency_ttl + } + if delivery_id in self._seen_deliveries: + logger.info( + "[webhook] Skipping duplicate delivery %s", delivery_id + ) + return web.json_response( + {"status": "duplicate", "delivery_id": delivery_id}, + status=200, + ) + self._seen_deliveries[delivery_id] = now + + # Use delivery_id in session key so concurrent webhooks on the + # same route get independent agent runs (not queued/interrupted). + session_chat_id = f"webhook:{route_name}:{delivery_id}" + + # Store delivery info for send() — consumed (popped) on delivery + deliver_config = { + "deliver": route_config.get("deliver", "log"), + "deliver_extra": self._render_delivery_extra( + route_config.get("deliver_extra", {}), payload + ), + "payload": payload, + } + self._delivery_info[session_chat_id] = deliver_config + + # Build source and event + source = self.build_source( + chat_id=session_chat_id, + chat_name=f"webhook/{route_name}", + chat_type="webhook", + user_id=f"webhook:{route_name}", + user_name=route_name, + ) + event = MessageEvent( + text=prompt, + message_type=MessageType.TEXT, + source=source, + raw_message=payload, + message_id=delivery_id, + ) + + logger.info( + "[webhook] %s event=%s route=%s prompt_len=%d delivery=%s", + request.method, + event_type, + route_name, + len(prompt), + delivery_id, + ) + + # Non-blocking — return 202 Accepted immediately + asyncio.create_task(self.handle_message(event)) + + return web.json_response( + { + "status": "accepted", + "route": route_name, + "event": event_type, + "delivery_id": delivery_id, + }, + status=202, + ) + + # ------------------------------------------------------------------ + # Signature validation + # ------------------------------------------------------------------ + + def _validate_signature( + self, request: "web.Request", body: bytes, secret: str + ) -> bool: + """Validate webhook signature (GitHub, GitLab, generic HMAC-SHA256).""" + # GitHub: X-Hub-Signature-256 = sha256= + gh_sig = request.headers.get("X-Hub-Signature-256", "") + if gh_sig: + expected = "sha256=" + hmac.new( + secret.encode(), body, hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(gh_sig, expected) + + # GitLab: X-Gitlab-Token = + gl_token = request.headers.get("X-Gitlab-Token", "") + if gl_token: + return hmac.compare_digest(gl_token, secret) + + # Generic: X-Webhook-Signature = + generic_sig = request.headers.get("X-Webhook-Signature", "") + if generic_sig: + expected = hmac.new( + secret.encode(), body, hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(generic_sig, expected) + + # No recognised signature header but secret is configured → reject + logger.debug( + "[webhook] Secret configured but no signature header found" + ) + return False + + # ------------------------------------------------------------------ + # Prompt rendering + # ------------------------------------------------------------------ + + def _render_prompt( + self, + template: str, + payload: dict, + event_type: str, + route_name: str, + ) -> str: + """Render a prompt template with the webhook payload. + + Supports dot-notation access into nested dicts: + ``{pull_request.title}`` → ``payload["pull_request"]["title"]`` + """ + if not template: + truncated = json.dumps(payload, indent=2)[:4000] + return ( + f"Webhook event '{event_type}' on route " + f"'{route_name}':\n\n```json\n{truncated}\n```" + ) + + def _resolve(match: re.Match) -> str: + key = match.group(1) + value: Any = payload + for part in key.split("."): + if isinstance(value, dict): + value = value.get(part, f"{{{key}}}") + else: + return f"{{{key}}}" + if isinstance(value, (dict, list)): + return json.dumps(value, indent=2)[:2000] + return str(value) + + return re.sub(r"\{([a-zA-Z0-9_.]+)\}", _resolve, template) + + def _render_delivery_extra( + self, extra: dict, payload: dict + ) -> dict: + """Render delivery_extra template values with payload data.""" + rendered: Dict[str, Any] = {} + for key, value in extra.items(): + if isinstance(value, str): + rendered[key] = self._render_prompt(value, payload, "", "") + else: + rendered[key] = value + return rendered + + # ------------------------------------------------------------------ + # Response delivery + # ------------------------------------------------------------------ + + async def _deliver_github_comment( + self, content: str, delivery: dict + ) -> SendResult: + """Post agent response as a GitHub PR/issue comment via ``gh`` CLI.""" + extra = delivery.get("deliver_extra", {}) + repo = extra.get("repo", "") + pr_number = extra.get("pr_number", "") + + if not repo or not pr_number: + logger.error( + "[webhook] github_comment delivery missing repo or pr_number" + ) + return SendResult( + success=False, error="Missing repo or pr_number" + ) + + try: + result = subprocess.run( + [ + "gh", + "pr", + "comment", + str(pr_number), + "--repo", + repo, + "--body", + content, + ], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode == 0: + logger.info( + "[webhook] Posted comment on %s#%s", repo, pr_number + ) + return SendResult(success=True) + else: + logger.error( + "[webhook] gh pr comment failed: %s", result.stderr + ) + return SendResult(success=False, error=result.stderr) + except FileNotFoundError: + logger.error( + "[webhook] 'gh' CLI not found — install GitHub CLI for " + "github_comment delivery" + ) + return SendResult( + success=False, error="gh CLI not installed" + ) + except Exception as e: + logger.error("[webhook] github_comment delivery error: %s", e) + return SendResult(success=False, error=str(e)) + + async def _deliver_cross_platform( + self, platform_name: str, content: str, delivery: dict + ) -> SendResult: + """Route response to another platform (telegram, discord, etc.).""" + if not self.gateway_runner: + return SendResult( + success=False, + error="No gateway runner for cross-platform delivery", + ) + + try: + target_platform = Platform(platform_name) + except ValueError: + return SendResult( + success=False, error=f"Unknown platform: {platform_name}" + ) + + adapter = self.gateway_runner.adapters.get(target_platform) + if not adapter: + return SendResult( + success=False, + error=f"Platform {platform_name} not connected", + ) + + # Use home channel if no specific chat_id in deliver_extra + extra = delivery.get("deliver_extra", {}) + chat_id = extra.get("chat_id", "") + if not chat_id: + home = self.gateway_runner.config.get_home_channel(target_platform) + if home: + chat_id = home.chat_id + else: + return SendResult( + success=False, + error=f"No chat_id or home channel for {platform_name}", + ) + + return await adapter.send(chat_id, content) diff --git a/gateway/run.py b/gateway/run.py index 679bc6c6f..56efa1582 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1191,6 +1191,15 @@ class GatewayRunner: return None return APIServerAdapter(config) + elif platform == Platform.WEBHOOK: + from gateway.platforms.webhook import WebhookAdapter, check_webhook_requirements + if not check_webhook_requirements(): + logger.warning("Webhook: aiohttp not installed") + return None + adapter = WebhookAdapter(config) + adapter.gateway_runner = self # For cross-platform delivery + return adapter + return None def _is_user_authorized(self, source: SessionSource) -> bool: @@ -1207,7 +1216,9 @@ class GatewayRunner: # Home Assistant events are system-generated (state changes), not # user-initiated messages. The HASS_TOKEN already authenticates the # connection, so HA events are always authorized. - if source.platform == Platform.HOMEASSISTANT: + # Webhook events are authenticated via HMAC signature validation in + # the adapter itself — no user allowlist applies. + if source.platform in (Platform.HOMEASSISTANT, Platform.WEBHOOK): return True user_id = source.user_id diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 086acfa2b..9be96e42f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -812,6 +812,27 @@ OPTIONAL_ENV_VARS = { "category": "messaging", "advanced": True, }, + "WEBHOOK_ENABLED": { + "description": "Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.", + "prompt": "Enable webhooks (true/false)", + "url": None, + "password": False, + "category": "messaging", + }, + "WEBHOOK_PORT": { + "description": "Port for the webhook HTTP server (default: 8644).", + "prompt": "Webhook port", + "url": None, + "password": False, + "category": "messaging", + }, + "WEBHOOK_SECRET": { + "description": "Global HMAC secret for webhook signature validation (overridable per route in config.yaml).", + "prompt": "Webhook secret", + "url": None, + "password": True, + "category": "messaging", + }, # ── Agent settings ── "MESSAGING_CWD": { diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 5d114885d..874ea8db5 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2775,6 +2775,61 @@ def setup_gateway(config: dict): print_info("Run 'hermes whatsapp' to choose your mode (separate bot number") print_info("or personal self-chat) and pair via QR code.") + # ── Webhooks ── + existing_webhook = get_env_value("WEBHOOK_ENABLED") + if existing_webhook: + print_info("Webhooks: already configured") + if prompt_yes_no("Reconfigure webhooks?", False): + existing_webhook = None + + if not existing_webhook and prompt_yes_no("Set up webhooks? (GitHub, GitLab, etc.)", False): + print() + print_warning( + "⚠ Webhook and SMS platforms require exposing gateway ports to the" + ) + print_warning( + " internet. For security, run the gateway in a sandboxed environment" + ) + print_warning( + " (Docker, VM, etc.) to limit blast radius from prompt injection." + ) + print() + print_info( + " Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/" + ) + print() + + port = prompt("Webhook port (default 8644)") + if port: + try: + save_env_value("WEBHOOK_PORT", str(int(port))) + print_success(f"Webhook port set to {port}") + except ValueError: + print_warning("Invalid port number, using default 8644") + + secret = prompt("Global HMAC secret (shared across all routes)", password=True) + if secret: + save_env_value("WEBHOOK_SECRET", secret) + print_success("Webhook secret saved") + else: + print_warning("No secret set — you must configure per-route secrets in config.yaml") + + save_env_value("WEBHOOK_ENABLED", "true") + print() + print_success("Webhooks enabled! Next steps:") + print_info(" 1. Define webhook routes in ~/.hermes/config.yaml") + print_info(" 2. Point your service (GitHub, GitLab, etc.) at:") + print_info(" http://your-server:8644/webhooks/") + print() + print_info( + " Route configuration guide:" + ) + print_info( + " https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/#configuring-routes" + ) + print() + print_info(" Open config in your editor: hermes config edit") + # ── Gateway Service Setup ── any_messaging = ( get_env_value("TELEGRAM_BOT_TOKEN") @@ -2784,6 +2839,7 @@ def setup_gateway(config: dict): or get_env_value("MATRIX_ACCESS_TOKEN") or get_env_value("MATRIX_PASSWORD") or get_env_value("WHATSAPP_ENABLED") + or get_env_value("WEBHOOK_ENABLED") ) if any_messaging: print() diff --git a/tests/gateway/test_webhook_adapter.py b/tests/gateway/test_webhook_adapter.py new file mode 100644 index 000000000..9b8a91318 --- /dev/null +++ b/tests/gateway/test_webhook_adapter.py @@ -0,0 +1,619 @@ +"""Unit tests for the generic webhook platform adapter. + +Covers: +- HMAC signature validation (GitHub, GitLab, generic) +- Prompt rendering with dot-notation template variables +- Event type filtering +- HTTP handler behaviour (404, 202, health) +- Idempotency cache (duplicate delivery IDs) +- Rate limiting (fixed-window, per route) +- Body size limits +- INSECURE_NO_AUTH bypass +- Session isolation for concurrent webhooks +- Delivery info cleanup after send() +- connect / disconnect lifecycle +""" + +import asyncio +import hashlib +import hmac +import json +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import MessageEvent, MessageType, SendResult +from gateway.platforms.webhook import ( + WebhookAdapter, + _INSECURE_NO_AUTH, + check_webhook_requirements, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_config( + routes=None, + secret="", + rate_limit=30, + max_body_bytes=1_048_576, + host="0.0.0.0", + port=0, # let OS pick a free port in tests +): + """Build a PlatformConfig suitable for WebhookAdapter.""" + extra = { + "host": host, + "port": port, + "routes": routes or {}, + "rate_limit": rate_limit, + "max_body_bytes": max_body_bytes, + } + if secret: + extra["secret"] = secret + return PlatformConfig(enabled=True, extra=extra) + + +def _make_adapter(routes=None, **kwargs): + """Create a WebhookAdapter with sensible defaults for testing.""" + config = _make_config(routes=routes, **kwargs) + return WebhookAdapter(config) + + +def _create_app(adapter: WebhookAdapter) -> web.Application: + """Build the aiohttp Application from the adapter (without starting a full server).""" + app = web.Application() + app.router.add_get("/health", adapter._handle_health) + app.router.add_post("/webhooks/{route_name}", adapter._handle_webhook) + return app + + +def _mock_request(headers=None, body=b"", content_length=None, match_info=None): + """Build a lightweight mock aiohttp request for non-HTTP tests.""" + req = MagicMock() + req.headers = headers or {} + req.content_length = content_length if content_length is not None else len(body) + req.match_info = match_info or {} + req.method = "POST" + + async def _read(): + return body + + req.read = _read + return req + + +def _github_signature(body: bytes, secret: str) -> str: + """Compute X-Hub-Signature-256 for *body* using *secret*.""" + return "sha256=" + hmac.new( + secret.encode(), body, hashlib.sha256 + ).hexdigest() + + +def _generic_signature(body: bytes, secret: str) -> str: + """Compute X-Webhook-Signature (plain HMAC-SHA256 hex) for *body*.""" + return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + + +# =================================================================== +# Signature validation +# =================================================================== + + +class TestValidateSignature: + """Tests for WebhookAdapter._validate_signature.""" + + def test_validate_github_signature_valid(self): + """Valid X-Hub-Signature-256 is accepted.""" + adapter = _make_adapter() + body = b'{"action": "opened"}' + secret = "webhook-secret-42" + sig = _github_signature(body, secret) + req = _mock_request(headers={"X-Hub-Signature-256": sig}) + assert adapter._validate_signature(req, body, secret) is True + + def test_validate_github_signature_invalid(self): + """Wrong X-Hub-Signature-256 is rejected.""" + adapter = _make_adapter() + body = b'{"action": "opened"}' + secret = "webhook-secret-42" + req = _mock_request(headers={"X-Hub-Signature-256": "sha256=deadbeef"}) + assert adapter._validate_signature(req, body, secret) is False + + def test_validate_gitlab_token(self): + """GitLab plain-token match via X-Gitlab-Token.""" + adapter = _make_adapter() + secret = "gl-token-value" + req = _mock_request(headers={"X-Gitlab-Token": secret}) + assert adapter._validate_signature(req, b"{}", secret) is True + + def test_validate_gitlab_token_wrong(self): + """Wrong X-Gitlab-Token is rejected.""" + adapter = _make_adapter() + req = _mock_request(headers={"X-Gitlab-Token": "wrong"}) + assert adapter._validate_signature(req, b"{}", "correct") is False + + def test_validate_no_signature_with_secret_rejects(self): + """Secret configured but no recognised signature header → reject.""" + adapter = _make_adapter() + req = _mock_request(headers={}) # no sig headers at all + assert adapter._validate_signature(req, b"{}", "my-secret") is False + + def test_validate_no_secret_allows_all(self): + """When the secret is empty/falsy, the validator is never even called + by the handler (secret check is 'if secret and secret != _INSECURE...'). + Verify that an empty secret isn't accidentally passed to the validator.""" + # This tests the semantics: empty secret means skip validation entirely. + # The handler code does: if secret and secret != _INSECURE_NO_AUTH: validate + # So with an empty secret, _validate_signature is never reached. + # We just verify the code path is correct by constructing an adapter + # with no secret and confirming the route config resolves to "". + adapter = _make_adapter( + routes={"test": {"prompt": "hello"}}, + secret="", + ) + # The route has no secret, global secret is empty + route_secret = adapter._routes["test"].get("secret", adapter._global_secret) + assert not route_secret # empty → validation is skipped in handler + + def test_validate_generic_signature_valid(self): + """Valid X-Webhook-Signature (generic HMAC-SHA256 hex) is accepted.""" + adapter = _make_adapter() + body = b'{"event": "push"}' + secret = "generic-secret" + sig = _generic_signature(body, secret) + req = _mock_request(headers={"X-Webhook-Signature": sig}) + assert adapter._validate_signature(req, body, secret) is True + + +# =================================================================== +# Prompt rendering +# =================================================================== + + +class TestRenderPrompt: + """Tests for WebhookAdapter._render_prompt.""" + + def test_render_prompt_dot_notation(self): + """Dot-notation {pull_request.title} resolves nested keys.""" + adapter = _make_adapter() + payload = {"pull_request": {"title": "Fix bug", "number": 42}} + result = adapter._render_prompt( + "PR #{pull_request.number}: {pull_request.title}", + payload, + "pull_request", + "github", + ) + assert result == "PR #42: Fix bug" + + def test_render_prompt_missing_key_preserved(self): + """{nonexistent} is left as-is when key doesn't exist in payload.""" + adapter = _make_adapter() + result = adapter._render_prompt( + "Hello {nonexistent}!", + {"action": "opened"}, + "push", + "test", + ) + assert "{nonexistent}" in result + + def test_render_prompt_no_template_dumps_json(self): + """Empty template → JSON dump fallback with event/route context.""" + adapter = _make_adapter() + payload = {"key": "value"} + result = adapter._render_prompt("", payload, "push", "my-route") + assert "push" in result + assert "my-route" in result + assert "key" in result + + +# =================================================================== +# Delivery extra rendering +# =================================================================== + + +class TestRenderDeliveryExtra: + def test_render_delivery_extra_templates(self): + """String values in deliver_extra are rendered with payload data.""" + adapter = _make_adapter() + extra = {"repo": "{repository.full_name}", "pr_number": "{number}", "static": 42} + payload = {"repository": {"full_name": "org/repo"}, "number": 7} + result = adapter._render_delivery_extra(extra, payload) + assert result["repo"] == "org/repo" + assert result["pr_number"] == "7" + assert result["static"] == 42 # non-string left as-is + + +# =================================================================== +# Event filtering +# =================================================================== + + +class TestEventFilter: + """Tests for event type filtering in _handle_webhook.""" + + @pytest.mark.asyncio + async def test_event_filter_accepts_matching(self): + """Matching event type passes through.""" + routes = { + "gh": { + "secret": _INSECURE_NO_AUTH, + "events": ["pull_request"], + "prompt": "PR: {action}", + } + } + adapter = _make_adapter(routes=routes) + # Stub handle_message to avoid running the agent + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/gh", + json={"action": "opened"}, + headers={"X-GitHub-Event": "pull_request"}, + ) + assert resp.status == 202 + + @pytest.mark.asyncio + async def test_event_filter_rejects_non_matching(self): + """Non-matching event type returns 200 with status=ignored.""" + routes = { + "gh": { + "secret": _INSECURE_NO_AUTH, + "events": ["pull_request"], + "prompt": "test", + } + } + adapter = _make_adapter(routes=routes) + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/gh", + json={"action": "opened"}, + headers={"X-GitHub-Event": "push"}, + ) + assert resp.status == 200 + data = await resp.json() + assert data["status"] == "ignored" + + @pytest.mark.asyncio + async def test_event_filter_empty_allows_all(self): + """No events list → accept any event type.""" + routes = { + "all": { + "secret": _INSECURE_NO_AUTH, + "prompt": "got it", + } + } + adapter = _make_adapter(routes=routes) + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/all", + json={"action": "any"}, + headers={"X-GitHub-Event": "whatever"}, + ) + assert resp.status == 202 + + +# =================================================================== +# HTTP handling +# =================================================================== + + +class TestHTTPHandling: + + @pytest.mark.asyncio + async def test_unknown_route_returns_404(self): + """POST to an unknown route returns 404.""" + adapter = _make_adapter(routes={"real": {"secret": _INSECURE_NO_AUTH, "prompt": "x"}}) + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post("/webhooks/nonexistent", json={"a": 1}) + assert resp.status == 404 + + @pytest.mark.asyncio + async def test_webhook_handler_returns_202(self): + """Valid request returns 202 Accepted.""" + routes = {"test": {"secret": _INSECURE_NO_AUTH, "prompt": "hi"}} + adapter = _make_adapter(routes=routes) + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post("/webhooks/test", json={"data": "value"}) + assert resp.status == 202 + data = await resp.json() + assert data["status"] == "accepted" + assert data["route"] == "test" + + @pytest.mark.asyncio + async def test_health_endpoint(self): + """GET /health returns 200 with status=ok.""" + adapter = _make_adapter() + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.get("/health") + assert resp.status == 200 + data = await resp.json() + assert data["status"] == "ok" + assert data["platform"] == "webhook" + + @pytest.mark.asyncio + async def test_connect_starts_server(self): + """connect() starts the HTTP listener and marks adapter as connected.""" + routes = {"r1": {"secret": _INSECURE_NO_AUTH, "prompt": "x"}} + adapter = _make_adapter(routes=routes, port=0) + # Use port 0 — the OS picks a free port, but aiohttp requires a real bind. + # We just test that the method completes and marks connected. + # Need to mock TCPSite to avoid actual binding. + with patch("gateway.platforms.webhook.web.AppRunner") as MockRunner, \ + patch("gateway.platforms.webhook.web.TCPSite") as MockSite: + mock_runner_inst = AsyncMock() + MockRunner.return_value = mock_runner_inst + mock_site_inst = AsyncMock() + MockSite.return_value = mock_site_inst + + result = await adapter.connect() + assert result is True + assert adapter.is_connected + mock_runner_inst.setup.assert_awaited_once() + mock_site_inst.start.assert_awaited_once() + + await adapter.disconnect() + + @pytest.mark.asyncio + async def test_disconnect_cleans_up(self): + """disconnect() stops the server and marks adapter disconnected.""" + adapter = _make_adapter() + # Simulate a runner that was previously set up + mock_runner = AsyncMock() + adapter._runner = mock_runner + adapter._running = True + + await adapter.disconnect() + mock_runner.cleanup.assert_awaited_once() + assert adapter._runner is None + assert not adapter.is_connected + + +# =================================================================== +# Idempotency +# =================================================================== + + +class TestIdempotency: + + @pytest.mark.asyncio + async def test_duplicate_delivery_id_returns_200(self): + """Second request with same delivery ID returns 200 duplicate.""" + routes = {"idem": {"secret": _INSECURE_NO_AUTH, "prompt": "test"}} + adapter = _make_adapter(routes=routes) + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + headers = {"X-GitHub-Delivery": "delivery-123"} + resp1 = await cli.post("/webhooks/idem", json={"a": 1}, headers=headers) + assert resp1.status == 202 + + resp2 = await cli.post("/webhooks/idem", json={"a": 1}, headers=headers) + assert resp2.status == 200 + data = await resp2.json() + assert data["status"] == "duplicate" + + @pytest.mark.asyncio + async def test_expired_delivery_id_allows_reprocess(self): + """After TTL expires, the same delivery ID is accepted again.""" + routes = {"idem": {"secret": _INSECURE_NO_AUTH, "prompt": "test"}} + adapter = _make_adapter(routes=routes) + adapter._idempotency_ttl = 1 # 1 second TTL for test speed + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + headers = {"X-GitHub-Delivery": "delivery-456"} + + resp1 = await cli.post("/webhooks/idem", json={"x": 1}, headers=headers) + assert resp1.status == 202 + + # Backdate the cache entry so it appears expired + adapter._seen_deliveries["delivery-456"] = time.time() - 3700 + + resp2 = await cli.post("/webhooks/idem", json={"x": 1}, headers=headers) + assert resp2.status == 202 # re-accepted + + +# =================================================================== +# Rate limiting +# =================================================================== + + +class TestRateLimiting: + + @pytest.mark.asyncio + async def test_rate_limit_rejects_excess(self): + """Exceeding the rate limit returns 429.""" + routes = {"limited": {"secret": _INSECURE_NO_AUTH, "prompt": "test"}} + adapter = _make_adapter(routes=routes, rate_limit=2) + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + # Two requests within limit + for i in range(2): + resp = await cli.post( + "/webhooks/limited", + json={"n": i}, + headers={"X-GitHub-Delivery": f"d-{i}"}, + ) + assert resp.status == 202, f"Request {i} should be accepted" + + # Third request should be rate-limited + resp = await cli.post( + "/webhooks/limited", + json={"n": 99}, + headers={"X-GitHub-Delivery": "d-99"}, + ) + assert resp.status == 429 + + @pytest.mark.asyncio + async def test_rate_limit_window_resets(self): + """After the 60-second window passes, requests are allowed again.""" + routes = {"limited": {"secret": _INSECURE_NO_AUTH, "prompt": "test"}} + adapter = _make_adapter(routes=routes, rate_limit=1) + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/limited", + json={"n": 1}, + headers={"X-GitHub-Delivery": "d-a"}, + ) + assert resp.status == 202 + + # Backdate all rate-limit timestamps to > 60 seconds ago + adapter._rate_counts["limited"] = [time.time() - 120] + + resp = await cli.post( + "/webhooks/limited", + json={"n": 2}, + headers={"X-GitHub-Delivery": "d-b"}, + ) + assert resp.status == 202 # allowed again + + +# =================================================================== +# Body size limit +# =================================================================== + + +class TestBodySize: + + @pytest.mark.asyncio + async def test_oversized_payload_rejected(self): + """Content-Length > max_body_bytes returns 413.""" + routes = {"big": {"secret": _INSECURE_NO_AUTH, "prompt": "test"}} + adapter = _make_adapter(routes=routes, max_body_bytes=100) + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + large_payload = {"data": "x" * 200} + resp = await cli.post( + "/webhooks/big", + json=large_payload, + headers={"Content-Length": "999999"}, + ) + assert resp.status == 413 + + +# =================================================================== +# INSECURE_NO_AUTH +# =================================================================== + + +class TestInsecureNoAuth: + + @pytest.mark.asyncio + async def test_insecure_no_auth_skips_validation(self): + """Setting secret to _INSECURE_NO_AUTH bypasses signature check.""" + routes = {"open": {"secret": _INSECURE_NO_AUTH, "prompt": "hello"}} + adapter = _make_adapter(routes=routes) + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + # No signature header at all — should still be accepted + resp = await cli.post("/webhooks/open", json={"test": True}) + assert resp.status == 202 + + +# =================================================================== +# Session isolation +# =================================================================== + + +class TestSessionIsolation: + + @pytest.mark.asyncio + async def test_concurrent_webhooks_get_independent_sessions(self): + """Two events on the same route produce different session keys.""" + routes = {"ci": {"secret": _INSECURE_NO_AUTH, "prompt": "build"}} + adapter = _make_adapter(routes=routes) + + captured_events = [] + + async def _capture(event): + captured_events.append(event) + + adapter.handle_message = _capture + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp1 = await cli.post( + "/webhooks/ci", + json={"ref": "main"}, + headers={"X-GitHub-Delivery": "aaa-111"}, + ) + assert resp1.status == 202 + + resp2 = await cli.post( + "/webhooks/ci", + json={"ref": "dev"}, + headers={"X-GitHub-Delivery": "bbb-222"}, + ) + assert resp2.status == 202 + + # Wait for the async tasks to be created + await asyncio.sleep(0.05) + + assert len(captured_events) == 2 + ids = {ev.source.chat_id for ev in captured_events} + assert len(ids) == 2, "Each delivery must have a unique session chat_id" + + +# =================================================================== +# Delivery info cleanup +# =================================================================== + + +class TestDeliveryCleanup: + + @pytest.mark.asyncio + async def test_delivery_info_cleaned_after_send(self): + """send() pops delivery_info so the entry doesn't leak memory.""" + adapter = _make_adapter() + chat_id = "webhook:test:d-xyz" + adapter._delivery_info[chat_id] = { + "deliver": "log", + "deliver_extra": {}, + "payload": {"x": 1}, + } + + result = await adapter.send(chat_id, "Agent response here") + assert result.success is True + assert chat_id not in adapter._delivery_info + + +# =================================================================== +# check_webhook_requirements +# =================================================================== + + +class TestCheckRequirements: + def test_returns_true_when_aiohttp_available(self): + assert check_webhook_requirements() is True + + @patch("gateway.platforms.webhook.AIOHTTP_AVAILABLE", False) + def test_returns_false_without_aiohttp(self): + assert check_webhook_requirements() is False diff --git a/tests/gateway/test_webhook_integration.py b/tests/gateway/test_webhook_integration.py new file mode 100644 index 000000000..14b9b6974 --- /dev/null +++ b/tests/gateway/test_webhook_integration.py @@ -0,0 +1,337 @@ +"""Integration tests for the generic webhook platform adapter. + +These tests exercise end-to-end flows through the webhook adapter: +1. GitHub PR webhook → agent MessageEvent created +2. Skills config injects skill content into the prompt +3. Cross-platform delivery routes to a mock Telegram adapter +4. GitHub comment delivery invokes ``gh`` CLI (mocked subprocess) +""" + +import asyncio +import hashlib +import hmac +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from gateway.config import ( + GatewayConfig, + HomeChannel, + Platform, + PlatformConfig, +) +from gateway.platforms.base import MessageEvent, MessageType, SendResult +from gateway.platforms.webhook import WebhookAdapter, _INSECURE_NO_AUTH + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_adapter(routes, **extra_kw) -> WebhookAdapter: + """Create a WebhookAdapter with the given routes.""" + extra = {"host": "0.0.0.0", "port": 0, "routes": routes} + extra.update(extra_kw) + config = PlatformConfig(enabled=True, extra=extra) + return WebhookAdapter(config) + + +def _create_app(adapter: WebhookAdapter) -> web.Application: + """Build the aiohttp Application from the adapter.""" + app = web.Application() + app.router.add_get("/health", adapter._handle_health) + app.router.add_post("/webhooks/{route_name}", adapter._handle_webhook) + return app + + +def _github_signature(body: bytes, secret: str) -> str: + """Compute X-Hub-Signature-256 for *body* using *secret*.""" + return "sha256=" + hmac.new( + secret.encode(), body, hashlib.sha256 + ).hexdigest() + + +# A realistic GitHub pull_request event payload (trimmed) +GITHUB_PR_PAYLOAD = { + "action": "opened", + "number": 42, + "pull_request": { + "title": "Add webhook adapter", + "body": "This PR adds a generic webhook platform adapter.", + "html_url": "https://github.com/org/repo/pull/42", + "user": {"login": "contributor"}, + "head": {"ref": "feature/webhooks"}, + "base": {"ref": "main"}, + }, + "repository": { + "full_name": "org/repo", + "html_url": "https://github.com/org/repo", + }, + "sender": {"login": "contributor"}, +} + + +# =================================================================== +# Test 1: GitHub PR webhook triggers agent +# =================================================================== + +class TestGitHubPRWebhook: + + @pytest.mark.asyncio + async def test_github_pr_webhook_triggers_agent(self): + """POST with a realistic GitHub PR payload should: + 1. Return 202 Accepted + 2. Call handle_message with a MessageEvent + 3. The event text contains the rendered prompt + 4. The event source has chat_type 'webhook' + """ + secret = "gh-webhook-test-secret" + routes = { + "github-pr": { + "secret": secret, + "events": ["pull_request"], + "prompt": ( + "Review PR #{number} by {sender.login}: " + "{pull_request.title}\n\n{pull_request.body}" + ), + "deliver": "log", + } + } + adapter = _make_adapter(routes) + + captured_events: list[MessageEvent] = [] + + async def _capture(event: MessageEvent): + captured_events.append(event) + + adapter.handle_message = _capture + + app = _create_app(adapter) + body = json.dumps(GITHUB_PR_PAYLOAD).encode() + sig = _github_signature(body, secret) + + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/github-pr", + data=body, + headers={ + "Content-Type": "application/json", + "X-GitHub-Event": "pull_request", + "X-Hub-Signature-256": sig, + "X-GitHub-Delivery": "gh-delivery-001", + }, + ) + assert resp.status == 202 + data = await resp.json() + assert data["status"] == "accepted" + assert data["route"] == "github-pr" + assert data["event"] == "pull_request" + assert data["delivery_id"] == "gh-delivery-001" + + # Let the asyncio.create_task fire + await asyncio.sleep(0.05) + + assert len(captured_events) == 1 + event = captured_events[0] + assert "Review PR #42 by contributor" in event.text + assert "Add webhook adapter" in event.text + assert event.source.chat_type == "webhook" + assert event.source.platform == Platform.WEBHOOK + assert "github-pr" in event.source.chat_id + assert event.message_id == "gh-delivery-001" + + +# =================================================================== +# Test 2: Skills injected into prompt +# =================================================================== + +class TestSkillsInjection: + + @pytest.mark.asyncio + async def test_skills_injected_into_prompt(self): + """When a route has skills: [code-review], the adapter should + call build_skill_invocation_message() and use its output as the + prompt instead of the raw template render.""" + routes = { + "pr-review": { + "secret": _INSECURE_NO_AUTH, + "events": ["pull_request"], + "prompt": "Review this PR: {pull_request.title}", + "skills": ["code-review"], + } + } + adapter = _make_adapter(routes) + + captured_events: list[MessageEvent] = [] + + async def _capture(event: MessageEvent): + captured_events.append(event) + + adapter.handle_message = _capture + + skill_content = ( + "You are a code reviewer. Review the following:\n" + "Review this PR: Add webhook adapter" + ) + + # The imports are lazy (inside the handler), so patch the source module + with patch( + "agent.skill_commands.build_skill_invocation_message", + return_value=skill_content, + ) as mock_build, patch( + "agent.skill_commands.get_skill_commands", + return_value={"/code-review": {"name": "code-review"}}, + ): + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/pr-review", + json=GITHUB_PR_PAYLOAD, + headers={ + "X-GitHub-Event": "pull_request", + "X-GitHub-Delivery": "skill-test-001", + }, + ) + assert resp.status == 202 + + await asyncio.sleep(0.05) + + assert len(captured_events) == 1 + event = captured_events[0] + # The prompt should be the skill content, not the raw template + assert "You are a code reviewer" in event.text + mock_build.assert_called_once() + + +# =================================================================== +# Test 3: Cross-platform delivery (webhook → Telegram) +# =================================================================== + +class TestCrossPlatformDelivery: + + @pytest.mark.asyncio + async def test_cross_platform_delivery(self): + """When deliver='telegram', the response is routed to the + Telegram adapter via gateway_runner.adapters.""" + routes = { + "alerts": { + "secret": _INSECURE_NO_AUTH, + "prompt": "Alert: {message}", + "deliver": "telegram", + "deliver_extra": {"chat_id": "12345"}, + } + } + adapter = _make_adapter(routes) + adapter.handle_message = AsyncMock() + + # Set up a mock gateway runner with a mock Telegram adapter + mock_tg_adapter = AsyncMock() + mock_tg_adapter.send = AsyncMock(return_value=SendResult(success=True)) + + mock_runner = MagicMock() + mock_runner.adapters = {Platform.TELEGRAM: mock_tg_adapter} + mock_runner.config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake")} + ) + adapter.gateway_runner = mock_runner + + # First, simulate a webhook POST to set up delivery_info + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/alerts", + json={"message": "Server is on fire!"}, + headers={"X-GitHub-Delivery": "alert-001"}, + ) + assert resp.status == 202 + + # The adapter should have stored delivery info + chat_id = "webhook:alerts:alert-001" + assert chat_id in adapter._delivery_info + + # Now call send() as if the agent has finished + result = await adapter.send(chat_id, "I've acknowledged the alert.") + + assert result.success is True + mock_tg_adapter.send.assert_awaited_once_with( + "12345", "I've acknowledged the alert." + ) + # Delivery info should be cleaned up + assert chat_id not in adapter._delivery_info + + +# =================================================================== +# Test 4: GitHub comment delivery via gh CLI +# =================================================================== + +class TestGitHubCommentDelivery: + + @pytest.mark.asyncio + async def test_github_comment_delivery(self): + """When deliver='github_comment', the adapter invokes + ``gh pr comment`` via subprocess.run (mocked).""" + routes = { + "pr-bot": { + "secret": _INSECURE_NO_AUTH, + "prompt": "Review: {pull_request.title}", + "deliver": "github_comment", + "deliver_extra": { + "repo": "{repository.full_name}", + "pr_number": "{number}", + }, + } + } + adapter = _make_adapter(routes) + adapter.handle_message = AsyncMock() + + # POST a webhook to set up delivery info + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/pr-bot", + json=GITHUB_PR_PAYLOAD, + headers={ + "X-GitHub-Event": "pull_request", + "X-GitHub-Delivery": "gh-comment-001", + }, + ) + assert resp.status == 202 + + chat_id = "webhook:pr-bot:gh-comment-001" + assert chat_id in adapter._delivery_info + + # Verify deliver_extra was rendered with payload data + delivery = adapter._delivery_info[chat_id] + assert delivery["deliver_extra"]["repo"] == "org/repo" + assert delivery["deliver_extra"]["pr_number"] == "42" + + # Mock subprocess.run and call send() + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "Comment posted" + mock_result.stderr = "" + + with patch( + "gateway.platforms.webhook.subprocess.run", + return_value=mock_result, + ) as mock_run: + result = await adapter.send( + chat_id, "LGTM! The code looks great." + ) + + assert result.success is True + mock_run.assert_called_once_with( + [ + "gh", "pr", "comment", "42", + "--repo", "org/repo", + "--body", "LGTM! The code looks great.", + ], + capture_output=True, + text=True, + timeout=30, + ) + # Delivery info cleaned up + assert chat_id not in adapter._delivery_info diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index a92919da0..6bb2dc0b9 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -207,6 +207,9 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `MATRIX_ENCRYPTION` | Enable end-to-end encryption (`true`/`false`, default: `false`) | | `HASS_TOKEN` | Home Assistant Long-Lived Access Token (enables HA platform + tools) | | `HASS_URL` | Home Assistant URL (default: `http://homeassistant.local:8123`) | +| `WEBHOOK_ENABLED` | Enable the webhook platform adapter (`true`/`false`) | +| `WEBHOOK_PORT` | HTTP server port for receiving webhooks (default: `8644`) | +| `WEBHOOK_SECRET` | Global HMAC secret for webhook signature validation (used as fallback when routes don't specify their own) | | `API_SERVER_ENABLED` | Enable the OpenAI-compatible API server (`true`/`false`). Runs alongside other platforms. | | `API_SERVER_KEY` | Bearer token for API server authentication. If empty, all requests are allowed (local-only use). | | `API_SERVER_PORT` | Port for the API server (default: `8642`) | diff --git a/website/docs/user-guide/messaging/webhooks.md b/website/docs/user-guide/messaging/webhooks.md new file mode 100644 index 000000000..75d672688 --- /dev/null +++ b/website/docs/user-guide/messaging/webhooks.md @@ -0,0 +1,310 @@ +--- +sidebar_position: 13 +title: "Webhooks" +description: "Receive events from GitHub, GitLab, and other services to trigger Hermes agent runs" +--- + +# Webhooks + +Receive events from external services (GitHub, GitLab, JIRA, Stripe, etc.) and trigger Hermes agent runs automatically. The webhook adapter runs an HTTP server that accepts POST requests, validates HMAC signatures, transforms payloads into agent prompts, and routes responses back to the source or to another configured platform. + +The agent processes the event and can respond by posting comments on PRs, sending messages to Telegram/Discord, or logging the result. + +--- + +## Quick Start + +1. Enable via `hermes setup gateway` or environment variables +2. Define webhook routes in `config.yaml` +3. Point your service at `http://your-server:8644/webhooks/` + +--- + +## Setup + +There are two ways to enable the webhook adapter. + +### Via setup wizard + +```bash +hermes setup gateway +``` + +Follow the prompts to enable webhooks, set the port, and set a global HMAC secret. + +### Via environment variables + +Add to `~/.hermes/.env`: + +```bash +WEBHOOK_ENABLED=true +WEBHOOK_PORT=8644 # default +WEBHOOK_SECRET=your-global-secret +``` + +### Verify the server + +Once the gateway is running: + +```bash +curl http://localhost:8644/health +``` + +Expected response: + +```json +{"status": "ok", "platform": "webhook"} +``` + +--- + +## Configuring Routes {#configuring-routes} + +Routes define how different webhook sources are handled. Each route is a named entry under `platforms.webhook.extra.routes` in your `config.yaml`. + +### Route properties + +| Property | Required | Description | +|----------|----------|-------------| +| `events` | No | List of event types to accept (e.g. `["pull_request"]`). If empty, all events are accepted. Event type is read from `X-GitHub-Event`, `X-GitLab-Event`, or `event_type` in the payload. | +| `secret` | **Yes** | HMAC secret for signature validation. Falls back to the global `secret` if not set on the route. Set to `"INSECURE_NO_AUTH"` for testing only (skips validation). | +| `prompt` | No | Template string with dot-notation payload access (e.g. `{pull_request.title}`). If omitted, the full JSON payload is dumped into the prompt. | +| `skills` | No | List of skill names to load for the agent run. | +| `deliver` | No | Where to send the response: `github_comment`, `telegram`, `discord`, `slack`, `signal`, `sms`, or `log` (default). | +| `deliver_extra` | No | Additional delivery config — keys depend on `deliver` type (e.g. `repo`, `pr_number`, `chat_id`). Values support the same `{dot.notation}` templates as `prompt`. | + +### Full example + +```yaml +platforms: + webhook: + enabled: true + extra: + port: 8644 + secret: "global-fallback-secret" + routes: + github-pr: + events: ["pull_request"] + secret: "github-webhook-secret" + prompt: | + Review this pull request: + Repository: {repository.full_name} + PR #{number}: {pull_request.title} + Author: {pull_request.user.login} + URL: {pull_request.html_url} + Diff URL: {pull_request.diff_url} + Action: {action} + skills: ["github-code-review"] + deliver: "github_comment" + deliver_extra: + repo: "{repository.full_name}" + pr_number: "{number}" + deploy-notify: + events: ["push"] + secret: "deploy-secret" + prompt: "New push to {repository.full_name} branch {ref}: {head_commit.message}" + deliver: "telegram" +``` + +### Prompt Templates + +Prompts use dot-notation to access nested fields in the webhook payload: + +- `{pull_request.title}` resolves to `payload["pull_request"]["title"]` +- `{repository.full_name}` resolves to `payload["repository"]["full_name"]` +- Missing keys are left as the literal `{key}` string (no error) +- Nested dicts and lists are JSON-serialized and truncated at 2000 characters + +If no `prompt` template is configured for a route, the entire payload is dumped as indented JSON (truncated at 4000 characters). + +The same dot-notation templates work in `deliver_extra` values. + +--- + +## GitHub PR Review (Step by Step) {#github-pr-review} + +This walkthrough sets up automatic code review on every pull request. + +### 1. Create the webhook in GitHub + +1. Go to your repository → **Settings** → **Webhooks** → **Add webhook** +2. Set **Payload URL** to `http://your-server:8644/webhooks/github-pr` +3. Set **Content type** to `application/json` +4. Set **Secret** to match your route config (e.g. `github-webhook-secret`) +5. Under **Which events?**, select **Let me select individual events** and check **Pull requests** +6. Click **Add webhook** + +### 2. Add the route config + +Add the `github-pr` route to your `~/.hermes/config.yaml` as shown in the example above. + +### 3. Ensure `gh` CLI is authenticated + +The `github_comment` delivery type uses the GitHub CLI to post comments: + +```bash +gh auth login +``` + +### 4. Test it + +Open a pull request on the repository. The webhook fires, Hermes processes the event, and posts a review comment on the PR. + +--- + +## GitLab Webhook Setup {#gitlab-webhook-setup} + +GitLab webhooks work similarly but use a different authentication mechanism. GitLab sends the secret as a plain `X-Gitlab-Token` header (exact string match, not HMAC). + +### 1. Create the webhook in GitLab + +1. Go to your project → **Settings** → **Webhooks** +2. Set the **URL** to `http://your-server:8644/webhooks/gitlab-mr` +3. Enter your **Secret token** +4. Select **Merge request events** (and any other events you want) +5. Click **Add webhook** + +### 2. Add the route config + +```yaml +platforms: + webhook: + enabled: true + extra: + routes: + gitlab-mr: + events: ["merge_request"] + secret: "your-gitlab-secret-token" + prompt: | + Review this merge request: + Project: {project.path_with_namespace} + MR !{object_attributes.iid}: {object_attributes.title} + Author: {object_attributes.last_commit.author.name} + URL: {object_attributes.url} + Action: {object_attributes.action} + deliver: "log" +``` + +--- + +## Delivery Options {#delivery-options} + +The `deliver` field controls where the agent's response goes after processing the webhook event. + +| Deliver Type | Description | +|-------------|-------------| +| `log` | Logs the response to the gateway log output. This is the default and is useful for testing. | +| `github_comment` | Posts the response as a PR/issue comment via the `gh` CLI. Requires `deliver_extra.repo` and `deliver_extra.pr_number`. The `gh` CLI must be installed and authenticated on the gateway host (`gh auth login`). | +| `telegram` | Routes the response to Telegram. Uses the home channel, or specify `chat_id` in `deliver_extra`. | +| `discord` | Routes the response to Discord. Uses the home channel, or specify `chat_id` in `deliver_extra`. | +| `slack` | Routes the response to Slack. Uses the home channel, or specify `chat_id` in `deliver_extra`. | +| `signal` | Routes the response to Signal. Uses the home channel, or specify `chat_id` in `deliver_extra`. | +| `sms` | Routes the response to SMS via Twilio. Uses the home channel, or specify `chat_id` in `deliver_extra`. | + +For cross-platform delivery (telegram, discord, slack, signal, sms), the target platform must also be enabled and connected in the gateway. If no `chat_id` is provided in `deliver_extra`, the response is sent to that platform's configured home channel. + +--- + +## Security {#security} + +The webhook adapter includes multiple layers of security: + +### HMAC signature validation + +The adapter validates incoming webhook signatures using the appropriate method for each source: + +- **GitHub**: `X-Hub-Signature-256` header — HMAC-SHA256 hex digest prefixed with `sha256=` +- **GitLab**: `X-Gitlab-Token` header — plain secret string match +- **Generic**: `X-Webhook-Signature` header — raw HMAC-SHA256 hex digest + +If a secret is configured but no recognized signature header is present, the request is rejected. + +### Secret is required + +Every route must have a secret — either set directly on the route or inherited from the global `secret`. Routes without a secret cause the adapter to fail at startup with an error. For development/testing only, you can set the secret to `"INSECURE_NO_AUTH"` to skip validation entirely. + +### Rate limiting + +Each route is rate-limited to **30 requests per minute** by default (fixed-window). Configure this globally: + +```yaml +platforms: + webhook: + extra: + rate_limit: 60 # requests per minute +``` + +Requests exceeding the limit receive a `429 Too Many Requests` response. + +### Idempotency + +Delivery IDs (from `X-GitHub-Delivery`, `X-Request-ID`, or a timestamp fallback) are cached for **1 hour**. Duplicate deliveries (e.g. webhook retries) are silently skipped with a `200` response, preventing duplicate agent runs. + +### Body size limits + +Payloads exceeding **1 MB** are rejected before the body is read. Configure this: + +```yaml +platforms: + webhook: + extra: + max_body_bytes: 2097152 # 2 MB +``` + +### Prompt injection risk + +:::warning +Webhook payloads contain attacker-controlled data — PR titles, commit messages, issue descriptions, etc. can all contain malicious instructions. Run the gateway in a sandboxed environment (Docker, VM) when exposed to the internet. Consider using the Docker or SSH terminal backend for isolation. +::: + +--- + +## Troubleshooting {#troubleshooting} + +### Webhook not arriving + +- Verify the port is exposed and accessible from the webhook source +- Check firewall rules — port `8644` (or your configured port) must be open +- Verify the URL path matches: `http://your-server:8644/webhooks/` +- Use the `/health` endpoint to confirm the server is running + +### Signature validation failing + +- Ensure the secret in your route config exactly matches the secret configured in the webhook source +- For GitHub, the secret is HMAC-based — check `X-Hub-Signature-256` +- For GitLab, the secret is a plain token match — check `X-Gitlab-Token` +- Check gateway logs for `Invalid signature` warnings + +### Event being ignored + +- Check that the event type is in your route's `events` list +- GitHub events use values like `pull_request`, `push`, `issues` (the `X-GitHub-Event` header value) +- GitLab events use values like `merge_request`, `push` (the `X-GitLab-Event` header value) +- If `events` is empty or not set, all events are accepted + +### Agent not responding + +- Run the gateway in foreground to see logs: `hermes gateway run` +- Check that the prompt template is rendering correctly +- Verify the delivery target is configured and connected + +### Duplicate responses + +- The idempotency cache should prevent this — check that the webhook source is sending a delivery ID header (`X-GitHub-Delivery` or `X-Request-ID`) +- Delivery IDs are cached for 1 hour + +### `gh` CLI errors (GitHub comment delivery) + +- Run `gh auth login` on the gateway host +- Ensure the authenticated GitHub user has write access to the repository +- Check that `gh` is installed and on the PATH + +--- + +## Environment Variables {#environment-variables} + +| Variable | Description | Default | +|----------|-------------|---------| +| `WEBHOOK_ENABLED` | Enable the webhook platform adapter | `false` | +| `WEBHOOK_PORT` | HTTP server port for receiving webhooks | `8644` | +| `WEBHOOK_SECRET` | Global HMAC secret (used as fallback when routes don't specify their own) | _(none)_ | diff --git a/website/sidebars.ts b/website/sidebars.ts index a25aa105c..2d515e8af 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -52,6 +52,7 @@ const sidebars: SidebarsConfig = { 'user-guide/messaging/matrix', 'user-guide/messaging/dingtalk', 'user-guide/messaging/open-webui', + 'user-guide/messaging/webhooks', ], }, {