fix(webhook): bind a subscription to a profile with --route-profile, not --profile

The salvaged flag was spelled --profile, which collides with the global
-p/--profile that hermes_cli.main scans BEFORE argparse: `hermes webhook
subscribe x --profile compta` would switch this CLI process to compta's
HERMES_HOME and write the subscription into compta's webhook_subscriptions.json
— a file the default gateway's webhook adapter never reads — while the route
still lacked the profile key. #109020 special-cased the scanner for the webhook
subcommand; naming the flag --route-profile removes the ambiguity without
touching _scan_profile_flag: -p picks the gateway whose subscriptions file is
written, --route-profile picks which /p/<profile>/ prefix may hit the route.

Docs: cli-commands reference row, multi-profile-gateways webhook section, the
route `profile` field. Builds on #109020 (fangliquanflq). Fixes #109016.
This commit is contained in:
teknium1
2026-09-13 12:44:46 -07:00
committed by Teknium
parent e0e3dc9341
commit 2dfd831d3b
6 changed files with 19 additions and 9 deletions
+5 -2
View File
@@ -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/<name> "
"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 "
+1 -1
View File
@@ -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:
+3 -3
View File
@@ -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
+2 -1
View File
@@ -841,8 +841,9 @@ hermes webhook subscribe <name> [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/<profile>/webhooks/<name>` 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`
@@ -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/<route>`. |
| `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/<route>`. Dynamic subscriptions set it with `hermes webhook subscribe <name> --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. |
@@ -193,7 +193,13 @@ using the default listener's existing credentials.
`config.yaml`. That secret is then accepted only at
`/p/coder/webhooks/<route>` 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 <name> --route-profile coder` writes
`profile: coder` into the default gateway's `webhook_subscriptions.json` and
prints the `/p/coder/webhooks/<name>` 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