From dabe3c34cc780455fd89f0ca8f08cb35a06643fe Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:33:35 -0700 Subject: [PATCH] feat(webhook): hermes webhook CLI + skill for event-driven subscriptions (#3578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 'hermes webhook' CLI subcommand and a skill — zero new model tools. CLI commands (require webhook platform to be enabled): hermes webhook subscribe [--events, --prompt, --deliver, ...] hermes webhook list hermes webhook remove hermes webhook test All commands gate on webhook platform being enabled in config. If not configured, prints setup instructions (gateway setup wizard, manual config.yaml, or env vars). The agent uses these via terminal tool, guided by the webhook-subscriptions skill which documents setup, common patterns (GitHub, Stripe, CI/CD, monitoring), prompt template syntax, security, and troubleshooting. Adapter enhancement: webhook.py hot-reloads dynamic subscriptions from ~/.hermes/webhook_subscriptions.json on each incoming request (mtime-gated). Static config.yaml routes always take precedence. Docs: updated webhooks.md with Dynamic Subscriptions section, added hermes webhook to cli-commands.md reference. No new model tools. No toolset changes. 24 new tests for CLI CRUD, persistence, enabled-gate, and adapter dynamic route loading. --- gateway/platforms/webhook.py | 48 +++- hermes_cli/main.py | 39 ++- hermes_cli/webhook.py | 256 ++++++++++++++++++ skills/devops/webhook-subscriptions/SKILL.md | 180 ++++++++++++ tests/gateway/test_webhook_dynamic_routes.py | 87 ++++++ tests/hermes_cli/test_webhook_cli.py | 189 +++++++++++++ website/docs/reference/cli-commands.md | 34 +++ website/docs/reference/skills-catalog.md | 8 + website/docs/user-guide/messaging/webhooks.md | 52 +++- 9 files changed, 890 insertions(+), 3 deletions(-) create mode 100644 hermes_cli/webhook.py create mode 100644 skills/devops/webhook-subscriptions/SKILL.md create mode 100644 tests/gateway/test_webhook_dynamic_routes.py create mode 100644 tests/hermes_cli/test_webhook_cli.py diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 2d75879b..841d6160 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -27,6 +27,7 @@ import hashlib import hmac import json import logging +import os import re import subprocess import time @@ -53,6 +54,7 @@ logger = logging.getLogger(__name__) DEFAULT_HOST = "0.0.0.0" DEFAULT_PORT = 8644 _INSECURE_NO_AUTH = "INSECURE_NO_AUTH" +_DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json" def check_webhook_requirements() -> bool: @@ -68,7 +70,10 @@ class WebhookAdapter(BasePlatformAdapter): 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._static_routes: Dict[str, dict] = config.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 # Delivery info keyed by session chat_id — consumed by send() @@ -96,6 +101,9 @@ class WebhookAdapter(BasePlatformAdapter): # ------------------------------------------------------------------ async def connect(self) -> bool: + # Load agent-created subscriptions before validating + self._reload_dynamic_routes() + # Validate routes at startup — secret is required per route for name, route in self._routes.items(): secret = route.get("secret", self._global_secret) @@ -182,8 +190,46 @@ class WebhookAdapter(BasePlatformAdapter): """GET /health — simple health check.""" return web.json_response({"status": "ok", "platform": "webhook"}) + def _reload_dynamic_routes(self) -> None: + """Reload agent-created subscriptions from disk if the file changed.""" + from pathlib import Path as _Path + hermes_home = _Path( + os.getenv("HERMES_HOME", str(_Path.home() / ".hermes")) + ).expanduser() + subs_path = 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 + # Merge: static routes take precedence over dynamic ones + self._dynamic_routes = { + k: v for k, v in data.items() + if k not in self._static_routes + } + 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.warning("[webhook] Failed to reload dynamic routes: %s", e) + async def _handle_webhook(self, request: "web.Request") -> "web.Response": """POST /webhooks/{route_name} — receive and process a webhook event.""" + # 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) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index abd6bd0f..1cf6e459 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2339,6 +2339,12 @@ def cmd_cron(args): cron_command(args) +def cmd_webhook(args): + """Webhook subscription management.""" + from hermes_cli.webhook import webhook_command + webhook_command(args) + + def cmd_doctor(args): """Check configuration and dependencies.""" from hermes_cli.doctor import run_doctor @@ -3523,7 +3529,38 @@ For more help on a command: cron_subparsers.add_parser("tick", help="Run due jobs once and exit") cron_parser.set_defaults(func=cmd_cron) - + + # ========================================================================= + # webhook command + # ========================================================================= + webhook_parser = subparsers.add_parser( + "webhook", + help="Manage dynamic webhook subscriptions", + description="Create, list, and remove webhook subscriptions for event-driven agent activation", + ) + webhook_subparsers = webhook_parser.add_subparsers(dest="webhook_action") + + wh_sub = webhook_subparsers.add_parser("subscribe", aliases=["add"], help="Create a webhook subscription") + wh_sub.add_argument("name", help="Route name (used in URL: /webhooks/)") + wh_sub.add_argument("--prompt", default="", help="Prompt template with {dot.notation} payload refs") + wh_sub.add_argument("--events", default="", help="Comma-separated event types to accept") + wh_sub.add_argument("--description", default="", help="What this subscription does") + wh_sub.add_argument("--skills", default="", help="Comma-separated skill names to load") + wh_sub.add_argument("--deliver", default="log", help="Delivery target: log, telegram, discord, slack, etc.") + wh_sub.add_argument("--deliver-chat-id", default="", help="Target chat ID for cross-platform delivery") + wh_sub.add_argument("--secret", default="", help="HMAC secret (auto-generated if omitted)") + + webhook_subparsers.add_parser("list", aliases=["ls"], help="List all dynamic subscriptions") + + wh_rm = webhook_subparsers.add_parser("remove", aliases=["rm"], help="Remove a subscription") + wh_rm.add_argument("name", help="Subscription name to remove") + + wh_test = webhook_subparsers.add_parser("test", help="Send a test POST to a webhook route") + wh_test.add_argument("name", help="Subscription name to test") + wh_test.add_argument("--payload", default="", help="JSON payload to send (default: test payload)") + + webhook_parser.set_defaults(func=cmd_webhook) + # ========================================================================= # doctor command # ========================================================================= diff --git a/hermes_cli/webhook.py b/hermes_cli/webhook.py new file mode 100644 index 00000000..7514ddd1 --- /dev/null +++ b/hermes_cli/webhook.py @@ -0,0 +1,256 @@ +"""hermes webhook — manage dynamic webhook subscriptions from the CLI. + +Usage: + hermes webhook subscribe [options] + hermes webhook list + hermes webhook remove + hermes webhook test [--payload '{"key": "value"}'] + +Subscriptions persist to ~/.hermes/webhook_subscriptions.json and are +hot-reloaded by the webhook adapter without a gateway restart. +""" + +import json +import os +import re +import secrets +import time +from pathlib import Path +from typing import Dict, Optional + + +_SUBSCRIPTIONS_FILENAME = "webhook_subscriptions.json" + + +def _hermes_home() -> Path: + return Path( + os.getenv("HERMES_HOME", str(Path.home() / ".hermes")) + ).expanduser() + + +def _subscriptions_path() -> Path: + return _hermes_home() / _SUBSCRIPTIONS_FILENAME + + +def _load_subscriptions() -> Dict[str, dict]: + path = _subscriptions_path() + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _save_subscriptions(subs: Dict[str, dict]) -> None: + path = _subscriptions_path() + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(".tmp") + tmp_path.write_text( + json.dumps(subs, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + os.replace(str(tmp_path), str(path)) + + +def _get_webhook_config() -> dict: + """Load webhook platform config. Returns {} if not configured.""" + try: + from hermes_cli.config import load_config + cfg = load_config() + return cfg.get("platforms", {}).get("webhook", {}) + except Exception: + return {} + + +def _is_webhook_enabled() -> bool: + return bool(_get_webhook_config().get("enabled")) + + +def _get_webhook_base_url() -> str: + wh = _get_webhook_config().get("extra", {}) + host = wh.get("host", "0.0.0.0") + port = wh.get("port", 8644) + display_host = "localhost" if host == "0.0.0.0" else host + return f"http://{display_host}:{port}" + + +_SETUP_HINT = """ + Webhook platform is not enabled. To set it up: + + 1. Run the gateway setup wizard: + hermes gateway setup + + 2. Or manually add to ~/.hermes/config.yaml: + platforms: + webhook: + enabled: true + extra: + host: "0.0.0.0" + port: 8644 + secret: "your-global-hmac-secret" + + 3. Or set environment variables in ~/.hermes/.env: + WEBHOOK_ENABLED=true + WEBHOOK_PORT=8644 + WEBHOOK_SECRET=your-global-secret + + Then start the gateway: hermes gateway run +""" + + +def _require_webhook_enabled() -> bool: + """Check webhook is enabled. Print setup guide and return False if not.""" + if _is_webhook_enabled(): + return True + print(_SETUP_HINT) + return False + + +def webhook_command(args): + """Entry point for 'hermes webhook' subcommand.""" + sub = getattr(args, "webhook_action", None) + + if not sub: + print("Usage: hermes webhook {subscribe|list|remove|test}") + print("Run 'hermes webhook --help' for details.") + return + + if not _require_webhook_enabled(): + return + + if sub in ("subscribe", "add"): + _cmd_subscribe(args) + elif sub in ("list", "ls"): + _cmd_list(args) + elif sub in ("remove", "rm"): + _cmd_remove(args) + elif sub == "test": + _cmd_test(args) + + +def _cmd_subscribe(args): + name = args.name.strip().lower().replace(" ", "-") + if not re.match(r'^[a-z0-9][a-z0-9_-]*$', name): + print(f"Error: Invalid name '{name}'. Use lowercase alphanumeric with hyphens/underscores.") + return + + subs = _load_subscriptions() + is_update = name in subs + + secret = args.secret or secrets.token_urlsafe(32) + events = [e.strip() for e in args.events.split(",")] if args.events else [] + + route = { + "description": args.description or f"Agent-created subscription: {name}", + "events": events, + "secret": secret, + "prompt": args.prompt or "", + "skills": [s.strip() for s in args.skills.split(",")] if args.skills else [], + "deliver": args.deliver or "log", + "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + + if args.deliver_chat_id: + route["deliver_extra"] = {"chat_id": args.deliver_chat_id} + + subs[name] = route + _save_subscriptions(subs) + + base_url = _get_webhook_base_url() + status = "Updated" if is_update else "Created" + + print(f"\n {status} webhook subscription: {name}") + print(f" URL: {base_url}/webhooks/{name}") + print(f" Secret: {secret}") + if events: + print(f" Events: {', '.join(events)}") + else: + print(" Events: (all)") + print(f" Deliver: {route['deliver']}") + if route.get("prompt"): + prompt_preview = route["prompt"][:80] + ("..." if len(route["prompt"]) > 80 else "") + print(f" Prompt: {prompt_preview}") + print(f"\n Configure your service to POST to the URL above.") + print(f" Use the secret for HMAC-SHA256 signature validation.") + print(f" The gateway must be running to receive events (hermes gateway run).\n") + + +def _cmd_list(args): + subs = _load_subscriptions() + if not subs: + print(" No dynamic webhook subscriptions.") + print(" Create one with: hermes webhook subscribe ") + return + + base_url = _get_webhook_base_url() + print(f"\n {len(subs)} webhook subscription(s):\n") + for name, route in subs.items(): + events = ", ".join(route.get("events", [])) or "(all)" + deliver = route.get("deliver", "log") + desc = route.get("description", "") + print(f" ◆ {name}") + if desc: + print(f" {desc}") + print(f" URL: {base_url}/webhooks/{name}") + print(f" Events: {events}") + print(f" Deliver: {deliver}") + print() + + +def _cmd_remove(args): + name = args.name.strip().lower() + subs = _load_subscriptions() + + if name not in subs: + print(f" No subscription named '{name}'.") + print(" Note: Static routes from config.yaml cannot be removed here.") + return + + del subs[name] + _save_subscriptions(subs) + print(f" Removed webhook subscription: {name}") + + +def _cmd_test(args): + """Send a test POST to a webhook route.""" + name = args.name.strip().lower() + subs = _load_subscriptions() + + if name not in subs: + print(f" No subscription named '{name}'.") + return + + route = subs[name] + secret = route.get("secret", "") + base_url = _get_webhook_base_url() + url = f"{base_url}/webhooks/{name}" + + payload = args.payload or '{"test": true, "event_type": "test", "message": "Hello from hermes webhook test"}' + + import hmac + import hashlib + sig = "sha256=" + hmac.new( + secret.encode(), payload.encode(), hashlib.sha256 + ).hexdigest() + + print(f" Sending test POST to {url}") + try: + import urllib.request + req = urllib.request.Request( + url, + data=payload.encode(), + headers={ + "Content-Type": "application/json", + "X-Hub-Signature-256": sig, + "X-GitHub-Event": "test", + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=10) as resp: + body = resp.read().decode() + print(f" Response ({resp.status}): {body}") + except Exception as e: + print(f" Error: {e}") + print(" Is the gateway running? (hermes gateway run)") diff --git a/skills/devops/webhook-subscriptions/SKILL.md b/skills/devops/webhook-subscriptions/SKILL.md new file mode 100644 index 00000000..e5ab6d58 --- /dev/null +++ b/skills/devops/webhook-subscriptions/SKILL.md @@ -0,0 +1,180 @@ +--- +name: webhook-subscriptions +description: Create and manage webhook subscriptions for event-driven agent activation. Use when the user wants external services to trigger agent runs automatically. +version: 1.0.0 +metadata: + hermes: + tags: [webhook, events, automation, integrations] +--- + +# Webhook Subscriptions + +Create dynamic webhook subscriptions so external services (GitHub, GitLab, Stripe, CI/CD, IoT sensors, monitoring tools) can trigger Hermes agent runs by POSTing events to a URL. + +## Setup (Required First) + +The webhook platform must be enabled before subscriptions can be created. Check with: +```bash +hermes webhook list +``` + +If it says "Webhook platform is not enabled", set it up: + +### Option 1: Setup wizard +```bash +hermes gateway setup +``` +Follow the prompts to enable webhooks, set the port, and set a global HMAC secret. + +### Option 2: Manual config +Add to `~/.hermes/config.yaml`: +```yaml +platforms: + webhook: + enabled: true + extra: + host: "0.0.0.0" + port: 8644 + secret: "generate-a-strong-secret-here" +``` + +### Option 3: Environment variables +Add to `~/.hermes/.env`: +```bash +WEBHOOK_ENABLED=true +WEBHOOK_PORT=8644 +WEBHOOK_SECRET=generate-a-strong-secret-here +``` + +After configuration, start (or restart) the gateway: +```bash +hermes gateway run +# Or if using systemd: +systemctl --user restart hermes-gateway +``` + +Verify it's running: +```bash +curl http://localhost:8644/health +``` + +## Commands + +All management is via the `hermes webhook` CLI command: + +### Create a subscription +```bash +hermes webhook subscribe \ + --prompt "Prompt template with {payload.fields}" \ + --events "event1,event2" \ + --description "What this does" \ + --skills "skill1,skill2" \ + --deliver telegram \ + --deliver-chat-id "12345" \ + --secret "optional-custom-secret" +``` + +Returns the webhook URL and HMAC secret. The user configures their service to POST to that URL. + +### List subscriptions +```bash +hermes webhook list +``` + +### Remove a subscription +```bash +hermes webhook remove +``` + +### Test a subscription +```bash +hermes webhook test +hermes webhook test --payload '{"key": "value"}' +``` + +## Prompt Templates + +Prompts support `{dot.notation}` for accessing nested payload fields: + +- `{issue.title}` — GitHub issue title +- `{pull_request.user.login}` — PR author +- `{data.object.amount}` — Stripe payment amount +- `{sensor.temperature}` — IoT sensor reading + +If no prompt is specified, the full JSON payload is dumped into the agent prompt. + +## Common Patterns + +### GitHub: new issues +```bash +hermes webhook subscribe github-issues \ + --events "issues" \ + --prompt "New GitHub issue #{issue.number}: {issue.title}\n\nAction: {action}\nAuthor: {issue.user.login}\nBody:\n{issue.body}\n\nPlease triage this issue." \ + --deliver telegram \ + --deliver-chat-id "-100123456789" +``` + +Then in GitHub repo Settings → Webhooks → Add webhook: +- Payload URL: the returned webhook_url +- Content type: application/json +- Secret: the returned secret +- Events: "Issues" + +### GitHub: PR reviews +```bash +hermes webhook subscribe github-prs \ + --events "pull_request" \ + --prompt "PR #{pull_request.number} {action}: {pull_request.title}\nBy: {pull_request.user.login}\nBranch: {pull_request.head.ref}\n\n{pull_request.body}" \ + --skills "github-code-review" \ + --deliver github_comment +``` + +### Stripe: payment events +```bash +hermes webhook subscribe stripe-payments \ + --events "payment_intent.succeeded,payment_intent.payment_failed" \ + --prompt "Payment {data.object.status}: {data.object.amount} cents from {data.object.receipt_email}" \ + --deliver telegram \ + --deliver-chat-id "-100123456789" +``` + +### CI/CD: build notifications +```bash +hermes webhook subscribe ci-builds \ + --events "pipeline" \ + --prompt "Build {object_attributes.status} on {project.name} branch {object_attributes.ref}\nCommit: {commit.message}" \ + --deliver discord \ + --deliver-chat-id "1234567890" +``` + +### Generic monitoring alert +```bash +hermes webhook subscribe alerts \ + --prompt "Alert: {alert.name}\nSeverity: {alert.severity}\nMessage: {alert.message}\n\nPlease investigate and suggest remediation." \ + --deliver origin +``` + +## Security + +- Each subscription gets an auto-generated HMAC-SHA256 secret (or provide your own with `--secret`) +- The webhook adapter validates signatures on every incoming POST +- Static routes from config.yaml cannot be overwritten by dynamic subscriptions +- Subscriptions persist to `~/.hermes/webhook_subscriptions.json` + +## How It Works + +1. `hermes webhook subscribe` writes to `~/.hermes/webhook_subscriptions.json` +2. The webhook adapter hot-reloads this file on each incoming request (mtime-gated, negligible overhead) +3. When a POST arrives matching a route, the adapter formats the prompt and triggers an agent run +4. The agent's response is delivered to the configured target (Telegram, Discord, GitHub comment, etc.) + +## Troubleshooting + +If webhooks aren't working: + +1. **Is the gateway running?** Check with `systemctl --user status hermes-gateway` or `ps aux | grep gateway` +2. **Is the webhook server listening?** `curl http://localhost:8644/health` should return `{"status": "ok"}` +3. **Check gateway logs:** `grep webhook ~/.hermes/logs/gateway.log | tail -20` +4. **Signature mismatch?** Verify the secret in your service matches the one from `hermes webhook list`. GitHub sends `X-Hub-Signature-256`, GitLab sends `X-Gitlab-Token`. +5. **Firewall/NAT?** The webhook URL must be reachable from the service. For local development, use a tunnel (ngrok, cloudflared). +6. **Wrong event type?** Check `--events` filter matches what the service sends. Use `hermes webhook test ` to verify the route works. diff --git a/tests/gateway/test_webhook_dynamic_routes.py b/tests/gateway/test_webhook_dynamic_routes.py new file mode 100644 index 00000000..2029dd13 --- /dev/null +++ b/tests/gateway/test_webhook_dynamic_routes.py @@ -0,0 +1,87 @@ +"""Tests for webhook adapter dynamic route loading.""" + +import json +import os +import pytest +from pathlib import Path + +from gateway.config import PlatformConfig +from gateway.platforms.webhook import WebhookAdapter, _DYNAMIC_ROUTES_FILENAME + + +def _make_adapter(routes=None, extra=None): + _extra = extra or {} + if routes: + _extra["routes"] = routes + _extra.setdefault("secret", "test-global-secret") + config = PlatformConfig(enabled=True, extra=_extra) + return WebhookAdapter(config) + + +@pytest.fixture(autouse=True) +def _isolate(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + +class TestDynamicRouteLoading: + def test_no_dynamic_file(self): + adapter = _make_adapter(routes={"static": {"secret": "s"}}) + adapter._reload_dynamic_routes() + assert "static" in adapter._routes + assert len(adapter._dynamic_routes) == 0 + + def test_loads_dynamic_routes(self, tmp_path): + subs = {"my-hook": {"secret": "dynamic-secret", "prompt": "test", "events": []}} + (tmp_path / _DYNAMIC_ROUTES_FILENAME).write_text(json.dumps(subs)) + + adapter = _make_adapter(routes={"static": {"secret": "s"}}) + adapter._reload_dynamic_routes() + assert "my-hook" in adapter._routes + assert "static" in adapter._routes + + def test_static_takes_precedence(self, tmp_path): + (tmp_path / _DYNAMIC_ROUTES_FILENAME).write_text( + json.dumps({"conflict": {"secret": "dynamic", "prompt": "dyn"}}) + ) + adapter = _make_adapter(routes={"conflict": {"secret": "static", "prompt": "stat"}}) + adapter._reload_dynamic_routes() + assert adapter._routes["conflict"]["secret"] == "static" + + def test_mtime_gated(self, tmp_path): + import time + path = tmp_path / _DYNAMIC_ROUTES_FILENAME + path.write_text(json.dumps({"v1": {"secret": "s"}})) + + adapter = _make_adapter() + adapter._reload_dynamic_routes() + assert "v1" in adapter._dynamic_routes + + # Same mtime — no reload + adapter._dynamic_routes["injected"] = True + adapter._reload_dynamic_routes() + assert "injected" in adapter._dynamic_routes + + # New write — reloads + time.sleep(0.05) + path.write_text(json.dumps({"v2": {"secret": "s"}})) + adapter._reload_dynamic_routes() + assert "v2" in adapter._dynamic_routes + assert "v1" not in adapter._dynamic_routes + + def test_file_removal_clears(self, tmp_path): + path = tmp_path / _DYNAMIC_ROUTES_FILENAME + path.write_text(json.dumps({"temp": {"secret": "s"}})) + adapter = _make_adapter() + adapter._reload_dynamic_routes() + assert "temp" in adapter._dynamic_routes + + path.unlink() + adapter._reload_dynamic_routes() + assert len(adapter._dynamic_routes) == 0 + + def test_corrupted_file(self, tmp_path): + (tmp_path / _DYNAMIC_ROUTES_FILENAME).write_text("not json") + adapter = _make_adapter(routes={"static": {"secret": "s"}}) + adapter._reload_dynamic_routes() + assert "static" in adapter._routes + assert len(adapter._dynamic_routes) == 0 diff --git a/tests/hermes_cli/test_webhook_cli.py b/tests/hermes_cli/test_webhook_cli.py new file mode 100644 index 00000000..0094e917 --- /dev/null +++ b/tests/hermes_cli/test_webhook_cli.py @@ -0,0 +1,189 @@ +"""Tests for hermes_cli/webhook.py — webhook subscription CLI.""" + +import json +import os +import pytest +from argparse import Namespace +from pathlib import Path + +from hermes_cli.webhook import ( + webhook_command, + _load_subscriptions, + _save_subscriptions, + _subscriptions_path, + _is_webhook_enabled, +) + + +@pytest.fixture(autouse=True) +def _isolate(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + # Default: webhooks enabled (most tests need this) + monkeypatch.setattr( + "hermes_cli.webhook._is_webhook_enabled", lambda: True + ) + + +def _make_args(**kwargs): + defaults = { + "webhook_action": None, + "name": "", + "prompt": "", + "events": "", + "description": "", + "skills": "", + "deliver": "log", + "deliver_chat_id": "", + "secret": "", + "payload": "", + } + defaults.update(kwargs) + return Namespace(**defaults) + + +class TestSubscribe: + def test_basic_create(self, capsys): + webhook_command(_make_args(webhook_action="subscribe", name="test-hook")) + out = capsys.readouterr().out + assert "Created" in out + assert "/webhooks/test-hook" in out + subs = _load_subscriptions() + assert "test-hook" in subs + + def test_with_options(self, capsys): + webhook_command(_make_args( + webhook_action="subscribe", + name="gh-issues", + events="issues,pull_request", + prompt="Issue: {issue.title}", + deliver="telegram", + deliver_chat_id="12345", + description="Watch GitHub", + )) + subs = _load_subscriptions() + route = subs["gh-issues"] + assert route["events"] == ["issues", "pull_request"] + assert route["prompt"] == "Issue: {issue.title}" + assert route["deliver"] == "telegram" + assert route["deliver_extra"] == {"chat_id": "12345"} + + def test_custom_secret(self): + webhook_command(_make_args( + webhook_action="subscribe", name="s", secret="my-secret" + )) + assert _load_subscriptions()["s"]["secret"] == "my-secret" + + def test_auto_secret(self): + webhook_command(_make_args(webhook_action="subscribe", name="s")) + secret = _load_subscriptions()["s"]["secret"] + assert len(secret) > 20 + + def test_update(self, capsys): + webhook_command(_make_args(webhook_action="subscribe", name="x", prompt="v1")) + webhook_command(_make_args(webhook_action="subscribe", name="x", prompt="v2")) + out = capsys.readouterr().out + assert "Updated" in out + assert _load_subscriptions()["x"]["prompt"] == "v2" + + def test_invalid_name(self, capsys): + webhook_command(_make_args(webhook_action="subscribe", name="bad name!")) + out = capsys.readouterr().out + assert "Error" in out or "Invalid" in out + assert _load_subscriptions() == {} + + +class TestList: + def test_empty(self, capsys): + webhook_command(_make_args(webhook_action="list")) + out = capsys.readouterr().out + assert "No dynamic" in out + + def test_with_entries(self, capsys): + webhook_command(_make_args(webhook_action="subscribe", name="a")) + webhook_command(_make_args(webhook_action="subscribe", name="b")) + capsys.readouterr() # clear + webhook_command(_make_args(webhook_action="list")) + out = capsys.readouterr().out + assert "2 webhook" in out + assert "a" in out + assert "b" in out + + +class TestRemove: + def test_remove_existing(self, capsys): + webhook_command(_make_args(webhook_action="subscribe", name="temp")) + webhook_command(_make_args(webhook_action="remove", name="temp")) + out = capsys.readouterr().out + assert "Removed" in out + assert _load_subscriptions() == {} + + def test_remove_nonexistent(self, capsys): + webhook_command(_make_args(webhook_action="remove", name="nope")) + out = capsys.readouterr().out + assert "No subscription" in out + + def test_selective_remove(self): + webhook_command(_make_args(webhook_action="subscribe", name="keep")) + webhook_command(_make_args(webhook_action="subscribe", name="drop")) + webhook_command(_make_args(webhook_action="remove", name="drop")) + subs = _load_subscriptions() + assert "keep" in subs + assert "drop" not in subs + + +class TestPersistence: + def test_file_written(self): + webhook_command(_make_args(webhook_action="subscribe", name="persist")) + path = _subscriptions_path() + assert path.exists() + data = json.loads(path.read_text()) + assert "persist" in data + + def test_corrupted_file(self): + path = _subscriptions_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("broken{{{") + assert _load_subscriptions() == {} + + +class TestWebhookEnabledGate: + def test_blocks_when_disabled(self, capsys, monkeypatch): + monkeypatch.setattr("hermes_cli.webhook._is_webhook_enabled", lambda: False) + webhook_command(_make_args(webhook_action="subscribe", name="blocked")) + out = capsys.readouterr().out + assert "not enabled" in out.lower() + assert "hermes gateway setup" in out + assert _load_subscriptions() == {} + + def test_blocks_list_when_disabled(self, capsys, monkeypatch): + monkeypatch.setattr("hermes_cli.webhook._is_webhook_enabled", lambda: False) + webhook_command(_make_args(webhook_action="list")) + out = capsys.readouterr().out + assert "not enabled" in out.lower() + + def test_allows_when_enabled(self, capsys): + # _is_webhook_enabled already patched to True by autouse fixture + webhook_command(_make_args(webhook_action="subscribe", name="allowed")) + out = capsys.readouterr().out + assert "Created" in out + assert "allowed" in _load_subscriptions() + + def test_real_check_disabled(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.webhook._get_webhook_config", + lambda: {}, + ) + monkeypatch.setattr( + "hermes_cli.webhook._is_webhook_enabled", + lambda: bool({}.get("enabled")), + ) + import hermes_cli.webhook as wh_mod + assert wh_mod._is_webhook_enabled() is False + + def test_real_check_enabled(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.webhook._is_webhook_enabled", + lambda: True, + ) + import hermes_cli.webhook as wh_mod + assert wh_mod._is_webhook_enabled() is True diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 9155793e..b6062454 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -39,6 +39,7 @@ hermes [global-options] [subcommand/options] | `hermes login` / `logout` | Authenticate with OAuth-backed providers. | | `hermes status` | Show agent, auth, and platform status. | | `hermes cron` | Inspect and tick the cron scheduler. | +| `hermes webhook` | Manage dynamic webhook subscriptions for event-driven activation. | | `hermes doctor` | Diagnose config and dependency issues. | | `hermes config` | Show, edit, migrate, and query configuration files. | | `hermes pairing` | Approve or revoke messaging pairing codes. | @@ -214,6 +215,39 @@ hermes cron | `status` | Check whether the cron scheduler is running. | | `tick` | Run due jobs once and exit. | +## `hermes webhook` + +```bash +hermes webhook +``` + +Manage dynamic webhook subscriptions for event-driven agent activation. Requires the webhook platform to be enabled in config — if not configured, prints setup instructions. + +| Subcommand | Description | +|------------|-------------| +| `subscribe` / `add` | Create a webhook route. Returns the URL and HMAC secret to configure on your service. | +| `list` / `ls` | Show all agent-created subscriptions. | +| `remove` / `rm` | Delete a dynamic subscription. Static routes from config.yaml are not affected. | +| `test` | Send a test POST to verify a subscription is working. | + +### `hermes webhook subscribe` + +```bash +hermes webhook subscribe [options] +``` + +| Option | Description | +|--------|-------------| +| `--prompt` | Prompt template with `{dot.notation}` payload references. | +| `--events` | Comma-separated event types to accept (e.g. `issues,pull_request`). Empty = all. | +| `--description` | Human-readable description. | +| `--skills` | Comma-separated skill names to load for the agent run. | +| `--deliver` | Delivery target: `log` (default), `telegram`, `discord`, `slack`, `github_comment`. | +| `--deliver-chat-id` | Target chat/channel ID for cross-platform delivery. | +| `--secret` | Custom HMAC secret. Auto-generated if omitted. | + +Subscriptions persist to `~/.hermes/webhook_subscriptions.json` and are hot-reloaded by the webhook adapter without a gateway restart. + ## `hermes doctor` ```bash diff --git a/website/docs/reference/skills-catalog.md b/website/docs/reference/skills-catalog.md index 4f6889b0..305cd001 100644 --- a/website/docs/reference/skills-catalog.md +++ b/website/docs/reference/skills-catalog.md @@ -48,6 +48,14 @@ Creative content generation — ASCII art, hand-drawn style diagrams, and visual | `ascii-video` | "Production pipeline for ASCII art video — any format. Converts video/audio/images/generative input into colored ASCII character video output (MP4, GIF, image sequence). Covers: video-to-ASCII conversion, audio-reactive music visualizers, generative ASCII art animations, hybrid… | `creative/ascii-video` | | `excalidraw` | Create hand-drawn style diagrams using Excalidraw JSON format. Generate .excalidraw files for architecture diagrams, flowcharts, sequence diagrams, concept maps, and more. Files can be opened at excalidraw.com or uploaded for shareable links. | `creative/excalidraw` | +## devops + +DevOps and infrastructure automation skills. + +| Skill | Description | Path | +|-------|-------------|------| +| `webhook-subscriptions` | Create and manage webhook subscriptions for event-driven agent activation. External services (GitHub, Stripe, CI/CD, IoT) POST events to trigger agent runs. Requires webhook platform to be enabled. | `devops/webhook-subscriptions` | + ## dogfood | Skill | Description | Path | diff --git a/website/docs/user-guide/messaging/webhooks.md b/website/docs/user-guide/messaging/webhooks.md index 81744638..b804152f 100644 --- a/website/docs/user-guide/messaging/webhooks.md +++ b/website/docs/user-guide/messaging/webhooks.md @@ -15,7 +15,7 @@ The agent processes the event and can respond by posting comments on PRs, sendin ## Quick Start 1. Enable via `hermes gateway setup` or environment variables -2. Define webhook routes in `config.yaml` +2. Define routes in `config.yaml` **or** create them dynamically with `hermes webhook subscribe` 3. Point your service at `http://your-server:8644/webhooks/` --- @@ -205,6 +205,56 @@ For cross-platform delivery (telegram, discord, slack, signal, sms), the target --- +## Dynamic Subscriptions (CLI) {#dynamic-subscriptions} + +In addition to static routes in `config.yaml`, you can create webhook subscriptions dynamically using the `hermes webhook` CLI command. This is especially useful when the agent itself needs to set up event-driven triggers. + +### Create a subscription + +```bash +hermes webhook subscribe github-issues \ + --events "issues" \ + --prompt "New issue #{issue.number}: {issue.title}\nBy: {issue.user.login}\n\n{issue.body}" \ + --deliver telegram \ + --deliver-chat-id "-100123456789" \ + --description "Triage new GitHub issues" +``` + +This returns the webhook URL and an auto-generated HMAC secret. Configure your service to POST to that URL. + +### List subscriptions + +```bash +hermes webhook list +``` + +### Remove a subscription + +```bash +hermes webhook remove github-issues +``` + +### Test a subscription + +```bash +hermes webhook test github-issues +hermes webhook test github-issues --payload '{"issue": {"number": 42, "title": "Test"}}' +``` + +### How dynamic subscriptions work + +- Subscriptions are stored in `~/.hermes/webhook_subscriptions.json` +- The webhook adapter hot-reloads this file on each incoming request (mtime-gated, negligible overhead) +- Static routes from `config.yaml` always take precedence over dynamic ones with the same name +- Dynamic subscriptions use the same route format and capabilities as static routes (events, prompt templates, skills, delivery) +- No gateway restart required — subscribe and it's immediately live + +### Agent-driven subscriptions + +The agent can create subscriptions via the terminal tool when guided by the `webhook-subscriptions` skill. Ask the agent to "set up a webhook for GitHub issues" and it will run the appropriate `hermes webhook subscribe` command. + +--- + ## Security {#security} The webhook adapter includes multiple layers of security: