Files
hermes-agent/hermes_cli/webhook.py
Teknium a5bd246865 Old pre-decomposition import paths are gone: plugin compat layer removed on schedule (#126164)
* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule

The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:

- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
  names, restored dead definitions) and the three re-export stub modules
  (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
  the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
  `plugins.allow_deprecated_imports` escape hatch

An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.

hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).

In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.

* chore: retrigger CI (zero-job startup_failure phantom)

* test: drop resolution allowlist rows for the two deleted which() sites

hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
2026-09-28 10:21:41 -07:00

280 lines
9.9 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-sig"))
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 _route_url(name: str, route: dict) -> str:
profile = route.get("profile", "default")
prefix = f"/p/{profile}" if profile != "default" else ""
return f"{_get_webhook_base_url()}{prefix}/webhooks/{name}"
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
existing = subs.get(name, {})
profile_arg = getattr(args, "route_profile", None)
if profile_arg is None:
profile = existing.get("profile", "default")
else:
from hermes_cli.profiles import normalize_profile_name, profile_exists, validate_profile_name
try:
profile = normalize_profile_name(profile_arg)
validate_profile_name(profile)
except ValueError as exc:
print(f"Error: {exc}")
return
if not profile_exists(profile):
print(f"Error: Profile '{profile}' does not exist.")
return
secret = args.secret or existing.get("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",
"profile": profile,
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
if getattr(args, "deliver_only", False):
if getattr(args, "cron_job", ""):
print(
"Error: --deliver-only and --cron-job are mutually exclusive. "
"--deliver-only pushes the rendered template as a message; "
"--cron-job fires an existing cron job (which handles its own "
"delivery)."
)
return
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
if getattr(args, "mirror_to_session", False):
route["mirror_to_session"] = True
cron_job = (getattr(args, "cron_job", "") or "").strip()
if cron_job:
# Validate the reference up-front so a typo surfaces here, not on the first inbound event.
from cron.jobs import AmbiguousJobReference, resolve_job_ref
try:
job = resolve_job_ref(cron_job)
except AmbiguousJobReference as e:
print(f"Error: {e}")
return
if job is None:
print(f"Error: no cron job matches '{cron_job}'. List jobs with: hermes cron list")
return
route["cron_job"] = job["id"]
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: {_route_url(name, route)}")
print(f" Profile: {profile}")
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("mirror_to_session"):
print(" Replies: each delivery is mirrored into the target chat's session")
if route.get("cron_job"):
print(f" Mode: cron-job trigger — fires job '{route['cron_job']}' on each event")
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
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)"
if route.get("cron_job"):
deliver = f"cron job '{route['cron_job']}'"
desc = route.get("description", "")
print(f" ◆ {name}")
if desc:
print(f" {desc}")
profile = route.get("profile", "default")
print(f" URL: {_route_url(name, route)}")
print(f" Profile: {profile}")
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 = _route_url(name, subs[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}