feat(webhook): hermes webhook CLI + skill for event-driven subscriptions (#3578)
Adds 'hermes webhook' CLI subcommand and a skill — zero new model tools.
CLI commands (require webhook platform to be enabled):
hermes webhook subscribe <name> [--events, --prompt, --deliver, ...]
hermes webhook list
hermes webhook remove <name>
hermes webhook test <name>
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.
2026-03-28 14:33:35 -07:00
|
|
|
"""Tests for hermes_cli/webhook.py — webhook subscription CLI."""
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import pytest
|
2026-05-24 04:54:49 -07:00
|
|
|
import stat
|
feat(webhook): hermes webhook CLI + skill for event-driven subscriptions (#3578)
Adds 'hermes webhook' CLI subcommand and a skill — zero new model tools.
CLI commands (require webhook platform to be enabled):
hermes webhook subscribe <name> [--events, --prompt, --deliver, ...]
hermes webhook list
hermes webhook remove <name>
hermes webhook test <name>
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.
2026-03-28 14:33:35 -07:00
|
|
|
from argparse import Namespace
|
|
|
|
|
|
|
|
|
|
from hermes_cli.webhook import (
|
|
|
|
|
webhook_command,
|
|
|
|
|
_load_subscriptions,
|
|
|
|
|
_save_subscriptions,
|
|
|
|
|
_subscriptions_path,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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() == {}
|
|
|
|
|
|
2026-05-24 04:54:49 -07:00
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are platform-specific")
|
|
|
|
|
def test_save_creates_secret_file_owner_only_under_permissive_umask(self):
|
|
|
|
|
old_umask = os.umask(0o022)
|
|
|
|
|
try:
|
|
|
|
|
_save_subscriptions({"demo": {"secret": "TOPSECRET", "prompt": "x"}})
|
|
|
|
|
finally:
|
|
|
|
|
os.umask(old_umask)
|
|
|
|
|
|
|
|
|
|
path = _subscriptions_path()
|
|
|
|
|
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
|
|
|
|
assert "TOPSECRET" in path.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are platform-specific")
|
|
|
|
|
def test_save_narrows_existing_broad_secret_file_mode(self):
|
|
|
|
|
# Simulate a pre-existing 0o644 file from before this hardening landed.
|
|
|
|
|
path = _subscriptions_path()
|
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
path.write_text(json.dumps({"old": {"secret": "stale", "prompt": "x"}}))
|
|
|
|
|
path.chmod(0o644)
|
|
|
|
|
|
|
|
|
|
_save_subscriptions({"demo": {"secret": "FRESH", "prompt": "x"}})
|
|
|
|
|
|
|
|
|
|
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
|
|
|
|
assert "FRESH" in path.read_text(encoding="utf-8")
|
|
|
|
|
|
feat(webhook): hermes webhook CLI + skill for event-driven subscriptions (#3578)
Adds 'hermes webhook' CLI subcommand and a skill — zero new model tools.
CLI commands (require webhook platform to be enabled):
hermes webhook subscribe <name> [--events, --prompt, --deliver, ...]
hermes webhook list
hermes webhook remove <name>
hermes webhook test <name>
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.
2026-03-28 14:33:35 -07:00
|
|
|
|
|
|
|
|
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
|