diff --git a/hermes_cli/subcommands/webhook.py b/hermes_cli/subcommands/webhook.py index 5bcbca8849..61d56f9ea6 100644 --- a/hermes_cli/subcommands/webhook.py +++ b/hermes_cli/subcommands/webhook.py @@ -27,8 +27,11 @@ def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None: "--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)") wh_sub.add_argument( - "--profile", default=None, - help="Profile that may receive this route (default: default; preserved on update)") + "--route-profile", dest="route_profile", default=None, metavar="PROFILE", + help="Bind the route to a multiplexed profile: only POSTs to /p/PROFILE/webhooks/ " + "are accepted and the agent runs as that profile (default: default; kept on update). " + "Distinct from the global -p/--profile, which picks the gateway whose subscriptions " + "file is written.") wh_sub.add_argument( "--deliver-only", action="store_true", help="Skip the agent — deliver the rendered prompt directly as the " diff --git a/hermes_cli/webhook.py b/hermes_cli/webhook.py index e624a2b770..99b4469300 100644 --- a/hermes_cli/webhook.py +++ b/hermes_cli/webhook.py @@ -119,7 +119,7 @@ def _cmd_subscribe(args): subs = _load_subscriptions() is_update = name in subs existing = subs.get(name, {}) - profile_arg = getattr(args, "profile", None) + profile_arg = getattr(args, "route_profile", None) if profile_arg is None: profile = existing.get("profile", "default") else: diff --git a/tests/hermes_cli/test_webhook_cli.py b/tests/hermes_cli/test_webhook_cli.py index 18828daa30..b1eb45d22c 100644 --- a/tests/hermes_cli/test_webhook_cli.py +++ b/tests/hermes_cli/test_webhook_cli.py @@ -35,7 +35,7 @@ def _make_args(**kwargs): "deliver": "log", "deliver_chat_id": "", "secret": "", - "profile": None, + "route_profile": None, "payload": "", "script": "", } @@ -72,7 +72,7 @@ class TestSubscribe: profile_dir.mkdir(parents=True) webhook_command(_make_args( - webhook_action="subscribe", name="notifier", profile="compta" + webhook_action="subscribe", name="notifier", route_profile="compta" )) created = _load_subscriptions()["notifier"] first_secret = created["secret"] @@ -91,7 +91,7 @@ class TestSubscribe: webhook_action="subscribe", name="notifier", secret="original" )) webhook_command(_make_args( - webhook_action="subscribe", name="notifier", profile="missing" + webhook_action="subscribe", name="notifier", route_profile="missing" )) assert "does not exist" in capsys.readouterr().out diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 0d80ca7be7..1eca44ed40 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -841,8 +841,9 @@ hermes webhook subscribe [options] | `--secret` | Custom HMAC secret. Auto-generated if omitted. | | `--deliver-only` | Skip the agent — deliver the rendered `--prompt` as the literal message. Zero LLM cost, sub-second delivery. Requires `--deliver` to be a real target (not `log`). | | `--script` | Filter/transform script under `~/.hermes/scripts/`. The webhook payload is passed as JSON on stdin; JSON stdout replaces the payload, and empty stdout, `[SILENT]`, or a nonzero exit code ignores the webhook. See [Script Filters and Transforms](../user-guide/messaging/webhooks.md#script-filters-and-transforms). | +| `--route-profile` | Bind the route to a multiplexed profile: it is then reachable only at `/p//webhooks/` and the agent runs as that profile. Validated against existing profiles; kept on update when omitted. Not the same as the global `-p/--profile`, which selects the gateway whose subscriptions file is written. See [Multi-profile gateways](../user-guide/multi-profile-gateways.md). | -Subscriptions persist to `~/.hermes/webhook_subscriptions.json` and are hot-reloaded by the webhook adapter without a gateway restart. +Subscriptions persist to `~/.hermes/webhook_subscriptions.json` and are hot-reloaded by the webhook adapter without a gateway restart. Re-running `subscribe` for an existing name keeps its secret and profile binding unless you pass `--secret` / `--route-profile`. ## `hermes doctor` diff --git a/website/docs/user-guide/messaging/webhooks.md b/website/docs/user-guide/messaging/webhooks.md index 3a2b3aafb5..71c5774a9e 100644 --- a/website/docs/user-guide/messaging/webhooks.md +++ b/website/docs/user-guide/messaging/webhooks.md @@ -80,7 +80,7 @@ Routes define how different webhook sources are handled. Each route is a named e |----------|----------|-------------| | `events` | No | List of event types to accept (e.g. `["pull_request"]`). If empty, all events are accepted. Event type is read from `X-GitHub-Event`, `X-GitLab-Event`, or `event_type` in the payload. | | `secret` | **Yes** | HMAC secret for signature validation. Falls back to the global `secret` if not set on the route. Set to `"INSECURE_NO_AUTH"` for testing only (skips validation). | -| `profile` | No | Profile authorized to execute this route when `gateway.multiplex_profiles` is enabled. Omit it for a default-profile-only route; set a profile name (for example `coder`) to bind the route and its secret to `/p/coder/webhooks/`. | +| `profile` | No | Profile authorized to execute this route when `gateway.multiplex_profiles` is enabled. Omit it for a default-profile-only route; set a profile name (for example `coder`) to bind the route and its secret to `/p/coder/webhooks/`. Dynamic subscriptions set it with `hermes webhook subscribe --route-profile coder`. | | `prompt` | No | Template string with dot-notation payload access (e.g. `{pull_request.title}`). If omitted, the full JSON payload is dumped into the prompt. Payload fields are untrusted — see [Authenticated does not mean trusted](#authenticated-does-not-mean-trusted). | | `filters` | No | Declarative payload filters evaluated after auth/body/event filtering and before agent or direct delivery work. Non-matches return `{"status":"ignored","reason":"filter"}` with HTTP 200. | | `script` | No | Filter/transform script under `~/.hermes/scripts/`. The webhook payload is passed as JSON on stdin. JSON object stdout replaces the payload before templating; text stdout is exposed as `script_output`; empty stdout, `[SILENT]`, or a nonzero exit code ignores the webhook. | diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md index 06cf517a2f..a3b45cb85d 100644 --- a/website/docs/user-guide/multi-profile-gateways.md +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -193,7 +193,13 @@ using the default listener's existing credentials. `config.yaml`. That secret is then accepted only at `/p/coder/webhooks/` and is rejected on every other profile prefix. - Webhook routes without `profile` remain default-profile routes and are not - reachable through a named profile prefix. + reachable through a named profile prefix. Dynamic subscriptions bind the same + way: `hermes webhook subscribe --route-profile coder` writes + `profile: coder` into the default gateway's `webhook_subscriptions.json` and + prints the `/p/coder/webhooks/` URL (`hermes webhook ls` shows the + binding). Use `--route-profile`, not the global `-p coder`: `-p` would write + the subscription into coder's own subscriptions file, which the default + gateway's webhook adapter never reads. - Delivery follows the same binding. A `profile: coder` route's reply (or `deliver_only` message) goes out through **coder's** adapter for the `deliver` platform, falls back to **coder's** home channel when