846 lines
44 KiB
Python
846 lines
44 KiB
Python
"""Generic webhook platform adapter: aiohttp server that validates HMAC-signed POSTs
|
|
(GitHub, GitLab, Svix, Linear, generic), renders payloads into agent prompts, and
|
|
routes responses back (github_comment or any gateway platform).
|
|
|
|
Routes live under platforms.webhook.extra.routes: events (header filter), secret
|
|
(REQUIRED; "INSECURE_NO_AUTH" skips validation, loopback bind only), prompt template,
|
|
skills, deliver/deliver_extra, deliver_only (rendered prompt IS the message). Per-route
|
|
rate limiting, idempotency cache for provider retries, body-size caps checked before
|
|
reading. Generic HMAC V2 binds a timestamp for replay protection; body-only V1 is
|
|
deprecated but accepted with a warning.
|
|
"""
|
|
|
|
import asyncio
|
|
import base64
|
|
import binascii
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from collections import deque
|
|
from contextlib import nullcontext, suppress
|
|
from typing import Any, Deque, 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
|
|
from gateway.platforms.webhook_filters import DEFAULT_SCRIPT_TIMEOUT_SECONDS, WebhookRouteProcessor
|
|
from gateway.response_filters import is_autonomous_silence_response
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Sentinel from _resolve_request_profile: a /p/<profile>/ prefix names a profile
|
|
# this gateway does not serve (→ 404). Distinct from None (no prefix / default).
|
|
_PROFILE_REJECTED = object()
|
|
_UNPARSEABLE = object()
|
|
|
|
_BUILTIN_DELIVER_PLATFORMS = {
|
|
"telegram", "discord", "slack", "signal", "sms", "whatsapp", "matrix", "mattermost",
|
|
"homeassistant", "email", "dingtalk", "feishu", "wecom", "wecom_callback", "weixin",
|
|
"bluebubbles", "qqbot", "yuanbao"}
|
|
|
|
# ``None`` → aiohttp binds BOTH address families. "0.0.0.0" is IPv4-only and was
|
|
# unreachable on IPv6-only networks (Fly.io 6PN); "::" becomes IPv6-only where the
|
|
# kernel sets IPV6_V6ONLY=1, breaking the 127.0.0.1 health check. Users can pin a
|
|
# host via ``platforms.webhook.extra.host``.
|
|
DEFAULT_HOST = None
|
|
DEFAULT_PORT = 8644
|
|
_INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
|
|
_DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json"
|
|
_RATE_WINDOW_SECONDS = 60.0
|
|
# Hosts that only serve same-machine connections; anything else is a public bind.
|
|
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "ip6-localhost", "ip6-loopback"})
|
|
_V2_REPLAY_WINDOW_SECONDS = 300
|
|
_TEMPLATE_KEY_RE = re.compile(r"\{([a-zA-Z0-9_.]+)\}")
|
|
_REPO_RE = re.compile(r"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+")
|
|
|
|
|
|
def _is_loopback_host(host: Optional[str]) -> bool:
|
|
"""True when `host` binds only to the local machine (falsy → non-loopback: usually a public default bind)."""
|
|
return bool(host) and host.strip().lower() in _LOOPBACK_HOSTS
|
|
|
|
|
|
def _hmac_str_equal(provided: str, expected: str) -> bool:
|
|
"""Timing-safe str equality tolerant of non-ASCII: ``compare_digest`` raises TypeError on non-ASCII
|
|
str and ``provided`` is an attacker-controlled header, so compare as UTF-8 bytes to fail closed."""
|
|
return hmac.compare_digest(provided.encode(), expected.encode())
|
|
|
|
|
|
def _hex_hmac(secret: str, data: bytes) -> str:
|
|
return hmac.new(secret.encode(), data, hashlib.sha256).hexdigest()
|
|
|
|
|
|
def _timestamp_age(raw: str) -> Optional[int]:
|
|
"""Seconds between now and an integer timestamp header; None when unparseable."""
|
|
try:
|
|
return abs(int(time.time()) - int(raw))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _json_error(message: str, status: int) -> "web.Response":
|
|
return web.json_response({"error": message}, status=status)
|
|
|
|
|
|
def check_webhook_requirements() -> bool:
|
|
"""Check if webhook adapter dependencies are available."""
|
|
return AIOHTTP_AVAILABLE
|
|
|
|
|
|
def _validate_svix_signature(body: bytes, secret: str, msg_id: str, timestamp: str, signature_header: str) -> bool:
|
|
"""Svix-compatible signatures (AgentMail): base64 HMAC-SHA256 of "{id}.{timestamp}.{body}"."""
|
|
if not (msg_id and timestamp and signature_header and secret):
|
|
return False
|
|
age = _timestamp_age(timestamp)
|
|
if age is None:
|
|
return False
|
|
if age > _V2_REPLAY_WINDOW_SECONDS:
|
|
logger.warning("[webhook] Svix signature timestamp outside replay window")
|
|
return False
|
|
if secret.startswith("whsec_"):
|
|
try:
|
|
key = base64.b64decode(secret.removeprefix("whsec_"), validate=True)
|
|
except (binascii.Error, ValueError):
|
|
logger.debug("[webhook] Invalid whsec_ Svix signing secret")
|
|
return False
|
|
else:
|
|
# Some providers document Svix-style headers but hand out raw shared secrets.
|
|
logger.debug("[webhook] Validating Svix-style signature with raw secret")
|
|
key = secret.encode()
|
|
signed_content = msg_id.encode() + b"." + timestamp.encode() + b"." + body
|
|
expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest()).decode()
|
|
# Multiple space-separated "vN,<base64>" entries during secret rotation.
|
|
for part in signature_header.split():
|
|
version, _, signature = part.partition(",")
|
|
if _ and version == "v1" and _hmac_str_equal(signature, expected):
|
|
return True
|
|
return False
|
|
|
|
|
|
class WebhookAdapter(BasePlatformAdapter):
|
|
"""Generic webhook receiver that triggers agent runs from HTTP POSTs."""
|
|
|
|
# Event-triggered, no human present: the startup auto-resume turn must FINISH
|
|
# the interrupted work instead of asking "what next?".
|
|
interactive_resume: bool = False
|
|
|
|
def __init__(self, config: PlatformConfig):
|
|
super().__init__(config, Platform.WEBHOOK)
|
|
extra = config.extra
|
|
# Empty string / null host normalises to None ("bind all families").
|
|
self._host: Optional[str] = extra.get("host", DEFAULT_HOST) or None
|
|
self._port: int = int(extra.get("port", DEFAULT_PORT))
|
|
self._global_secret: str = extra.get("secret", "")
|
|
self._static_routes: Dict[str, dict] = extra.get("routes", {})
|
|
self._dynamic_routes: Dict[str, dict] = {}
|
|
self._dynamic_routes_mtime: float = 0.0
|
|
self._routes: Dict[str, dict] = dict(self._static_routes)
|
|
self._runner = None
|
|
# Routes already warned about legacy V1 body-only signatures (once per route).
|
|
self._v1_signature_warned: set[str] = set()
|
|
# Keyed by session chat_id; read by EVERY send() (interim status messages AND
|
|
# the final response) so never pop on send(). TTL-pruned on each POST.
|
|
self._delivery_info: Dict[str, dict] = {}
|
|
self._delivery_info_created: Dict[str, float] = {}
|
|
self._delivery_info_order: Deque[tuple[float, str]] = deque()
|
|
# Gateway runner for cross-platform delivery (set externally).
|
|
self.gateway_runner = None
|
|
# Idempotency: TTL cache of recently processed delivery IDs.
|
|
self._seen_deliveries: Dict[str, float] = {}
|
|
self._idempotency_ttl: int = 3600 # 1 hour
|
|
self._seen_deliveries_next_prune_at: float = 0.0
|
|
# Rate limiting: per-route timestamps in a fixed window.
|
|
self._rate_counts: Dict[str, Deque[float]] = {}
|
|
self._rate_limit: int = int(extra.get("rate_limit", 30)) # per minute
|
|
self._max_body_bytes: int = int(extra.get("max_body_bytes", 1_048_576)) # 1MB
|
|
self._script_timeout_seconds: int = int(extra.get("script_timeout_seconds", DEFAULT_SCRIPT_TIMEOUT_SECONDS))
|
|
self._route_processor = WebhookRouteProcessor(script_timeout_seconds=self._script_timeout_seconds)
|
|
|
|
# --- Lifecycle ---
|
|
|
|
def _validate_route(self, name: str, route: dict) -> None:
|
|
"""Startup validation: secret is required; INSECURE_NO_AUTH only on loopback
|
|
(an unauthenticated route on a public interface is a footgun — crash early)."""
|
|
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}'.")
|
|
if secret == _INSECURE_NO_AUTH and not _is_loopback_host(self._host):
|
|
raise ValueError(
|
|
f"[webhook] Route '{name}' uses INSECURE_NO_AUTH secret "
|
|
f"but is bound to non-loopback host '{self._host}'. "
|
|
f"INSECURE_NO_AUTH is for local testing only. "
|
|
f"Refusing to start to prevent accidental exposure.")
|
|
if route.get("deliver_only"):
|
|
deliver = route.get("deliver", "log")
|
|
if not deliver or deliver == "log":
|
|
raise ValueError(
|
|
f"[webhook] Route '{name}' has deliver_only=true but "
|
|
f"deliver is '{deliver}'. Direct delivery requires a "
|
|
f"real target (telegram, discord, slack, github_comment, etc.).")
|
|
|
|
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
|
self._reload_dynamic_routes()
|
|
for name, route in self._routes.items():
|
|
self._validate_route(name, route)
|
|
# client_max_size enforces the cap on every read path, including chunked
|
|
# bodies without Content-Length that bypass the header check.
|
|
app = web.Application(client_max_size=self._max_body_bytes)
|
|
app.router.add_get("/health", self._handle_health)
|
|
app.router.add_post("/webhooks/{route_name}", self._handle_webhook)
|
|
# /p/<profile>/ prefix routes the event to that profile (only honored when
|
|
# gateway.multiplex_profiles is on; the handler validates).
|
|
app.router.add_post("/p/{profile}/webhooks/{route_name}", self._handle_webhook)
|
|
self._runner = web.AppRunner(app)
|
|
await self._runner.setup()
|
|
# SO_REUSEADDR: on macOS (BSD) two wildcard/specific sockets can silently split traffic
|
|
# while both report success → disable. On Linux it only permits rebinding past TIME_WAIT
|
|
# (a quick restart would otherwise fail to bind for ~60s) → keep the default.
|
|
site = web.TCPSite(
|
|
self._runner, self._host, self._port, reuse_address=False if sys.platform == "darwin" else None,
|
|
)
|
|
try:
|
|
await site.start()
|
|
except OSError as exc:
|
|
await self._runner.cleanup()
|
|
self._runner = None
|
|
logger.error(
|
|
"[webhook] Could not bind %s:%d: %s. "
|
|
"Set a different host or port in config.yaml under platforms.webhook.extra.",
|
|
self._host or "all IPv4+IPv6 interfaces", self._port, exc)
|
|
return False
|
|
self._mark_connected()
|
|
route_names = ", ".join(self._routes.keys()) or "(none configured)"
|
|
logger.info("[webhook] Listening on %s:%d — routes: %s",
|
|
self._host or "* (all interfaces, IPv4+IPv6)", self._port, route_names)
|
|
self._wire_plugin_handlers(None)
|
|
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 destination stored for ``chat_id``
|
|
(``webhook:{route}:{delivery_id}``) — read with ``.get()``, never popped."""
|
|
# Autonomous lane (no human reader): the loose marker matcher shared with cron —
|
|
# marker on its own first/last line — because models add a sentence explaining
|
|
# why they stayed quiet, which the interactive exact-match rule would deliver.
|
|
if is_autonomous_silence_response(content):
|
|
logger.info("[webhook] Response for %s is a silence marker — not delivering", chat_id)
|
|
return SendResult(success=True)
|
|
delivery = self._delivery_info.get(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: built-in names or plugin-registered platforms.
|
|
_is_known_platform = deliver_type in _BUILTIN_DELIVER_PLATFORMS
|
|
if not _is_known_platform:
|
|
with suppress(Exception):
|
|
from gateway.platform_registry import platform_registry
|
|
_is_known_platform = platform_registry.is_registered(deliver_type)
|
|
if self.gateway_runner and _is_known_platform:
|
|
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}")
|
|
|
|
def _prune_delivery_info(self, now: float) -> None:
|
|
"""Drop delivery_info entries older than the idempotency TTL (bounds the dict
|
|
by ``rate_limit * TTL`` even when runs never produce a final response)."""
|
|
if len(self._delivery_info_order) < len(self._delivery_info_created):
|
|
self._delivery_info_order = deque(
|
|
(created_at, key)
|
|
for key, created_at in sorted(self._delivery_info_created.items(), key=lambda item: item[1])
|
|
)
|
|
cutoff = now - self._idempotency_ttl
|
|
while self._delivery_info_order and self._delivery_info_order[0][0] < cutoff:
|
|
created_at, key = self._delivery_info_order.popleft()
|
|
if self._delivery_info_created.get(key) != created_at:
|
|
continue
|
|
self._delivery_info.pop(key, None)
|
|
self._delivery_info_created.pop(key, None)
|
|
|
|
def _prune_seen_deliveries(self, now: float) -> None:
|
|
"""Occasionally prune expired delivery IDs without scanning every POST."""
|
|
if now < self._seen_deliveries_next_prune_at:
|
|
return
|
|
cutoff = now - self._idempotency_ttl
|
|
for k in [k for k, t in self._seen_deliveries.items() if t < cutoff]:
|
|
self._seen_deliveries.pop(k, None)
|
|
self._seen_deliveries_next_prune_at = now + min(60.0, max(1.0, self._idempotency_ttl / 10))
|
|
|
|
def _record_rate_limit_hit(self, route_name: str, now: float) -> bool:
|
|
"""Return True if route is still within limit after recording this hit."""
|
|
window = self._rate_counts.get(route_name)
|
|
if not isinstance(window, deque):
|
|
window = self._rate_counts[route_name] = deque(window or ())
|
|
cutoff = now - _RATE_WINDOW_SECONDS
|
|
while window and window[0] < cutoff:
|
|
window.popleft()
|
|
if len(window) >= self._rate_limit:
|
|
return False
|
|
window.append(now)
|
|
return True
|
|
|
|
def _record_delivery_id(self, delivery_id: str, now: float) -> bool:
|
|
"""Return True when this delivery should be processed."""
|
|
seen_at = self._seen_deliveries.get(delivery_id)
|
|
if seen_at is not None and now - seen_at < self._idempotency_ttl:
|
|
return False
|
|
if seen_at is not None:
|
|
self._seen_deliveries.pop(delivery_id, None)
|
|
self._seen_deliveries[delivery_id] = now
|
|
if len(self._seen_deliveries) > max(self._rate_limit * 2, 128):
|
|
self._prune_seen_deliveries(now)
|
|
return True
|
|
|
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
return {"name": chat_id, "type": "webhook"}
|
|
|
|
def toolsets_for_source(self, source) -> Optional[List[str]]:
|
|
"""Per-route ``toolsets`` override (config.yaml or a manual key in
|
|
webhook_subscriptions.json — deliberately NOT settable via `hermes webhook
|
|
subscribe`, so an agent-created subscription cannot self-grant tools)."""
|
|
parts = str(getattr(source, "chat_id", "") or "").split(":", 2)
|
|
if len(parts) < 2 or parts[0] != "webhook":
|
|
return None
|
|
route_config = self._routes.get(parts[1])
|
|
if not isinstance(route_config, dict):
|
|
return None
|
|
toolsets = route_config.get("toolsets")
|
|
if not isinstance(toolsets, list) or not toolsets:
|
|
return None
|
|
return [str(t).strip() for t in toolsets if str(t).strip()] or None
|
|
|
|
# --- 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"})
|
|
|
|
def _dynamic_route_allowed(self, name: str, route: dict) -> bool:
|
|
"""An empty effective secret would make _handle_webhook skip HMAC validation →
|
|
reject such dynamic routes; INSECURE_NO_AUTH is loopback-only."""
|
|
effective_secret = route.get("secret", self._global_secret)
|
|
if not effective_secret:
|
|
logger.warning(
|
|
"[webhook] Dynamic route '%s' skipped: 'secret' is "
|
|
"missing or empty. Set a valid HMAC secret, or use "
|
|
"'%s' to explicitly disable auth (testing only).",
|
|
name, _INSECURE_NO_AUTH)
|
|
return False
|
|
if effective_secret == _INSECURE_NO_AUTH and not _is_loopback_host(self._host):
|
|
logger.warning(
|
|
"[webhook] Dynamic route '%s' skipped: INSECURE_NO_AUTH "
|
|
"is only allowed on loopback hosts. Current host: '%s'.",
|
|
name, self._host)
|
|
return False
|
|
return True
|
|
|
|
def _reload_dynamic_routes(self) -> None:
|
|
"""Reload agent-created subscriptions from disk if the file changed."""
|
|
from hermes_constants import get_hermes_home
|
|
subs_path = get_hermes_home() / _DYNAMIC_ROUTES_FILENAME
|
|
if not subs_path.exists():
|
|
if self._dynamic_routes:
|
|
self._dynamic_routes = {}
|
|
self._routes = dict(self._static_routes)
|
|
logger.debug("[webhook] Dynamic subscriptions file removed, cleared dynamic routes")
|
|
return
|
|
try:
|
|
mtime = subs_path.stat().st_mtime
|
|
if mtime <= self._dynamic_routes_mtime:
|
|
return # No change
|
|
data = json.loads(subs_path.read_text(encoding="utf-8"))
|
|
if not isinstance(data, dict):
|
|
return
|
|
# Static routes take precedence.
|
|
self._dynamic_routes = {
|
|
k: v for k, v in data.items()
|
|
if k not in self._static_routes and self._dynamic_route_allowed(k, v)}
|
|
self._routes = {**self._dynamic_routes, **self._static_routes}
|
|
self._dynamic_routes_mtime = mtime
|
|
logger.info("[webhook] Reloaded %d dynamic route(s): %s",
|
|
len(self._dynamic_routes), ", ".join(self._dynamic_routes.keys()) or "(none)")
|
|
except Exception as e:
|
|
logger.error("[webhook] Failed to reload dynamic routes: %s", e)
|
|
|
|
def _resolve_request_profile(self, request: "web.Request"):
|
|
"""Resolve + validate the /p/<profile>/ URL prefix: None (no prefix, or multiplexing off and the
|
|
prefix names this gateway's own profile), the profile name (served under multiplexing), or
|
|
``_PROFILE_REJECTED`` (unknown / not served → 404)."""
|
|
profile = (request.match_info.get("profile") or "").strip()
|
|
if not profile:
|
|
return None
|
|
cfg = getattr(self.gateway_runner, "config", None)
|
|
if not getattr(cfg, "multiplex_profiles", False):
|
|
# Only a self-referential prefix may fall through to the bare route;
|
|
# anything else fails closed (silently ignoring the prefix served the
|
|
# owner's routes under another profile's URL).
|
|
with suppress(Exception):
|
|
from hermes_cli.profiles import profile_matches_home
|
|
if profile_matches_home(profile):
|
|
return None
|
|
return _PROFILE_REJECTED
|
|
try:
|
|
from hermes_cli.profiles import profiles_to_serve
|
|
allowlist = getattr(cfg, "multiplex_profile_allowlist", None)
|
|
served = {name for name, _ in profiles_to_serve(multiplex=True, profile_allowlist=allowlist)}
|
|
except Exception:
|
|
return _PROFILE_REJECTED
|
|
return profile if profile in served else _PROFILE_REJECTED
|
|
|
|
@staticmethod
|
|
def _route_allows_profile(route_config: dict, request_profile: Optional[str]) -> bool:
|
|
"""Omitting ``profile`` binds a route to default; an explicit null/blank/non-string fails closed."""
|
|
configured_profile = route_config.get("profile") if "profile" in route_config else "default"
|
|
if not isinstance(configured_profile, str) or not configured_profile.strip():
|
|
return False
|
|
return configured_profile.strip() == (request_profile or "default")
|
|
|
|
@staticmethod
|
|
def _profile_scope(profile: Optional[str]):
|
|
"""Runtime scope for a resolved ``/p/<profile>/`` prefix; bare routes get a no-op."""
|
|
if not profile or not isinstance(profile, str):
|
|
return nullcontext()
|
|
from gateway.run import _profile_runtime_scope
|
|
from hermes_cli.profiles import get_profile_dir
|
|
return _profile_runtime_scope(get_profile_dir(profile))
|
|
|
|
async def _read_authenticated_body(
|
|
self, request: "web.Request", route_name: str, route_config: dict
|
|
) -> "tuple[Optional[bytes], Optional[web.Response]]":
|
|
"""Auth-before-body: size-cap, read, then HMAC-validate. Returns ``(body, None)`` or ``(None, response)``."""
|
|
if (request.content_length or 0) > self._max_body_bytes:
|
|
return None, _json_error("Payload too large", 413)
|
|
try:
|
|
raw_body = await request.read()
|
|
except web.HTTPRequestEntityTooLarge:
|
|
# aiohttp's client_max_size tripped — chunked or lying Content-Length.
|
|
return None, _json_error("Payload too large", 413)
|
|
except Exception as e:
|
|
logger.error("[webhook] Failed to read body: %s", e)
|
|
return None, _json_error("Bad request", 400)
|
|
if len(raw_body) > self._max_body_bytes:
|
|
# Defense in depth if the server-level limit was bypassed or misconfigured.
|
|
return None, _json_error("Payload too large", 413)
|
|
# Missing/empty secrets fail closed here too (not only in connect()), so
|
|
# direct handler reuse cannot become an unauthenticated dispatch surface.
|
|
secret = route_config.get("secret", self._global_secret)
|
|
if not secret:
|
|
logger.error("[webhook] Route %s has no HMAC secret; refusing request", route_name)
|
|
return None, _json_error("Webhook route is missing an HMAC secret", 403)
|
|
if secret != _INSECURE_NO_AUTH and not self._validate_signature(request, raw_body, secret):
|
|
logger.warning("[webhook] Invalid signature for route %s", route_name)
|
|
return None, _json_error("Invalid signature", 401)
|
|
return raw_body, None
|
|
|
|
@staticmethod
|
|
def _parse_body(raw_body: bytes) -> Any:
|
|
"""JSON, falling back to form-encoded; ``_UNPARSEABLE`` when neither parses."""
|
|
try:
|
|
return json.loads(raw_body)
|
|
except json.JSONDecodeError:
|
|
try:
|
|
import urllib.parse
|
|
return dict(urllib.parse.parse_qsl(raw_body.decode("utf-8")))
|
|
except Exception:
|
|
return _UNPARSEABLE
|
|
|
|
async def _handle_deliver_only(
|
|
self, prompt: str, payload: Any, route_config: dict, route_name: str, event_type: str, delivery_id: str
|
|
) -> "web.Response":
|
|
"""deliver_only: the rendered prompt IS the message — skip the agent, reuse
|
|
the same auth/rate-limit/idempotency/template pipeline."""
|
|
delivery = {
|
|
"deliver": route_config.get("deliver", "log"),
|
|
"deliver_extra": self._render_delivery_extra(route_config.get("deliver_extra", {}), payload),
|
|
"payload": payload}
|
|
logger.info("[webhook] direct-deliver event=%s route=%s target=%s msg_len=%d delivery=%s",
|
|
event_type, route_name, delivery["deliver"], len(prompt), delivery_id)
|
|
failed = {"status": "error", "error": "Delivery failed", "delivery_id": delivery_id}
|
|
try:
|
|
result = await self._direct_deliver(prompt, delivery)
|
|
except Exception:
|
|
logger.exception("[webhook] direct-deliver failed route=%s delivery=%s", route_name, delivery_id)
|
|
return web.json_response(failed, status=502)
|
|
if result.success:
|
|
return web.json_response(
|
|
{"status": "delivered", "route": route_name, "target": delivery["deliver"], "delivery_id": delivery_id},
|
|
status=200)
|
|
# Target rejected it — 502 with a generic error (don't leak adapter detail).
|
|
logger.warning("[webhook] direct-deliver target rejected route=%s target=%s error=%s",
|
|
route_name, delivery["deliver"], result.error)
|
|
return web.json_response(failed, status=502)
|
|
|
|
def _resolve_route(self, request: "web.Request") -> "tuple[str, Optional[dict], Any, Optional[web.Response]]":
|
|
"""Route + profile lookup for a POST; ``(route_name, route_config, profile, error_response)``."""
|
|
# Hot-reload dynamic subscriptions on each request (mtime-gated, cheap)
|
|
self._reload_dynamic_routes()
|
|
route_name = request.match_info.get("route_name", "")
|
|
route_config = self._routes.get(route_name)
|
|
profile = self._resolve_request_profile(request)
|
|
if profile is _PROFILE_REJECTED:
|
|
return route_name, None, profile, _json_error("Unknown or unconfigured profile", 404)
|
|
if not route_config:
|
|
return route_name, None, profile, _json_error(f"Unknown route: {route_name}", 404)
|
|
if not self._route_allows_profile(route_config, profile):
|
|
logger.warning("[webhook] Route %s is not authorized for profile %r", route_name, profile or "default")
|
|
# Same as unknown-route so profile mismatches can't enumerate route bindings.
|
|
return route_name, None, profile, _json_error(f"Unknown route: {route_name}", 404)
|
|
# Disabled routes stay in the subscriptions file (dashboard can re-enable)
|
|
# but reject events. Only an explicit ``enabled: false`` turns a route off.
|
|
if route_config.get("enabled", True) is False:
|
|
return route_name, None, profile, _json_error(f"Route disabled: {route_name}", 403)
|
|
return route_name, route_config, profile, None
|
|
|
|
@staticmethod
|
|
def _apply_skills(prompt: str, skills: list) -> str:
|
|
"""Inject the first matching skill via build_skill_invocation_message() directly —
|
|
/skill-name slash commands would be intercepted by the command parser."""
|
|
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:
|
|
return skill_content
|
|
else:
|
|
logger.warning("[webhook] Skill '%s' not found", skill_name)
|
|
except Exception as e:
|
|
logger.warning("[webhook] Skill loading failed: %s", e)
|
|
return prompt
|
|
|
|
async def _handle_webhook(self, request: "web.Request") -> "web.Response":
|
|
"""POST /webhooks/{route_name} — receive and process a webhook event."""
|
|
route_name, route_config, profile, error_response = self._resolve_route(request)
|
|
if error_response is None:
|
|
raw_body, error_response = await self._read_authenticated_body(request, route_name, route_config)
|
|
if error_response is not None:
|
|
return error_response
|
|
# Rate limiting (after auth)
|
|
if not self._record_rate_limit_hit(route_name, time.time()):
|
|
return _json_error("Rate limit exceeded", 429)
|
|
payload = self._parse_body(raw_body)
|
|
if payload is _UNPARSEABLE:
|
|
return _json_error("Cannot parse body", 400)
|
|
event_type = (
|
|
request.headers.get("X-GitHub-Event", "")
|
|
or request.headers.get("X-GitLab-Event", "")
|
|
or payload.get("event_type", "")
|
|
or payload.get("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})
|
|
if not self._route_processor.route_filters_match(route_config, payload, event_type, request.headers):
|
|
logger.info("[webhook] filtered event=%s route=%s", event_type, route_name)
|
|
return web.json_response({"status": "ignored", "reason": "filter", "route": route_name})
|
|
# Script, prompt render and skill lookup read the profile's home (skills/, config); the runner
|
|
# only enters the routed profile's scope later around handle_message, so enter it here.
|
|
with self._profile_scope(profile):
|
|
script = route_config.get("script")
|
|
if script:
|
|
# Shells out (up to its timeout) — worker thread so the loop isn't blocked; to_thread
|
|
# copies contextvars so the profile scope follows.
|
|
keep, transformed_payload = await asyncio.to_thread(
|
|
self._route_processor.run_route_script, script, payload)
|
|
if not keep:
|
|
logger.info("[webhook] script ignored event=%s route=%s", event_type, route_name)
|
|
return web.json_response({"status": "ignored", "reason": "script", "route": route_name})
|
|
payload = transformed_payload or payload
|
|
prompt = self._render_prompt(route_config.get("prompt", ""), payload, event_type, route_name)
|
|
skills = route_config.get("skills", [])
|
|
if skills:
|
|
prompt = self._apply_skills(prompt, skills)
|
|
headers = request.headers
|
|
delivery_id = headers.get("X-GitHub-Delivery", headers.get(
|
|
"svix-id", headers.get("X-Request-ID", str(int(time.time() * 1000)))))
|
|
# Idempotency: skip duplicate deliveries (webhook retries).
|
|
now = time.time()
|
|
if not self._record_delivery_id(delivery_id, now):
|
|
logger.info("[webhook] Skipping duplicate delivery %s", delivery_id)
|
|
return web.json_response({"status": "duplicate", "delivery_id": delivery_id}, status=200)
|
|
if route_config.get("deliver_only"):
|
|
return await self._handle_deliver_only(prompt, payload, route_config, route_name, event_type, delivery_id)
|
|
return self._dispatch_agent_run(request, route_config, route_name, profile, payload, prompt, event_type,
|
|
delivery_id, now)
|
|
|
|
def _dispatch_agent_run(
|
|
self, request, route_config: dict, route_name: str, profile, payload: Any, prompt: str,
|
|
event_type: str, delivery_id: str, now: float,
|
|
) -> "web.Response":
|
|
"""Record delivery info, spawn the agent run, and return 202 immediately."""
|
|
# delivery_id in the session key → concurrent webhooks on one route get
|
|
# independent agent runs (not queued/interrupted).
|
|
session_chat_id = f"webhook:{route_name}:{delivery_id}"
|
|
self._delivery_info[session_chat_id] = {
|
|
"deliver": route_config.get("deliver", "log"),
|
|
"deliver_extra": self._render_delivery_extra(route_config.get("deliver_extra", {}), payload)}
|
|
self._delivery_info_created[session_chat_id] = now
|
|
self._delivery_info_order.append((now, session_chat_id))
|
|
self._prune_delivery_info(now)
|
|
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)
|
|
if profile and isinstance(profile, str):
|
|
source.profile = profile
|
|
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)
|
|
# The per-delivery session is closed by ``on_processing_complete`` once the run
|
|
# finishes (``handle_message`` is fire-and-forget, so nothing can be closed here).
|
|
task = asyncio.create_task(self.handle_message(event))
|
|
self._background_tasks.add(task)
|
|
task.add_done_callback(self._background_tasks.discard)
|
|
return web.json_response(
|
|
{"status": "accepted", "route": route_name, "event": event_type, "delivery_id": delivery_id}, status=202)
|
|
|
|
async def on_processing_complete(self, event: "MessageEvent", outcome: Any) -> None:
|
|
"""Close the one-shot per-delivery session: ``prune_sessions`` only reaps rows with ``ended_at``
|
|
set, so unclosed webhook sessions leak unbounded. Fires at the true end of the run (success,
|
|
failure, cancellation); ``end_session()`` is first-reason-wins."""
|
|
await self._end_webhook_session(event, event.source.chat_id)
|
|
|
|
async def _end_webhook_session(self, event: "MessageEvent", session_chat_id: str) -> None:
|
|
"""Mark the per-delivery session ended via ``SessionDB.end_session`` (never a hand-written UPDATE),
|
|
resolving session_id from the SAME source the run was keyed on."""
|
|
runner = self.gateway_runner
|
|
if runner is None:
|
|
return
|
|
session_db = getattr(runner, "_session_db", None)
|
|
store = getattr(runner, "session_store", None)
|
|
if session_db is None or store is None:
|
|
return
|
|
try:
|
|
key_fn = getattr(runner, "_session_key_for_source", None)
|
|
if key_fn is None:
|
|
return
|
|
session_key = key_fn(event.source)
|
|
# Prefer the store's lock-held accessor; fall back to the private path
|
|
# only for older stores / test doubles that predate it.
|
|
peek = getattr(store, "peek_session_id", None)
|
|
if callable(peek):
|
|
session_id = peek(session_key)
|
|
else:
|
|
if hasattr(store, "_ensure_loaded"):
|
|
with suppress(Exception):
|
|
store._ensure_loaded()
|
|
entry = (getattr(store, "_entries", {}) or {}).get(session_key)
|
|
session_id = getattr(entry, "session_id", None) if entry else None
|
|
if not session_id:
|
|
logger.debug("[webhook] No session_id to close for %s (key=%s)", session_chat_id, session_key)
|
|
return
|
|
# AsyncSessionDB forwards end_session via to_thread; plain SessionDB is sync.
|
|
result = session_db.end_session(session_id, "webhook_complete")
|
|
if asyncio.iscoroutine(result):
|
|
await result
|
|
logger.debug("[webhook] Closed session %s for delivery %s", session_id, session_chat_id)
|
|
except Exception as e:
|
|
logger.debug("[webhook] Failed to close session for %s: %s", session_chat_id, e)
|
|
|
|
# --- Signature validation ---
|
|
|
|
def _validate_signature(self, request: "web.Request", body: bytes, secret: str) -> bool:
|
|
"""Validate webhook signature (GitHub, GitLab, Svix, Linear, generic HMAC-SHA256)."""
|
|
headers = request.headers
|
|
|
|
def _header(name: str) -> str:
|
|
return headers.get(name, "") or headers.get(name.lower(), "") or headers.get(name.upper(), "")
|
|
|
|
# Svix / AgentMail: signed content is "{id}.{timestamp}.{raw_body}".
|
|
svix = [_header(name) for name in ("svix-id", "svix-timestamp", "svix-signature")]
|
|
if any(svix):
|
|
return _validate_svix_signature(body, secret, *svix)
|
|
# Linear: linear-signature = hex HMAC-SHA256 of the raw body (no timestamp binding).
|
|
linear_sig = _header("linear-signature")
|
|
if linear_sig:
|
|
return _hmac_str_equal(linear_sig, _hex_hmac(secret, body))
|
|
# GitHub: X-Hub-Signature-256 = sha256=<hex>
|
|
gh_sig = headers.get("X-Hub-Signature-256", "")
|
|
if gh_sig:
|
|
return _hmac_str_equal(gh_sig, "sha256=" + _hex_hmac(secret, body))
|
|
# GitLab: X-Gitlab-Token = <plain secret>
|
|
gl_token = headers.get("X-Gitlab-Token", "")
|
|
if gl_token:
|
|
return _hmac_str_equal(gl_token, secret)
|
|
route_name = request.match_info.get("route_name", "")
|
|
# Generic V2: X-Webhook-Signature-V2 = hex HMAC-SHA256 of "<timestamp>.<body>",
|
|
# X-Webhook-Timestamp required. Presence of the V2 header COMMITS to V2 — it
|
|
# must not fall through to V1 on a missing/bad timestamp, or an attacker could
|
|
# strip the timestamp from a captured mixed V1+V2 request and replay it
|
|
# against the still-present body-only V1 signature.
|
|
v2_sig = headers.get("X-Webhook-Signature-V2", "")
|
|
if v2_sig:
|
|
v2_timestamp = headers.get("X-Webhook-Timestamp", "")
|
|
if not v2_timestamp:
|
|
logger.warning(
|
|
"[webhook] Route '%s' sent X-Webhook-Signature-V2 with "
|
|
"no X-Webhook-Timestamp — rejecting rather than falling back to legacy V1",
|
|
route_name)
|
|
return False
|
|
age = _timestamp_age(v2_timestamp)
|
|
if age is None:
|
|
return False
|
|
if age > _V2_REPLAY_WINDOW_SECONDS:
|
|
logger.warning("[webhook] Route '%s' generic HMAC V2 timestamp outside replay window", route_name)
|
|
return False
|
|
return _hmac_str_equal(v2_sig, _hex_hmac(secret, v2_timestamp.encode() + b"." + body))
|
|
# Generic V1 (legacy, deprecated): body-only HMAC → replays indefinitely.
|
|
generic_sig = headers.get("X-Webhook-Signature", "")
|
|
if generic_sig:
|
|
if route_name not in self._v1_signature_warned:
|
|
self._v1_signature_warned.add(route_name)
|
|
logger.warning(
|
|
"[webhook] Route '%s' uses legacy body-only HMAC (no "
|
|
"timestamp), which is vulnerable to replay attacks. Add "
|
|
"an 'X-Webhook-Timestamp' header and switch to "
|
|
"'X-Webhook-Signature-V2' (HMAC-SHA256 of '<timestamp>.<body>').",
|
|
route_name)
|
|
return _hmac_str_equal(generic_sig, _hex_hmac(secret, body))
|
|
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 dot-notation payload access (``{pull_request.title}``);
|
|
``{__raw__}`` dumps the whole payload as indented JSON (truncated to 4000 chars)."""
|
|
if not template:
|
|
truncated = json.dumps(payload, indent=2)[:4000]
|
|
return f"Webhook event '{event_type}' on route '{route_name}':\n\n```json\n{truncated}\n```"
|
|
|
|
def _resolve(match: re.Match) -> str:
|
|
key = match.group(1)
|
|
if key == "__raw__":
|
|
return json.dumps(payload, indent=2)[:4000]
|
|
if key == "event_type":
|
|
return event_type
|
|
value: Any = payload
|
|
for part in key.split("."):
|
|
if not isinstance(value, dict):
|
|
return f"{{{key}}}"
|
|
value = value.get(part, f"{{{key}}}")
|
|
if isinstance(value, (dict, list)):
|
|
return json.dumps(value, indent=2)[:2000]
|
|
return str(value)
|
|
|
|
return _TEMPLATE_KEY_RE.sub(_resolve, template)
|
|
|
|
def _render_delivery_extra(self, extra: dict, payload: dict) -> dict:
|
|
"""Render delivery_extra template values with payload data."""
|
|
return {
|
|
key: self._render_prompt(value, payload, "", "") if isinstance(value, str) else value
|
|
for key, value in extra.items()}
|
|
|
|
# --- Response delivery ---
|
|
|
|
async def _direct_deliver(self, content: str, delivery: dict) -> SendResult:
|
|
"""deliver_only: dispatch *content* to the same delivery helpers agent-mode ``send()`` uses."""
|
|
deliver_type = delivery.get("deliver", "log")
|
|
if deliver_type == "log":
|
|
# Startup validation rejects deliver_only + log, but guard defensively.
|
|
logger.info("[webhook] direct-deliver log-only: %s", content[:200])
|
|
return SendResult(success=True)
|
|
if deliver_type == "github_comment":
|
|
return await self._deliver_github_comment(content, delivery)
|
|
return await self._deliver_cross_platform(deliver_type, content, 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")
|
|
# Input validation (prevent CLI argument injection).
|
|
try:
|
|
pr_int = int(pr_number)
|
|
if pr_int <= 0:
|
|
raise ValueError("non-positive")
|
|
except (ValueError, TypeError):
|
|
logger.error("[webhook] invalid pr_number: %r", pr_number)
|
|
return SendResult(success=False, error="Invalid pr_number")
|
|
if not _REPO_RE.fullmatch(repo):
|
|
logger.error("[webhook] invalid repo format: %r", repo)
|
|
return SendResult(success=False, error="Invalid repo format")
|
|
try:
|
|
# Off-loop: `gh` does network I/O up to its 30s timeout; inline it froze
|
|
# every adapter and timer on the gateway event loop.
|
|
result = await asyncio.to_thread(
|
|
subprocess.run,
|
|
["gh", "pr", "comment", str(pr_int), "--repo", repo, "--body", content],
|
|
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=30)
|
|
if result.returncode == 0:
|
|
logger.info("[webhook] Posted comment on %s#%s", repo, pr_number)
|
|
return SendResult(success=True)
|
|
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))
|
|
|
|
def _find_adapter(self, target_platform: Platform):
|
|
"""Default adapters first; multiplex may park a platform only on a secondary
|
|
profile (_profile_adapters), so fall back to those."""
|
|
adapter = self.gateway_runner.adapters.get(target_platform)
|
|
if adapter:
|
|
return adapter
|
|
for amap in (getattr(self.gateway_runner, "_profile_adapters", None) or {}).values():
|
|
if isinstance(amap, dict) and amap.get(target_platform) is not None:
|
|
return amap[target_platform]
|
|
return None
|
|
|
|
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._find_adapter(target_platform)
|
|
if not adapter:
|
|
return SendResult(success=False, error=f"Platform {platform_name} not connected")
|
|
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 not home:
|
|
return SendResult(success=False, error=f"No chat_id or home channel for {platform_name}")
|
|
chat_id = home.chat_id
|
|
# thread_id from deliver_extra so Telegram forum topics work.
|
|
thread_id = extra.get("message_thread_id") or extra.get("thread_id")
|
|
metadata = {"thread_id": thread_id} if thread_id else None
|
|
return await adapter.send(chat_id, content, metadata=metadata)
|