2776813df3
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in the focused modules that define them. This commit is the ONLY thing keeping the old paths alive, so external plugins have time to update. It is deliberately a single, unsquashed commit: git revert <this sha> removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does. What it adds (see COMPAT_MANIFEST.md, compat_manifest.json): - 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file - 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import, so no import cycles; facades that already had `__getattr__` get a chained one - 592 third-party/stdlib names the old modules used to expose, with their original import statements - 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them) - 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/ relay_runtime, tools/environments/modal_utils) - private names (`_x`) get no pointer: they were never API (3,792 skipped) Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves; the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
249 lines
8.4 KiB
Python
249 lines
8.4 KiB
Python
"""hermes webhook — manage dynamic webhook subscriptions from the CLI."""
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import re
|
|
import secrets
|
|
import time
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Dict
|
|
|
|
from hermes_constants import display_hermes_home
|
|
from utils import atomic_json_write
|
|
from hermes_cli.config import cfg_get
|
|
|
|
|
|
_SUBSCRIPTIONS_FILENAME = "webhook_subscriptions.json"
|
|
_SUBSCRIPTIONS_FILE_MODE = 0o600
|
|
|
|
|
|
def _subscriptions_path() -> Path:
|
|
from hermes_constants import get_hermes_home
|
|
return get_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:
|
|
# The file holds per-route HMAC secrets: atomic_json_write fchmods the temp file 0o600 BEFORE the
|
|
# rename (no umask window) and re-asserts the mode on the destination afterwards.
|
|
atomic_json_write(_subscriptions_path(), subs, mode=_SUBSCRIPTIONS_FILE_MODE)
|
|
|
|
|
|
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(cfg, "platforms", "webhook", default={})
|
|
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")
|
|
display_host = "localhost" if not host or host in {"0.0.0.0", "::"} else host
|
|
if ":" in display_host and not display_host.startswith("["):
|
|
display_host = f"[{display_host}]"
|
|
return f"http://{display_host}:{wh.get('port', 8644)}"
|
|
|
|
|
|
def _setup_hint() -> str:
|
|
_dhh = display_hermes_home()
|
|
return f"""
|
|
Webhook platform is not enabled. To set it up:
|
|
|
|
1. Run the gateway setup wizard:
|
|
hermes gateway setup
|
|
|
|
2. Or manually add to {_dhh}/config.yaml:
|
|
platforms:
|
|
webhook:
|
|
enabled: true
|
|
extra:
|
|
port: 8644
|
|
secret: "your-global-hmac-secret"
|
|
|
|
3. Or set environment variables in {_dhh}/.env:
|
|
WEBHOOK_ENABLED=true
|
|
WEBHOOK_PORT=8644
|
|
WEBHOOK_SECRET=your-global-secret
|
|
|
|
Then start the gateway: hermes gateway run
|
|
"""
|
|
|
|
|
|
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 _is_webhook_enabled():
|
|
print(_setup_hint())
|
|
return
|
|
handler = _ACTIONS.get(sub)
|
|
if handler is not None:
|
|
handler(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 getattr(args, "deliver_only", False):
|
|
if route["deliver"] == "log":
|
|
print(
|
|
"Error: --deliver-only requires --deliver to be a real target "
|
|
"(telegram, discord, slack, github_comment, etc.) — not 'log'.")
|
|
return
|
|
route["deliver_only"] = True
|
|
script = (getattr(args, "script", "") or "").strip()
|
|
if script:
|
|
route["script"] = script
|
|
if args.deliver_chat_id:
|
|
route["deliver_extra"] = {"chat_id": args.deliver_chat_id}
|
|
subs[name] = route
|
|
_save_subscriptions(subs)
|
|
|
|
print(f"\n {'Updated' if is_update else 'Created'} webhook subscription: {name}")
|
|
print(f" URL: {_get_webhook_base_url()}/webhooks/{name}")
|
|
print(f" Secret: {secret}")
|
|
print(f" Events: {', '.join(events) or '(all)'}")
|
|
print(f" Deliver: {route['deliver']}")
|
|
if route.get("deliver_only"):
|
|
print(" Mode: direct delivery (no agent, zero LLM cost)")
|
|
if route.get("prompt"):
|
|
prompt_preview = route["prompt"][:80] + ("..." if len(route["prompt"]) > 80 else "")
|
|
print(f" {'Message' if route.get('deliver_only') else 'Prompt'}: {prompt_preview}")
|
|
if route.get("script"):
|
|
print(f" Script: {route['script']}")
|
|
print("\n Configure your service to POST to the URL above.")
|
|
print(" Use the secret for HMAC-SHA256 signature validation.")
|
|
print(" 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 <name>")
|
|
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")
|
|
if route.get("deliver_only"):
|
|
deliver = f"{deliver} (direct — no agent)"
|
|
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}")
|
|
if route.get("script"):
|
|
print(f" Script: {route['script']}")
|
|
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
|
|
secret = subs[name].get("secret", "")
|
|
url = f"{_get_webhook_base_url()}/webhooks/{name}"
|
|
payload = args.payload or '{"test": true, "event_type": "test", "message": "Hello from hermes webhook test"}'
|
|
sig = "sha256=" + hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
|
print(f" Sending test POST to {url}")
|
|
try:
|
|
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)")
|
|
|
|
|
|
_ACTIONS = {
|
|
"subscribe": _cmd_subscribe, "add": _cmd_subscribe,
|
|
"list": _cmd_list, "ls": _cmd_list,
|
|
"remove": _cmd_remove, "rm": _cmd_remove,
|
|
"test": _cmd_test}
|
|
|
|
|
|
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
|
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
|
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
|
# The whole block is removed by reverting the commit that added it.
|
|
import os # noqa: F401,E402
|
|
import tempfile # noqa: F401,E402
|
|
|
|
|
|
_PLUGIN_COMPAT_LAZY = {
|
|
'atomic_replace': ('utils', 'atomic_replace'),
|
|
}
|
|
|
|
|
|
def __getattr__(name): # PEP 562 — lazy so no import cycles
|
|
target = _PLUGIN_COMPAT_LAZY.get(name)
|
|
if target is None:
|
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
import importlib
|
|
return getattr(importlib.import_module(target[0]), target[1])
|
|
# ---- END PLUGIN-COMPAT ----
|