diff --git a/contributors/emails/andrexibiza@gmail.com b/contributors/emails/andrexibiza@gmail.com new file mode 100644 index 0000000000000..abe9a2b7d2f94 --- /dev/null +++ b/contributors/emails/andrexibiza@gmail.com @@ -0,0 +1,2 @@ +andrexibiza +# campaign authorship diff --git a/hermes_cli/subcommands/webhook.py b/hermes_cli/subcommands/webhook.py index 38085141b0697..4eadc6bdad3d3 100644 --- a/hermes_cli/subcommands/webhook.py +++ b/hermes_cli/subcommands/webhook.py @@ -9,6 +9,22 @@ from typing import Callable +def _add_profile_flag(parser) -> None: + parser.add_argument( + "--profile", + default="", + help="Profile whose webhook subscriptions to manage (default: active profile)", + ) + + +def _add_json_flag(parser) -> None: + parser.add_argument( + "--json", + action="store_true", + help="Emit machine-readable JSON (secrets are masked on read)", + ) + + def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None: """Attach the ``webhook`` subcommand to ``subparsers``.""" # ========================================================================= @@ -17,12 +33,12 @@ def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None: webhook_parser = subparsers.add_parser( "webhook", help="Manage dynamic webhook subscriptions", - description="Create, list, and remove webhook subscriptions for event-driven agent activation", + description="Create, list, and manage 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" + "subscribe", aliases=["add", "create"], help="Create a webhook subscription" ) wh_sub.add_argument("name", help="Route name (used in URL: /webhooks/)") wh_sub.add_argument( @@ -62,15 +78,61 @@ def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None: "payload is passed as JSON on stdin; empty stdout, [SILENT], or a " "nonzero exit code ignores the webhook.", ) + wh_sub.add_argument( + "--replace", + action="store_true", + help="Overwrite an existing route of the same name (default: error)", + ) + _add_profile_flag(wh_sub) - webhook_subparsers.add_parser( + wh_list = webhook_subparsers.add_parser( "list", aliases=["ls"], help="List all dynamic subscriptions" ) + _add_profile_flag(wh_list) + _add_json_flag(wh_list) + + wh_show = webhook_subparsers.add_parser( + "show", help="Show one subscription's details" + ) + wh_show.add_argument("name", help="Subscription name to show") + _add_profile_flag(wh_show) + _add_json_flag(wh_show) + + wh_upd = webhook_subparsers.add_parser( + "update", help="Patch fields on an existing subscription" + ) + wh_upd.add_argument("name", help="Subscription name to update") + wh_upd.add_argument("--prompt", default="", help="New prompt template") + wh_upd.add_argument("--events", default="", help="New comma-separated events") + wh_upd.add_argument("--description", default="", help="New description") + wh_upd.add_argument("--skills", default="", help="New comma-separated skills") + wh_upd.add_argument("--deliver", default="", help="New delivery target") + wh_upd.add_argument("--deliver-chat-id", default="", help="New target chat ID") + _add_profile_flag(wh_upd) + + wh_enable = webhook_subparsers.add_parser( + "enable", help="Enable a disabled subscription" + ) + wh_enable.add_argument("name", help="Subscription name to enable") + _add_profile_flag(wh_enable) + + wh_disable = webhook_subparsers.add_parser( + "disable", help="Disable a subscription without removing it" + ) + wh_disable.add_argument("name", help="Subscription name to disable") + _add_profile_flag(wh_disable) + + wh_rotate = webhook_subparsers.add_parser( + "rotate-secret", help="Rotate a subscription's HMAC secret" + ) + wh_rotate.add_argument("name", help="Subscription name to rotate") + _add_profile_flag(wh_rotate) wh_rm = webhook_subparsers.add_parser( "remove", aliases=["rm"], help="Remove a subscription" ) wh_rm.add_argument("name", help="Subscription name to remove") + _add_profile_flag(wh_rm) wh_test = webhook_subparsers.add_parser( "test", help="Send a test POST to a webhook route" @@ -79,5 +141,6 @@ def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None: wh_test.add_argument( "--payload", default="", help="JSON payload to send (default: test payload)" ) + _add_profile_flag(wh_test) webhook_parser.set_defaults(func=cmd_webhook) diff --git a/hermes_cli/webhook.py b/hermes_cli/webhook.py index 9b9de6cd5a6c2..ad29adebd6c6b 100644 --- a/hermes_cli/webhook.py +++ b/hermes_cli/webhook.py @@ -33,12 +33,37 @@ def _hermes_home() -> Path: return get_hermes_home() -def _subscriptions_path() -> Path: - return _hermes_home() / _SUBSCRIPTIONS_FILENAME +def _active_profile_name() -> str: + """Return the effective profile name for webhook subscription storage.""" + try: + from hermes_cli.profiles import get_active_profile_name + name = get_active_profile_name() + if name and name != "custom": + return name + except Exception: + pass + return "default" + + +def _profile_root(profile: str | None) -> Path: + """Return the profile-scoped Hermes root for subscription storage. + + The default profile lives directly in HERMES_HOME; named profiles live + under ``HERMES_HOME/profiles/``. + """ + home = _hermes_home() + name = (profile or "").strip() or _active_profile_name() + if not name or name == "default": + return home + return home / "profiles" / name + + +def _subscriptions_path(profile: str | None = None) -> Path: + return _profile_root(profile) / _SUBSCRIPTIONS_FILENAME -def _load_subscriptions() -> Dict[str, dict]: - path = _subscriptions_path() +def _load_subscriptions(profile: str | None = None) -> Dict[str, dict]: + path = _subscriptions_path(profile) if not path.exists(): return {} try: @@ -48,8 +73,8 @@ def _load_subscriptions() -> Dict[str, dict]: return {} -def _save_subscriptions(subs: Dict[str, dict]) -> None: - path = _subscriptions_path() +def _save_subscriptions(subs: Dict[str, dict], profile: str | None = None) -> None: + path = _subscriptions_path(profile) path.parent.mkdir(parents=True, exist_ok=True) # webhook_subscriptions.json contains per-route HMAC secrets — write # via tempfile + chmod 0o600 before the atomic rename so a permissive @@ -137,22 +162,60 @@ def _require_webhook_enabled() -> bool: return False +def _args_profile(args) -> str | None: + profile = getattr(args, "profile", "") or "" + return profile.strip() or None + + +def _redact_secret(value: str) -> str: + if not isinstance(value, str) or not value: + return value + if len(value) <= 8: + return "***" + return value[:4] + "..." + value[-4:] + + +def _route_for_json(name: str, route: dict, base_url: str) -> dict: + return { + "name": name, + "description": route.get("description", ""), + "enabled": route.get("enabled", True), + "events": route.get("events", []), + "deliver": route.get("deliver", "log"), + "deliver_only": bool(route.get("deliver_only")), + "script": route.get("script"), + "url": f"{base_url}/webhooks/{name}", + # Secret is never emitted verbatim on read; masked for safety. + "secret_masked": _redact_secret(str(route.get("secret", ""))), + } + + 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("Usage: hermes webhook {subscribe|list|show|update|enable|disable|rotate-secret|remove|test}") print("Run 'hermes webhook --help' for details.") return if not _require_webhook_enabled(): return - if sub in {"subscribe", "add"}: + if sub in {"subscribe", "add", "create"}: _cmd_subscribe(args) elif sub in {"list", "ls"}: _cmd_list(args) + elif sub == "show": + _cmd_show(args) + elif sub == "update": + _cmd_update(args) + elif sub == "enable": + _cmd_set_enabled(args, True) + elif sub == "disable": + _cmd_set_enabled(args, False) + elif sub == "rotate-secret": + _cmd_rotate_secret(args) elif sub in {"remove", "rm"}: _cmd_remove(args) elif sub == "test": @@ -165,8 +228,16 @@ def _cmd_subscribe(args): print(f"Error: Invalid name '{name}'. Use lowercase alphanumeric with hyphens/underscores.") return - subs = _load_subscriptions() + profile = _args_profile(args) + subs = _load_subscriptions(profile) is_update = name in subs + if is_update and not getattr(args, "replace", False): + print( + f"Error: A subscription named '{name}' already exists. " + f"Use 'hermes webhook update {name}' to patch fields, or pass " + f"--replace to overwrite." + ) + return secret = args.secret or secrets.token_urlsafe(32) events = [e.strip() for e in args.events.split(",")] if args.events else [] @@ -198,7 +269,7 @@ def _cmd_subscribe(args): route["deliver_extra"] = {"chat_id": args.deliver_chat_id} subs[name] = route - _save_subscriptions(subs) + _save_subscriptions(subs, profile) base_url = _get_webhook_base_url() status = "Updated" if is_update else "Created" @@ -224,14 +295,101 @@ def _cmd_subscribe(args): print(" The gateway must be running to receive events (hermes gateway run).\n") +def _cmd_show(args): + name = args.name.strip().lower() + subs = _load_subscriptions(_args_profile(args)) + if name not in subs: + print(f" No subscription named '{name}'.") + return + route = subs[name] + base_url = _get_webhook_base_url() + if getattr(args, "json", False): + print(json.dumps(_route_for_json(name, route, base_url), indent=2)) + return + events = ", ".join(route.get("events", [])) or "(all)" + deliver = route.get("deliver", "log") + if route.get("deliver_only"): + deliver = f"{deliver} (direct — no agent)" + enabled = "yes" if route.get("enabled", True) else "no" + print(f"\n ◆ {name}") + if route.get("description"): + print(f" {route['description']}") + print(f" URL: {base_url}/webhooks/{name}") + print(f" Enabled: {enabled}") + print(f" Events: {events}") + print(f" Deliver: {deliver}") + print(f" Secret: {_redact_secret(str(route.get('secret', ''))) or '(none)'}") + if route.get("script"): + print(f" Script: {route['script']}") + if route.get("skills"): + print(f" Skills: {', '.join(route['skills'])}") + if route.get("prompt"): + print(f" Prompt: {route['prompt'][:80]}") + print() + + +def _cmd_update(args): + name = args.name.strip().lower() + subs = _load_subscriptions(_args_profile(args)) + if name not in subs: + print(f" No subscription named '{name}'.") + return + route = subs[name] + if args.prompt: + route["prompt"] = args.prompt + if args.events: + route["events"] = [e.strip() for e in args.events.split(",") if e.strip()] + if args.description: + route["description"] = args.description + if args.skills: + route["skills"] = [s.strip() for s in args.skills.split(",") if s.strip()] + if args.deliver: + route["deliver"] = args.deliver + if args.deliver_chat_id: + route["deliver_extra"] = {"chat_id": args.deliver_chat_id} + _save_subscriptions(subs, _args_profile(args)) + print(f" Updated webhook subscription: {name}") + + +def _cmd_set_enabled(args, enabled: bool): + name = args.name.strip().lower() + subs = _load_subscriptions(_args_profile(args)) + if name not in subs: + print(f" No subscription named '{name}'.") + return + subs[name]["enabled"] = enabled + _save_subscriptions(subs, _args_profile(args)) + action = "Enabled" if enabled else "Disabled" + print(f" {action} webhook subscription: {name}") + + +def _cmd_rotate_secret(args): + name = args.name.strip().lower() + subs = _load_subscriptions(_args_profile(args)) + if name not in subs: + print(f" No subscription named '{name}'.") + return + new_secret = secrets.token_urlsafe(32) + subs[name]["secret"] = new_secret + _save_subscriptions(subs, _args_profile(args)) + print(f" Rotated secret for {name}.") + print(f" New secret (shown once): {new_secret}") + print(" Store this in your provider's webhook configuration.") + + def _cmd_list(args): - subs = _load_subscriptions() + profile = _args_profile(args) + subs = _load_subscriptions(profile) if not subs: print(" No dynamic webhook subscriptions.") print(" Create one with: hermes webhook subscribe ") return base_url = _get_webhook_base_url() + if getattr(args, "json", False): + payload = [_route_for_json(n, r, base_url) for n, r in subs.items()] + print(json.dumps(payload, indent=2)) + return print(f"\n {len(subs)} webhook subscription(s):\n") for name, route in subs.items(): events = ", ".join(route.get("events", [])) or "(all)" @@ -252,7 +410,8 @@ def _cmd_list(args): def _cmd_remove(args): name = args.name.strip().lower() - subs = _load_subscriptions() + profile = _args_profile(args) + subs = _load_subscriptions(profile) if name not in subs: print(f" No subscription named '{name}'.") @@ -260,14 +419,14 @@ def _cmd_remove(args): return del subs[name] - _save_subscriptions(subs) + _save_subscriptions(subs, profile) 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() + subs = _load_subscriptions(_args_profile(args)) if name not in subs: print(f" No subscription named '{name}'.") diff --git a/tests/hermes_cli/test_webhook_cli.py b/tests/hermes_cli/test_webhook_cli.py index 4fecf7f279c70..d118c2476dd89 100644 --- a/tests/hermes_cli/test_webhook_cli.py +++ b/tests/hermes_cli/test_webhook_cli.py @@ -153,3 +153,86 @@ def test_real_check_disabled(self, monkeypatch): import hermes_cli.webhook as wh_mod assert wh_mod._is_webhook_enabled() is False + +class TestManageCommands: + """Task 15: show / update / enable / disable / rotate-secret / --replace.""" + + def _mk(self, **kw): + return _make_args(**kw) + + def test_subscribe_conflict_requires_replace(self, capsys): + webhook_command(self._mk(webhook_action="subscribe", name="dup", secret="s1")) + capsys.readouterr() + webhook_command(self._mk(webhook_action="subscribe", name="dup", secret="s2")) + out = capsys.readouterr().out + assert "already exists" in out.lower() + # Without --replace, the route is untouched. + assert _load_subscriptions()["dup"]["secret"] == "s1" + + def test_subscribe_replace_overwrites(self, capsys): + webhook_command(self._mk(webhook_action="subscribe", name="dup", secret="s1")) + capsys.readouterr() + webhook_command(self._mk( + webhook_action="subscribe", name="dup", secret="s2", replace=True + )) + capsys.readouterr() + assert _load_subscriptions()["dup"]["secret"] == "s2" + + def test_show_masks_secret(self, capsys): + webhook_command(self._mk( + webhook_action="subscribe", name="shown", + secret="a-very-long-secret-value", description="desc", + )) + capsys.readouterr() + webhook_command(self._mk(webhook_action="show", name="shown")) + out = capsys.readouterr().out + assert "a-very-long-secret-value" not in out + assert "a-ve" in out # masked head + + def test_show_json(self, capsys): + webhook_command(self._mk(webhook_action="subscribe", name="js", secret="xyz123456")) + capsys.readouterr() + webhook_command(self._mk(webhook_action="show", name="js", json=True)) + out = capsys.readouterr().out + import json as _json + data = _json.loads(out) + assert data["name"] == "js" + assert "xyz123456" not in out + + def test_disable_and_enable(self, capsys): + webhook_command(self._mk(webhook_action="subscribe", name="toggle")) + capsys.readouterr() + webhook_command(self._mk(webhook_action="disable", name="toggle")) + capsys.readouterr() + assert _load_subscriptions()["toggle"]["enabled"] is False + webhook_command(self._mk(webhook_action="enable", name="toggle")) + capsys.readouterr() + assert _load_subscriptions()["toggle"]["enabled"] is True + + def test_update_patches_fields(self): + webhook_command(self._mk(webhook_action="subscribe", name="upd", prompt="old")) + webhook_command(self._mk(webhook_action="update", name="upd", prompt="new", events="a,b")) + route = _load_subscriptions()["upd"] + assert route["prompt"] == "new" + assert route["events"] == ["a", "b"] + + def test_rotate_secret_shows_once(self, capsys): + webhook_command(self._mk(webhook_action="subscribe", name="rot", secret="oldsecret")) + capsys.readouterr() + webhook_command(self._mk(webhook_action="rotate-secret", name="rot")) + out = capsys.readouterr().out + assert "New secret" in out + new_secret = _load_subscriptions()["rot"]["secret"] + assert new_secret != "oldsecret" + assert len(new_secret) > 20 + + def test_list_json_is_parseable_and_secret_safe(self, capsys): + webhook_command(self._mk(webhook_action="subscribe", name="lj", secret="topsecret123")) + capsys.readouterr() + webhook_command(self._mk(webhook_action="list", json=True)) + out = capsys.readouterr().out + import json as _json + data = _json.loads(out) + assert any(r["name"] == "lj" for r in data) + assert "topsecret123" not in out +