diff --git a/contributors/emails/andrexibiza@gmail.com b/contributors/emails/andrexibiza@gmail.com new file mode 100644 index 000000000000..01c4cfb74a35 --- /dev/null +++ b/contributors/emails/andrexibiza@gmail.com @@ -0,0 +1 @@ +andrexibiza diff --git a/gateway/config.py b/gateway/config.py index a00fa0f9a1ca..0d02138bacff 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -2242,21 +2242,26 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: if api_server_model_name: config.platforms[Platform.API_SERVER].extra["model_name"] = api_server_model_name - # Webhook platform - webhook_enabled = is_truthy_value(getenv("WEBHOOK_ENABLED", "")) - webhook_port = getenv("WEBHOOK_PORT") - webhook_secret = getenv("WEBHOOK_SECRET", "") - if webhook_enabled: + # Webhook platform. Keep the effective resolver as the single source for + # management and runtime surfaces, including WEBHOOK_HOST (#13240). + from gateway.webhook_config import ( + resolve_effective_webhook_config, + resolve_effective_webhook_secret, + ) + + effective_webhook = resolve_effective_webhook_config() + if effective_webhook.enabled or Platform.WEBHOOK in config.platforms: if Platform.WEBHOOK not in config.platforms: config.platforms[Platform.WEBHOOK] = PlatformConfig() - config.platforms[Platform.WEBHOOK].enabled = True - if webhook_port: - try: - config.platforms[Platform.WEBHOOK].extra["port"] = int(webhook_port) - except ValueError: - pass - if webhook_secret: - config.platforms[Platform.WEBHOOK].extra["secret"] = webhook_secret + config.platforms[Platform.WEBHOOK].enabled = effective_webhook.enabled + config.platforms[Platform.WEBHOOK].extra["port"] = effective_webhook.port + config.platforms[Platform.WEBHOOK].extra["host"] = effective_webhook.host + if effective_webhook.global_secret_ref: + # Keep only the resolver key in the runtime config object. The + # adapter resolves this reference inside the active profile scope. + config.platforms[Platform.WEBHOOK].extra["secret_ref"] = ( + effective_webhook.global_secret_ref + ) # Microsoft Graph webhook platform msgraph_webhook_enabled = is_truthy_value(getenv("MSGRAPH_WEBHOOK_ENABLED", "")) diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 7997160008f0..7c530db340b1 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -8,7 +8,8 @@ Configuration lives in config.yaml under platforms.webhook.extra.routes. Each route defines: - events: which event types to accept (header-based filtering) - - secret: HMAC secret for signature validation (REQUIRED) + - secret_ref: profile secret reference for signature validation (REQUIRED) + - secret: legacy HMAC secret, accepted only for incremental migration - prompt: template string formatted with the webhook payload - skills: optional list of skills to load for the agent - deliver: where to send the response (github_comment, telegram, etc.) @@ -63,6 +64,10 @@ DEFAULT_SCRIPT_TIMEOUT_SECONDS, WebhookRouteProcessor, ) +from gateway.platforms.webhook_profile_admission import ( + WebhookProfileAdmissionMixin, + _PROFILE_REJECTED, +) from gateway.response_filters import is_autonomous_silence_response logger = logging.getLogger(__name__) @@ -96,11 +101,6 @@ def _is_webhook_silence_response(content: Any) -> bool: """ return is_autonomous_silence_response(content) -# Sentinel returned by _resolve_request_profile when a /p// prefix -# names a profile this gateway does not serve (→ 404). Distinct from None -# (no prefix / multiplexing off → handle as the default profile). -_PROFILE_REJECTED = object() - _BUILTIN_DELIVER_PLATFORMS = { "telegram", "discord", "slack", "signal", "sms", "whatsapp", "matrix", "mattermost", "homeassistant", "email", "dingtalk", @@ -174,7 +174,7 @@ def check_webhook_requirements() -> bool: return AIOHTTP_AVAILABLE -class WebhookAdapter(BasePlatformAdapter): +class WebhookAdapter(WebhookProfileAdmissionMixin, BasePlatformAdapter): """Generic webhook receiver that triggers agent runs from HTTP POSTs.""" # No human is present to answer a "session restored — what next?" prompt: @@ -192,6 +192,7 @@ def __init__(self, config: PlatformConfig): self._host: Optional[str] = _cfg_host or None self._port: int = int(config.extra.get("port", DEFAULT_PORT)) self._global_secret: str = config.extra.get("secret", "") + self._global_secret_ref: str = str(config.extra.get("secret_ref", "") or "") self._static_routes: Dict[str, dict] = config.extra.get("routes", {}) self._dynamic_routes: Dict[str, dict] = {} self._dynamic_routes_mtime: float = 0.0 @@ -241,6 +242,40 @@ def __init__(self, config: PlatformConfig): script_timeout_seconds=self._script_timeout_seconds ) + @staticmethod + def _resolve_secret_ref(secret_ref: object) -> str: + """Resolve a route reference from the active profile secret scope.""" + if not isinstance(secret_ref, str) or not secret_ref.strip(): + return "" + try: + from agent.secret_scope import get_secret + resolved = get_secret(secret_ref.strip(), "") + if resolved: + return str(resolved) + # Preserve legacy WEBHOOK_SECRET values during incremental + # migration; new route references never take this branch. + if secret_ref.strip() == "WEBHOOK_SECRET": + from gateway.webhook_config import resolve_effective_webhook_secret + return resolve_effective_webhook_secret() + return "" + except Exception: + return "" + + def _route_secret(self, route: object) -> str: + """Resolve references first, retaining plaintext only for legacy routes.""" + if isinstance(route, dict): + ref = route.get("secret_ref") + if ref: + return self._resolve_secret_ref(ref) + legacy = route.get("secret") + if isinstance(legacy, str): + return legacy + if self._global_secret_ref: + resolved = self._resolve_secret_ref(self._global_secret_ref) + if resolved: + return resolved + return self._global_secret + # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ @@ -251,7 +286,7 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: # Validate routes at startup — secret is required per route for name, route in self._routes.items(): - secret = route.get("secret", self._global_secret) + secret = self._route_secret(route) if not secret: raise ValueError( f"[webhook] Route '{name}' has no HMAC secret. " @@ -503,9 +538,17 @@ async def _handle_health(self, request: "web.Request") -> "web.Response": def _reload_dynamic_routes(self) -> None: """Reload agent-created subscriptions from disk if the file changed.""" - from hermes_constants import get_hermes_home - hermes_home = get_hermes_home() - subs_path = hermes_home / _DYNAMIC_ROUTES_FILENAME + from gateway.webhook_config import resolve_effective_webhook_config + + subs_path = resolve_effective_webhook_config().routes_path + if subs_path.exists(): + try: + from hermes_cli.migrations.webhook_secret_refs import migrate_webhook_routes + migrate_webhook_routes(subs_path) + except Exception as exc: + # Migration is fail-safe: source remains byte-identical before + # the atomic switch, so legacy routes may continue to resolve. + logger.warning("[webhook] secret-ref migration deferred: %s", exc) if not subs_path.exists(): if self._dynamic_routes: self._dynamic_routes = {} @@ -527,7 +570,7 @@ def _reload_dynamic_routes(self) -> None: for k, v in data.items(): if k in self._static_routes: continue - effective_secret = v.get("secret", self._global_secret) + effective_secret = self._route_secret(v) if not effective_secret: logger.warning( "[webhook] Dynamic route '%s' skipped: 'secret' is " @@ -560,65 +603,6 @@ def _reload_dynamic_routes(self) -> None: except Exception as e: logger.error("[webhook] Failed to reload dynamic routes: %s", e) - def _resolve_request_profile(self, request: "web.Request"): - """Resolve + validate the /p// URL prefix on a webhook request. - - Returns: - - ``None`` when no profile prefix is present, or multiplexing is off - (the prefix is ignored, request handled as the default profile). - - the profile name (str) when present, multiplexing is on, and the - profile is one this gateway serves. - - ``_PROFILE_REJECTED`` when a prefix is present but the profile is - unknown/unconfigured (handler returns 404). - """ - profile = (request.match_info.get("profile") or "").strip() - if not profile: - return None - runner = self.gateway_runner - cfg = getattr(runner, "config", None) - if not getattr(cfg, "multiplex_profiles", False): - # Prefix supplied but multiplexing is off — ignore it, behave as - # the single-profile gateway (don't 404 a would-be valid route). - return None - try: - from hermes_cli.profiles import profiles_to_serve - served = { - name - for name, _ in profiles_to_serve( - multiplex=True, - profile_allowlist=getattr( - cfg, "multiplex_profile_allowlist", None - ), - ) - } - except Exception: - return _PROFILE_REJECTED - if profile not in served: - return _PROFILE_REJECTED - return profile - - @staticmethod - def _route_allows_profile( - route_config: dict, - request_profile: Optional[str], - ) -> bool: - """Return whether a route is bound to the URL-selected profile. - - Omitting ``profile`` keeps a route on the default profile. An explicit - null, blank, or non-string value is malformed and fails closed. - """ - if "profile" not in route_config: - configured_profile = "default" - else: - configured_profile = route_config.get("profile") - if not isinstance(configured_profile, str): - return False - configured_profile = configured_profile.strip() - if not configured_profile: - return False - effective_profile = request_profile or "default" - return configured_profile == effective_profile - async def _handle_webhook(self, request: "web.Request") -> "web.Response": """POST /webhooks/{route_name} — receive and process a webhook event.""" # Hot-reload dynamic subscriptions on each request (mtime-gated, cheap) @@ -692,7 +676,7 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response": # INSECURE_NO_AUTH mode). Missing/empty secrets must fail closed here, # not only during connect(), so direct handler reuse cannot turn a # network webhook route into an unauthenticated agent-dispatch surface. - secret = route_config.get("secret", self._global_secret) + secret = self._route_secret(route_config) if not secret: logger.error( "[webhook] Route %s has no HMAC secret; refusing request", diff --git a/gateway/platforms/webhook_profile_admission.py b/gateway/platforms/webhook_profile_admission.py new file mode 100644 index 000000000000..1818d34c06c5 --- /dev/null +++ b/gateway/platforms/webhook_profile_admission.py @@ -0,0 +1,76 @@ +"""Profile admission policy for the generic webhook adapter.""" + +from typing import Optional + +try: + from aiohttp import web +except ImportError: + web = None # type: ignore[assignment] + + +# Sentinel returned by _resolve_request_profile when a /p// prefix +# names a profile this gateway does not serve (→ 404). Distinct from None +# (no prefix / multiplexing off → handle as the default profile). +_PROFILE_REJECTED = object() + + +class WebhookProfileAdmissionMixin: + """Resolve and authorize profile-bound webhook requests.""" + + def _resolve_request_profile(self, request: "web.Request"): + """Resolve + validate the /p// URL prefix on a webhook request. + + Returns: + - ``None`` when no profile prefix is present, or multiplexing is off + (the prefix is ignored, request handled as the default profile). + - the profile name (str) when present, multiplexing is on, and the + profile is one this gateway serves. + - ``_PROFILE_REJECTED`` when a prefix is present but the profile is + unknown/unconfigured (handler returns 404). + """ + profile = (request.match_info.get("profile") or "").strip() + if not profile: + return None + runner = self.gateway_runner + cfg = getattr(runner, "config", None) + if not getattr(cfg, "multiplex_profiles", False): + # Prefix supplied but multiplexing is off — ignore it, behave as + # the single-profile gateway (don't 404 a would-be valid route). + return None + try: + from hermes_cli.profiles import profiles_to_serve + + served = { + name + for name, _ in profiles_to_serve( + multiplex=True, + profile_allowlist=getattr(cfg, "multiplex_profile_allowlist", None), + ) + } + except Exception: + return _PROFILE_REJECTED + if profile not in served: + return _PROFILE_REJECTED + return profile + + @staticmethod + def _route_allows_profile( + route_config: dict, + request_profile: Optional[str], + ) -> bool: + """Return whether a route is bound to the URL-selected profile. + + Omitting ``profile`` keeps a route on the default profile. An explicit + null, blank, or non-string value is malformed and fails closed. + """ + if "profile" not in route_config: + configured_profile = "default" + else: + configured_profile = route_config.get("profile") + if not isinstance(configured_profile, str): + return False + configured_profile = configured_profile.strip() + if not configured_profile: + return False + effective_profile = request_profile or "default" + return configured_profile == effective_profile diff --git a/gateway/webhook_config.py b/gateway/webhook_config.py new file mode 100644 index 000000000000..9f4629c5455a --- /dev/null +++ b/gateway/webhook_config.py @@ -0,0 +1,187 @@ +"""Unified effective configuration for the generic webhook listener. + +This module is deliberately value-aware only at resolution time. Callers that +need to display configuration can inspect ``source_map`` without printing any +secret value. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Mapping + +from agent.secret_scope import current_secret_scope +from hermes_cli.config import load_config_readonly +from hermes_cli.env_loader import get_secret_source_values +from hermes_cli.profiles import get_profile_dir + + + +WebhookSource = Literal["default", "yaml", "env", "profile"] + +DEFAULT_WEBHOOK_ENABLED = False +DEFAULT_WEBHOOK_HOST: str | None = None +DEFAULT_WEBHOOK_PORT = 8644 +_DEFAULT_ROUTES_FILENAME = "webhook_subscriptions.json" + + +@dataclass(frozen=True) +class EffectiveWebhookConfig: + """The resolved listener settings and non-sensitive provenance metadata.""" + + enabled: bool + host: str | None + port: int + profile: str + global_secret_ref: str | None + routes_path: Path + source_map: Mapping[str, WebhookSource] + + +def _as_mapping(value: object) -> dict: + return value if isinstance(value, dict) else {} + + +def _bool_value(value: object, default: bool) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def _int_value(value: object, default: int) -> int: + try: + return int(str(value).strip(), 10) + except (TypeError, ValueError): + return default + + +def _yaml_webhook(home: Path) -> dict: + """Read webhook platform config through the approved config-loading seam. + + ``load_config_readonly`` is the canonical owner for behavioral reads; it + applies the managed-scope overlay, ``${ENV_VAR}`` expansion, profile-aware + pathing, and root-model normalization. A raw ``yaml.safe_load`` here would + trip the config-read-guard lint (raw reads only allowed in owner modules). + """ + try: + data = load_config_readonly() or {} + except Exception: + return {} + platforms = _as_mapping(data).get("platforms") + webhook = _as_mapping(_as_mapping(platforms).get("webhook")) + extra = _as_mapping(webhook.get("extra")) + # Accept the documented platform fields and the legacy adapter shape. + result = dict(extra) + result.update({key: webhook[key] for key in ("enabled", "host", "port", "secret", "secret_ref", "routes_path") if key in webhook}) + return result + + +def _profile_env(home: Path) -> dict[str, str]: + values: dict[str, str] = {} + try: + from agent.secret_scope import build_profile_secret_scope + + values.update(build_profile_secret_scope(home)) + except Exception: + pass + try: + values.update(get_secret_source_values(home)) + except Exception: + pass + return values + + +def _env_value(name: str, profile_env: Mapping[str, str], scope: Mapping[str, str] | None) -> tuple[str | None, WebhookSource | None]: + if scope is not None and name in scope: + return scope[name], "profile" + if name in profile_env: + return profile_env[name], "profile" + if name in os.environ: + return os.environ[name], "env" + return None, None + + +def resolve_effective_webhook_config(profile: str = "default") -> EffectiveWebhookConfig: + """Resolve defaults, profile YAML, then profile/env webhook settings. + + ``global_secret_ref`` is a reference name (normally ``WEBHOOK_SECRET``), + never the resolved secret. Profile ``.env``/secret-scope values are marked + ``profile``; process environment values are marked ``env``. + """ + home = get_profile_dir(profile) + yaml_values = _yaml_webhook(home) + profile_env = _profile_env(home) + scope = current_secret_scope() + + enabled = DEFAULT_WEBHOOK_ENABLED + host = DEFAULT_WEBHOOK_HOST + port = DEFAULT_WEBHOOK_PORT + secret_ref: str | None = None + source: dict[str, WebhookSource] = { + "enabled": "default", + "host": "default", + "port": "default", + "global_secret_ref": "default", + "routes_path": "profile" if profile != "default" else "default", + } + + if "enabled" in yaml_values: + enabled = _bool_value(yaml_values["enabled"], enabled) + source["enabled"] = "yaml" + if "host" in yaml_values: + host = str(yaml_values["host"]).strip() or None + source["host"] = "yaml" + if "port" in yaml_values: + port = _int_value(yaml_values["port"], port) + source["port"] = "yaml" + if yaml_values.get("secret_ref") or yaml_values.get("secret"): + secret_ref = str(yaml_values.get("secret_ref") or "WEBHOOK_SECRET") + source["global_secret_ref"] = "yaml" + routes_path = home / str(yaml_values.get("routes_path") or _DEFAULT_ROUTES_FILENAME) + if yaml_values.get("routes_path"): + source["routes_path"] = "yaml" + + for field, env_name in (("enabled", "WEBHOOK_ENABLED"), ("host", "WEBHOOK_HOST"), ("port", "WEBHOOK_PORT")): + raw, origin = _env_value(env_name, profile_env, scope) + if raw is None: + continue + if field == "enabled": + enabled = _bool_value(raw, enabled) + elif field == "host": + host = str(raw).strip() or None + else: + port = _int_value(raw, port) + source[field] = origin # type: ignore[assignment] + + raw_secret, secret_origin = _env_value("WEBHOOK_SECRET", profile_env, scope) + if raw_secret is not None and str(raw_secret).strip(): + # Expose only the reference identifier, never its value. + secret_ref = "WEBHOOK_SECRET" + source["global_secret_ref"] = secret_origin # type: ignore[assignment] + + return EffectiveWebhookConfig( + enabled=enabled, + host=host, + port=port, + profile=profile, + global_secret_ref=secret_ref, + routes_path=routes_path, + source_map=source, + ) + + +def resolve_effective_webhook_secret(profile: str = "default") -> str: + """Resolve the global HMAC secret for runtime use without exposing it in config.""" + home = get_profile_dir(profile) + yaml_values = _yaml_webhook(home) + profile_env = _profile_env(home) + scope = current_secret_scope() + raw, _ = _env_value("WEBHOOK_SECRET", profile_env, scope) + if raw is not None and str(raw).strip(): + return str(raw) + yaml_secret = yaml_values.get("secret") + return str(yaml_secret).strip() if yaml_secret else "" diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 29c7a4551e67..31309ed8a413 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1171,9 +1171,22 @@ def _is_env_config_key(key: str) -> bool: key_upper in api_keys or key_upper.endswith(('_API_KEY', '_TOKEN', '_SECRET')) or key_upper.startswith('TERMINAL_SSH') + or key_upper == 'WEBHOOK_SECRET' + or key_upper.startswith('WEBHOOK_ROUTE_') ) +WEBHOOK_SECRET_REMEDIATION = ( + "Webhook secrets cannot be stored in config.yaml. " + "Use 'hermes webhook subscribe ' or the profile secret backend." +) + + +def _is_webhook_secret_config_key(key: str) -> bool: + parts = {part.lower() for part in key.split('.') if part} + return 'webhook' in parts and bool(parts & {'secret', 'secret_ref', 'secret_value'}) + + def _format_config_get_value(value, *, as_json: bool) -> str: """Format a config value for command-line output.""" if as_json: @@ -5135,6 +5148,8 @@ def set_config_value(key: str, value: str, force: bool = False): file=sys.stderr, ) sys.exit(1) + if _is_webhook_secret_config_key(key): + raise ValueError(WEBHOOK_SECRET_REMEDIATION) # Check if it's an API key (goes to .env) if _is_env_config_key(key): # Unified lifecycle: also rotates any config.yaml mirror of the old @@ -5338,6 +5353,15 @@ def get_config_value(key: str, *, as_json: bool = False): print(f"Config key not set: {key}", file=sys.stderr) sys.exit(1) + if _is_webhook_secret_config_key(key) or key.upper() in {"WEBHOOK_SECRET"} or key.upper().startswith("WEBHOOK_ROUTE_"): + if isinstance(value, str) and value: + # Scalar webhook secret keys (e.g. `WEBHOOK_SECRET` read from .env) + # are masked directly — redact_config_value only masks dict keys + # and would pass a scalar through unchanged, leaking the secret. + from agent.redact import mask_secret + value = mask_secret(value) + else: + value = redact_config_value(value) print(_format_config_get_value(value, as_json=as_json)) diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 584b10b36adc..990e59485fd0 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -4440,6 +4440,13 @@ "password": False, "category": "messaging", }, + "WEBHOOK_HOST": { + "description": "Host/interface for the webhook HTTP server (empty binds all interfaces).", + "prompt": "Webhook bind host", + "url": None, + "password": False, + "category": "messaging", + }, "WEBHOOK_PORT": { "description": "Port for the webhook HTTP server (default: 8644).", "prompt": "Webhook port", diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index f97f8ada99e2..dd03fa30a892 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -5391,6 +5391,34 @@ def _hard_exit_after_gateway_teardown(code: int) -> None: # Matrix moved to plugins/platforms/matrix/ — setup metadata discovered # dynamically via the platform registry entry registered by # plugins/platforms/matrix/adapter.py::register(). #41112. + { + "key": "webhook", + "label": "Webhooks", + "emoji": "🪝", + "token_var": "WEBHOOK_ENABLED", + "vars": [ + { + "name": "WEBHOOK_ENABLED", + "prompt": "Enable webhooks (true/false)", + "password": False, + }, + { + "name": "WEBHOOK_HOST", + "prompt": "Webhook bind host (empty for all interfaces)", + "password": False, + }, + { + "name": "WEBHOOK_PORT", + "prompt": "Webhook listener port", + "password": False, + }, + { + "name": "WEBHOOK_SECRET", + "prompt": "Global HMAC secret", + "password": True, + }, + ], + }, { "key": "mattermost", "label": "Mattermost", @@ -6507,6 +6535,7 @@ def _builtin_setup_fn(key: str): # plugins/platforms/mattermost/adapter.py::register() and dispatched # via the plugin path in _configure_platform(). "bluebubbles": _s._setup_bluebubbles, + "webhook": _s._setup_webhooks, "webhooks": _s._setup_webhooks, "signal": _setup_signal, # whatsapp + dingtalk moved into plugins: setup_fn registered by diff --git a/hermes_cli/migrations/__init__.py b/hermes_cli/migrations/__init__.py new file mode 100644 index 000000000000..cd7fa13d3962 --- /dev/null +++ b/hermes_cli/migrations/__init__.py @@ -0,0 +1 @@ +"""Explicit data migrations used by Hermes CLI surfaces.""" diff --git a/hermes_cli/migrations/webhook_secret_refs.py b/hermes_cli/migrations/webhook_secret_refs.py new file mode 100644 index 000000000000..0a36bf62dd69 --- /dev/null +++ b/hermes_cli/migrations/webhook_secret_refs.py @@ -0,0 +1,317 @@ +"""Atomic migration of webhook plaintext secrets to profile references. + +The migration keeps plaintext source bytes intact until secure persistence has +accepted and resolved every value. Default webhook writers share one bounded +cross-process lock so CLI updates and runtime migration cannot race each other. +Receipts never contain secret values. +""" +from __future__ import annotations + +import copy +import json +import os +import tempfile +from contextlib import nullcontext +from pathlib import Path +from typing import Any, Callable, Mapping + +import yaml + +from hermes_cli.webhook_secrets import ( + resolve_webhook_secret, + store_webhook_secret, + store_webhook_secret_unlocked, + webhook_secret_write_lock, +) + +DEFAULT_SECRET_PREFIX = "WEBHOOK_ROUTE_" +_SECRET_KEYS = ("secret", "secret_value") + + +class WebhookSecretMigrationError(RuntimeError): + """Raised when a webhook secret migration cannot complete safely.""" + + def __init__(self, message: str, *, receipt: dict[str, Any] | None = None, source: str = ""): + super().__init__(message) + self.receipt = receipt or {} + self.rollback_receipt = {"source": source, "source_preserved": True} + + +def _reference(route_name: str, route: Mapping[str, Any]) -> str: + ref = route.get("secret_ref") + if isinstance(ref, str) and ref.strip(): + return ref.strip() + safe = "".join(ch if ch.isalnum() else "_" for ch in route_name.upper()) + return f"{DEFAULT_SECRET_PREFIX}{safe}" + + +def _route_secret(route: Mapping[str, Any]) -> str | None: + for key in _SECRET_KEYS: + value = route.get(key) + if isinstance(value, str) and value: + return value + return None + + +def _write_json_atomic(path: Path, data: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) + tmp_path = Path(tmp) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + json.dump(data, stream, indent=2, ensure_ascii=False) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.chmod(tmp_path, 0o600) + os.replace(tmp_path, path) + os.chmod(path, 0o600) + except BaseException: + try: + tmp_path.unlink(missing_ok=True) + except OSError: + pass + raise + + +def _writer_context(store: Callable[[str, str], None] | None): + # Injected stores are test/operator-owned and must not be serialized by a + # Hermes-home lock. The production default owns the lock for the complete + # read → persist → verify → switch transaction. + return webhook_secret_write_lock() if store is None else nullcontext() + + +def migrate_webhook_routes( + source_path: str | Path, + *, + store: Callable[[str, str], None] | None = None, + resolve: Callable[[str], str | None] | None = None, + backup_paths: tuple[str | Path, ...] = (), +) -> dict[str, Any]: + """Migrate route JSON using write → resolve → verify → switch → scrub.""" + path = Path(source_path) + with _writer_context(store): + try: + original = path.read_text(encoding="utf-8") + routes = json.loads(original) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + raise WebhookSecretMigrationError( + "Unable to read webhook routes safely", source=str(path) + ) from None + if not isinstance(routes, dict): + raise WebhookSecretMigrationError( + "Webhook route store must be a JSON object", source=str(path) + ) + + put = store or store_webhook_secret_unlocked + lookup = resolve or resolve_webhook_secret + staged = copy.deepcopy(routes) + migrated: list[str] = [] + receipts: list[dict[str, Any]] = [] + + for name, route in routes.items(): + if not isinstance(route, dict): + continue + value = _route_secret(route) + if not value or route.get("secret_ref"): + continue + ref = _reference(str(name), route) + receipt = { + "route": str(name), + "reference": ref, + "stored": False, + "verified": False, + } + receipts.append(receipt) + try: + put(ref, value) + receipt["stored"] = True + if lookup(ref) != value: + raise WebhookSecretMigrationError( + "Secret backend verification failed", + receipt=receipt, + source=str(path), + ) + receipt["verified"] = True + except WebhookSecretMigrationError: + raise + except Exception: + raise WebhookSecretMigrationError( + f"Secure persistence failed for route {name!r}; source left untouched", + receipt=receipt, + source=str(path), + ) from None + staged[name].pop("secret", None) + staged[name].pop("secret_value", None) + staged[name]["secret_ref"] = ref + migrated.append(str(name)) + + if migrated: + try: + _write_json_atomic(path, staged) + except Exception: + raise WebhookSecretMigrationError( + "Atomic route switch failed; source remains available for rollback", + receipt={"migrated_routes": migrated}, + source=str(path), + ) from None + + scrubbed: list[str] = [] + for raw_backup in backup_paths: + backup = Path(raw_backup) + if not backup.exists(): + continue + try: + backup_routes = json.loads(backup.read_text(encoding="utf-8")) + if not isinstance(backup_routes, dict): + continue + changed = False + for name, route in backup_routes.items(): + if not isinstance(route, dict): + continue + staged_route = staged.get(name) + ref = staged_route.get("secret_ref") if isinstance(staged_route, dict) else None + if ref and _route_secret(route): + route.pop("secret", None) + route.pop("secret_value", None) + route["secret_ref"] = ref + changed = True + if changed: + _write_json_atomic(backup, backup_routes) + scrubbed.append(str(backup)) + except Exception: + raise WebhookSecretMigrationError( + "Route switched but backup scrub failed; rollback receipt retained", + receipt={"migrated_routes": migrated, "scrubbed_backups": scrubbed}, + source=str(path), + ) from None + + return { + "migrated_routes": migrated, + "receipts": receipts, + "scrubbed_backups": scrubbed, + "rollback": { + "source": str(path), + "source_preserved_on_pre_switch_failure": True, + }, + } + + +def migrate_webhook_config( + config_path: str | Path, + *, + store: Callable[[str, str], None] | None = None, + resolve: Callable[[str], str | None] | None = None, +) -> dict[str, Any]: + """Migrate global and static-route webhook secrets in config.yaml.""" + path = Path(config_path) + with _writer_context(store): + try: + original = path.read_text(encoding="utf-8") + config = yaml.safe_load(original) or {} + except (OSError, UnicodeDecodeError, yaml.YAMLError): + raise WebhookSecretMigrationError( + "Unable to parse webhook config safely", source=str(path) + ) from None + if not isinstance(config, dict): + raise WebhookSecretMigrationError( + "Webhook config must be a YAML mapping", source=str(path) + ) + + put = store or store_webhook_secret_unlocked + lookup = resolve or resolve_webhook_secret + staged = copy.deepcopy(config) + platforms = staged.get("platforms") + webhook = platforms.get("webhook", {}) if isinstance(platforms, dict) else {} + if not isinstance(webhook, dict): + return {"migrated": False, "receipts": [], "rollback": {"source": str(path)}} + extra = webhook.get("extra", {}) + if not isinstance(extra, dict): + extra = {} + webhook["extra"] = extra + + candidates: list[tuple[str, str, str]] = [] + global_secret = ( + extra.get("secret") + or extra.get("secret_value") + or webhook.get("secret") + or webhook.get("secret_value") + ) + if isinstance(global_secret, str) and global_secret: + candidates.append(("WEBHOOK_SECRET", global_secret, "global")) + routes = extra.get("routes") + if isinstance(routes, dict): + for name, route in routes.items(): + if isinstance(route, dict): + value = _route_secret(route) + if value: + candidates.append((_reference(str(name), route), value, str(name))) + + receipts: list[dict[str, Any]] = [] + for ref, value, label in candidates: + receipt = {"route": label, "reference": ref, "stored": False, "verified": False} + receipts.append(receipt) + try: + put(ref, value) + receipt["stored"] = True + if lookup(ref) != value: + raise WebhookSecretMigrationError( + "Secret backend verification failed", + receipt=receipt, + source=str(path), + ) + receipt["verified"] = True + except WebhookSecretMigrationError: + raise + except Exception: + raise WebhookSecretMigrationError( + f"Secure persistence failed for webhook secret {label!r}; source left untouched", + receipt=receipt, + source=str(path), + ) from None + + if candidates: + extra.pop("secret", None) + extra.pop("secret_value", None) + if global_secret: + extra["secret_ref"] = "WEBHOOK_SECRET" + webhook.pop("secret", None) + webhook.pop("secret_value", None) + if isinstance(routes, dict): + for name, route in routes.items(): + if isinstance(route, dict) and _route_secret(route): + route["secret_ref"] = _reference(str(name), route) + route.pop("secret", None) + route.pop("secret_value", None) + try: + from hermes_cli.config import atomic_config_write + + atomic_config_write(path, staged, sort_keys=False) + except Exception: + raise WebhookSecretMigrationError( + "Atomic config switch failed; source remains available for rollback", + receipt={"migrated": True}, + source=str(path), + ) from None + + return { + "migrated": bool(candidates), + "receipts": receipts, + "rollback": { + "source": str(path), + "source_preserved_on_pre_switch_failure": True, + }, + } + + +migrate = migrate_webhook_routes +migrate_webhook_secret_refs = migrate_webhook_routes + +__all__ = [ + "WebhookSecretMigrationError", + "migrate_webhook_config", + "migrate_webhook_routes", + "migrate_webhook_secret_refs", + "resolve_webhook_secret", + "store_webhook_secret", +] diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 3c65981cbb06..5590595bae02 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2106,8 +2106,15 @@ def _setup_qqbot(): def _setup_webhooks(): """Configure webhook integration.""" + from gateway.webhook_config import resolve_effective_webhook_config + print_header("Webhooks") existing = get_env_value("WEBHOOK_ENABLED") + if not existing: + try: + existing = "true" if resolve_effective_webhook_config().enabled else None + except Exception: + existing = None if existing: print_info("Webhooks: already configured") if not prompt_yes_no("Reconfigure webhooks?", False): @@ -2121,13 +2128,22 @@ def _setup_webhooks(): print_info(" Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/") print() - port = prompt("Webhook port (default 8644)") + current = resolve_effective_webhook_config() + current_host = current.host or "" + host = prompt( + f"Webhook bind host (empty for all interfaces) [{current_host}]" + ) + if host: + save_env_value("WEBHOOK_HOST", host) + print_success(f"Webhook host set to {host}") + + port = prompt(f"Webhook port (default {current.port})") if port: try: save_env_value("WEBHOOK_PORT", str(int(port))) print_success(f"Webhook port set to {port}") except ValueError: - print_warning("Invalid port number, using default 8644") + print_warning(f"Invalid port number, using default {current.port}") secret = prompt("Global HMAC secret (shared across all routes)", password=True) if secret: @@ -2137,18 +2153,21 @@ def _setup_webhooks(): print_warning("No secret set — you must configure per-route secrets in config.yaml") save_env_value("WEBHOOK_ENABLED", "true") + effective = resolve_effective_webhook_config() + display_host = effective.host or "localhost" + if ":" in display_host and not display_host.startswith("["): + display_host = f"[{display_host}]" print() print_success("Webhooks enabled! Next steps:") from hermes_constants import display_hermes_home as _dhh print_info(f" 1. Define webhook routes in {_dhh()}/config.yaml") print_info(" 2. Point your service (GitHub, GitLab, etc.) at:") - print_info(" http://your-server:8644/webhooks/") + print_info(f" http://{display_host}:{effective.port}/webhooks/") print() print_info(" Route configuration guide:") print_info(" https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/#configuring-routes") print() print_info(" Open config in your editor: hermes config edit") - print_info(" Open config in your editor: hermes config edit") def setup_gateway(config: dict): diff --git a/hermes_cli/subcommands/webhook.py b/hermes_cli/subcommands/webhook.py index 38085141b069..6540b775a779 100644 --- a/hermes_cli/subcommands/webhook.py +++ b/hermes_cli/subcommands/webhook.py @@ -11,13 +11,10 @@ def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None: """Attach the ``webhook`` subcommand to ``subparsers``.""" - # ========================================================================= - # webhook command - # ========================================================================= 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, remove, and migrate webhook subscriptions for event-driven agent activation", ) webhook_subparsers = webhook_parser.add_subparsers(dest="webhook_action") @@ -51,22 +48,26 @@ def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None: wh_sub.add_argument( "--deliver-only", action="store_true", - help="Skip the agent — deliver the rendered prompt directly as the " - "message. Zero LLM cost. Requires --deliver to be a real target " - "(not 'log').", + help="Skip the agent — deliver the rendered prompt directly as the message. Zero LLM cost. Requires --deliver to be a real target (not 'log').", ) wh_sub.add_argument( "--script", default="", - help="Filter/transform script under ~/.hermes/scripts/. The route " - "payload is passed as JSON on stdin; empty stdout, [SILENT], or a " - "nonzero exit code ignores the webhook.", + help="Filter/transform script under ~/.hermes/scripts/. The route payload is passed as JSON on stdin; empty stdout, [SILENT], or a nonzero exit code ignores the webhook.", ) webhook_subparsers.add_parser( "list", aliases=["ls"], help="List all dynamic subscriptions" ) + wh_migrate = webhook_subparsers.add_parser( + "migrate-secrets", + help="Move legacy plaintext webhook secrets into the profile secret backend", + ) + wh_migrate.add_argument( + "--json", action="store_true", help="Emit value-free migration receipts as JSON" + ) + wh_rm = webhook_subparsers.add_parser( "remove", aliases=["rm"], help="Remove a subscription" ) diff --git a/hermes_cli/web_routers/webhooks.py b/hermes_cli/web_routers/webhooks.py new file mode 100644 index 000000000000..e69f20b16bd1 --- /dev/null +++ b/hermes_cli/web_routers/webhooks.py @@ -0,0 +1,183 @@ +"""Webhook subscription dashboard routes (extracted verbatim from web_server.py). + +Handler bodies are byte-identical to their previous in-web_server form; the +helpers they call (``_write_platform_enabled``, ``_restart_gateway_after_webhook_enable``) +still live in web_server and are reached via the late-binding seam in +:mod:`hermes_cli.web_deps`, so ``monkeypatch.setattr(web_server, ...)`` keeps +working. +""" + +import logging +from typing import Any, Dict # noqa: F401 + +from fastapi import APIRouter, HTTPException # noqa: F401 + +from hermes_cli.web_deps import late +from hermes_cli.web_models import WebhookCreate, WebhookEnabledToggle # noqa: F401 + +# Same logger the handlers used before extraction (identical logger object). +_log = logging.getLogger("hermes_cli.web_server") + +router = APIRouter() + +# Late-bound web_server helpers (resolved at call time; cycle-safe, +# monkeypatch-transparent). +_write_platform_enabled = late("_write_platform_enabled") +_restart_gateway_after_webhook_enable = late("_restart_gateway_after_webhook_enable") +_webhook_route_summary_for_handler = late("_webhook_route_summary") + + +def _webhook_route_summary(name: str, route: Dict[str, Any], base_url: str) -> Dict[str, Any]: + return { + "name": name, + "description": route.get("description", ""), + "events": list(route.get("events") or []), + "deliver": route.get("deliver", "log"), + "deliver_only": bool(route.get("deliver_only")), + "prompt": route.get("prompt", ""), + "script": route.get("script", ""), + "skills": list(route.get("skills") or []), + "created_at": route.get("created_at"), + "url": f"{base_url}/webhooks/{name}", + # Never return the secret or a secret-shaped value on reads. + "secret_set": bool(route.get("secret_ref") or route.get("secret")), + "secret_ref": route.get("secret_ref"), + # Default-enabled; only an explicit enabled:false turns a route off. + "enabled": route.get("enabled", True) is not False, + } + + +# --------------------------------------------------------------------------- +# Webhook subscription endpoints — list / subscribe / remove. +# +# Wraps the same JSON store the CLI uses (hermes_cli.webhook); the webhook +# adapter hot-reloads it without a gateway restart. Per-route HMAC secrets +# are redacted on read and surfaced once on create. +# --------------------------------------------------------------------------- + + +@router.get("/api/webhooks") +async def list_webhooks(): + import hermes_cli.webhook as wh + + base_url = wh._get_webhook_base_url() + subs = wh._load_subscriptions() + return { + "enabled": wh._is_webhook_enabled(), + "base_url": base_url, + "subscriptions": [ + _webhook_route_summary_for_handler(name, route, base_url) + for name, route in subs.items() + ], + } + + +@router.post("/api/webhooks/enable") +async def enable_webhooks(): + try: + _write_platform_enabled("webhook", True) + except Exception as exc: + _log.exception("Failed to enable webhook platform from dashboard") + raise HTTPException( + status_code=500, + detail="Failed to enable webhook platform.", + ) from exc + + restart_result = _restart_gateway_after_webhook_enable() + return { + "ok": True, + "platform": "webhook", + "enabled": True, + "needs_restart": not restart_result["restart_started"], + **restart_result, + } + + +@router.post("/api/webhooks") +async def create_webhook(body: WebhookCreate): + import re as _re + import secrets as _secrets + import time as _time + import hermes_cli.webhook as wh + + if not wh._is_webhook_enabled(): + raise HTTPException( + status_code=400, + detail="Webhook platform is not enabled. Enable it from the Webhooks page first.", + ) + + name = (body.name or "").strip().lower().replace(" ", "-") + if not _re.match(r"^[a-z0-9][a-z0-9_-]*$", name): + raise HTTPException( + status_code=400, + detail="Invalid name. Use lowercase alphanumeric with hyphens/underscores.", + ) + + if body.deliver_only and body.deliver == "log": + raise HTTPException( + status_code=400, + detail="Direct delivery requires a real target (telegram, discord, …), not 'log'.", + ) + + secret = body.secret or _secrets.token_urlsafe(32) + # Persist only an opaque reference. The generated value is returned by the + # caller exactly once, but is never included in the route record. + secret_ref = wh._store_route_secret(name, secret) + route: Dict[str, Any] = { + "description": body.description or f"Dashboard-created subscription: {name}", + "events": [e.strip() for e in body.events if e.strip()], + "secret_ref": secret_ref, + "prompt": body.prompt or "", + "skills": [s.strip() for s in body.skills if s.strip()], + "deliver": body.deliver or "log", + "created_at": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()), + } + if body.script and body.script.strip(): + route["script"] = body.script.strip() + if body.deliver_only: + route["deliver_only"] = True + if body.deliver_chat_id: + route["deliver_extra"] = {"chat_id": body.deliver_chat_id} + + subs = wh._load_subscriptions() + subs[name] = route + wh._save_subscriptions(subs) + + base_url = wh._get_webhook_base_url() + summary = _webhook_route_summary_for_handler(name, route, base_url) + # Surface the secret exactly once, on create. + summary["secret"] = secret + return summary + + +@router.delete("/api/webhooks/{name}") +async def delete_webhook(name: str): + import hermes_cli.webhook as wh + + key = (name or "").strip().lower() + subs = wh._load_subscriptions() + if key not in subs: + raise HTTPException(status_code=404, detail=f"No subscription named '{key}'") + del subs[key] + wh._save_subscriptions(subs) + return {"ok": True} + + +@router.put("/api/webhooks/{name}/enabled") +async def set_webhook_enabled(name: str, body: WebhookEnabledToggle): + """Enable or disable a webhook route. + + Disabled routes stay in the subscriptions file (so they can be + re-enabled) but the gateway rejects incoming events with 403. The + gateway hot-reloads the subscriptions file, so this takes effect on the + next event without a restart. + """ + import hermes_cli.webhook as wh + + key = (name or "").strip().lower() + subs = wh._load_subscriptions() + if key not in subs: + raise HTTPException(status_code=404, detail=f"No subscription named '{key}'") + subs[key]["enabled"] = bool(body.enabled) + wh._save_subscriptions(subs) + return {"ok": True, "name": key, "enabled": bool(body.enabled)} diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 701c5662d6c1..2b002f328b7b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -12754,156 +12754,17 @@ async def clear_pending_pairing(profile: Optional[str] = None): return {"ok": True, "cleared": count} -# --------------------------------------------------------------------------- -# Webhook subscription endpoints — list / subscribe / remove. -# -# Wraps the same JSON store the CLI uses (hermes_cli.webhook); the webhook -# adapter hot-reloads it without a gateway restart. Per-route HMAC secrets -# are redacted on read and surfaced once on create. -# --------------------------------------------------------------------------- - - -def _webhook_route_summary(name: str, route: Dict[str, Any], base_url: str) -> Dict[str, Any]: - return { - "name": name, - "description": route.get("description", ""), - "events": list(route.get("events") or []), - "deliver": route.get("deliver", "log"), - "deliver_only": bool(route.get("deliver_only")), - "prompt": route.get("prompt", ""), - "script": route.get("script", ""), - "skills": list(route.get("skills") or []), - "created_at": route.get("created_at"), - "url": f"{base_url}/webhooks/{name}", - # Secret is masked on read; full value only returned on create. - "secret_set": bool(route.get("secret")), - # Default-enabled; only an explicit enabled:false turns a route off. - "enabled": route.get("enabled", True) is not False, - } - - -@app.get("/api/webhooks") -async def list_webhooks(): - import hermes_cli.webhook as wh - - base_url = wh._get_webhook_base_url() - subs = wh._load_subscriptions() - return { - "enabled": wh._is_webhook_enabled(), - "base_url": base_url, - "subscriptions": [ - _webhook_route_summary(name, route, base_url) - for name, route in subs.items() - ], - } - - -@app.post("/api/webhooks/enable") -async def enable_webhooks(): - try: - _write_platform_enabled("webhook", True) - except Exception as exc: - _log.exception("Failed to enable webhook platform from dashboard") - raise HTTPException( - status_code=500, - detail="Failed to enable webhook platform.", - ) from exc - - restart_result = _restart_gateway_after_webhook_enable() - return { - "ok": True, - "platform": "webhook", - "enabled": True, - "needs_restart": not restart_result["restart_started"], - **restart_result, - } - - -@app.post("/api/webhooks") -async def create_webhook(body: WebhookCreate): - import re as _re - import secrets as _secrets - import time as _time - import hermes_cli.webhook as wh - - if not wh._is_webhook_enabled(): - raise HTTPException( - status_code=400, - detail="Webhook platform is not enabled. Enable it from the Webhooks page first.", - ) - - name = (body.name or "").strip().lower().replace(" ", "-") - if not _re.match(r"^[a-z0-9][a-z0-9_-]*$", name): - raise HTTPException( - status_code=400, - detail="Invalid name. Use lowercase alphanumeric with hyphens/underscores.", - ) - - if body.deliver_only and body.deliver == "log": - raise HTTPException( - status_code=400, - detail="Direct delivery requires a real target (telegram, discord, …), not 'log'.", - ) - - secret = body.secret or _secrets.token_urlsafe(32) - route: Dict[str, Any] = { - "description": body.description or f"Dashboard-created subscription: {name}", - "events": [e.strip() for e in body.events if e.strip()], - "secret": secret, - "prompt": body.prompt or "", - "skills": [s.strip() for s in body.skills if s.strip()], - "deliver": body.deliver or "log", - "created_at": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()), - } - if body.script and body.script.strip(): - route["script"] = body.script.strip() - if body.deliver_only: - route["deliver_only"] = True - if body.deliver_chat_id: - route["deliver_extra"] = {"chat_id": body.deliver_chat_id} - - subs = wh._load_subscriptions() - subs[name] = route - wh._save_subscriptions(subs) - - base_url = wh._get_webhook_base_url() - summary = _webhook_route_summary(name, route, base_url) - # Surface the secret exactly once, on create. - summary["secret"] = secret - return summary - - -@app.delete("/api/webhooks/{name}") -async def delete_webhook(name: str): - import hermes_cli.webhook as wh - - key = (name or "").strip().lower() - subs = wh._load_subscriptions() - if key not in subs: - raise HTTPException(status_code=404, detail=f"No subscription named '{key}'") - del subs[key] - wh._save_subscriptions(subs) - return {"ok": True} - - -@app.put("/api/webhooks/{name}/enabled") -async def set_webhook_enabled(name: str, body: WebhookEnabledToggle): - """Enable or disable a webhook route. - - Disabled routes stay in the subscriptions file (so they can be - re-enabled) but the gateway rejects incoming events with 403. The - gateway hot-reloads the subscriptions file, so this takes effect on the - next event without a restart. - """ - import hermes_cli.webhook as wh - - key = (name or "").strip().lower() - subs = wh._load_subscriptions() - if key not in subs: - raise HTTPException(status_code=404, detail=f"No subscription named '{key}'") - subs[key]["enabled"] = bool(body.enabled) - wh._save_subscriptions(subs) - return {"ok": True, "name": key, "enabled": bool(body.enabled)} +from hermes_cli.web_routers import webhooks as _webhooks_routes # noqa: E402 + +app.include_router(_webhooks_routes.router) +from hermes_cli.web_routers.webhooks import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. + _webhook_route_summary, + list_webhooks, + enable_webhooks, + create_webhook, + delete_webhook, + set_webhook_enabled, +) # --------------------------------------------------------------------------- diff --git a/hermes_cli/webhook.py b/hermes_cli/webhook.py index 9b9de6cd5a6c..79c2184837d3 100644 --- a/hermes_cli/webhook.py +++ b/hermes_cli/webhook.py @@ -6,7 +6,7 @@ hermes webhook remove hermes webhook test [--payload '{"key": "value"}'] -Subscriptions persist to ~/.hermes/webhook_subscriptions.json and are +Subscriptions persist to the effective profile webhook route store and are hot-reloaded by the webhook adapter without a gateway restart. """ @@ -21,7 +21,13 @@ from hermes_constants import display_hermes_home from utils import atomic_replace -from hermes_cli.config import cfg_get + + +def _effective_webhook_config(): + """Return the unified runtime webhook configuration.""" + from gateway.webhook_config import resolve_effective_webhook_config + + return resolve_effective_webhook_config() _SUBSCRIPTIONS_FILENAME = "webhook_subscriptions.json" @@ -34,7 +40,10 @@ def _hermes_home() -> Path: def _subscriptions_path() -> Path: - return _hermes_home() / _SUBSCRIPTIONS_FILENAME + try: + return _effective_webhook_config().routes_path + except Exception: + return _hermes_home() / _SUBSCRIPTIONS_FILENAME def _load_subscriptions() -> Dict[str, dict]: @@ -51,10 +60,8 @@ def _load_subscriptions() -> Dict[str, dict]: def _save_subscriptions(subs: Dict[str, dict]) -> None: path = _subscriptions_path() 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 - # umask cannot leave the secrets readable to other local users in the - # window between create and rename. + # Reference-only routes should not normally contain plaintext secrets, but + # keep the route store private during incremental migration as well. fd, tmp_name = tempfile.mkstemp( prefix=f".{path.name}.", suffix=".tmp", @@ -69,8 +76,6 @@ def _save_subscriptions(subs: Dict[str, dict]) -> None: os.fsync(fh.fileno()) os.chmod(tmp_path, _SUBSCRIPTIONS_FILE_MODE) atomic_replace(tmp_path, path) - # Re-assert after rename in case the destination existed with a - # broader mode and atomic_replace preserved it. os.chmod(path, _SUBSCRIPTIONS_FILE_MODE) except Exception: try: @@ -80,18 +85,45 @@ def _save_subscriptions(subs: Dict[str, dict]) -> None: raise +def _store_route_secret(name: str, value: str) -> str: + """Store a route secret through the Task 8 canonical persistence seam.""" + from hermes_cli.migrations.webhook_secret_refs import store_webhook_secret + + ref = "WEBHOOK_ROUTE_" + re.sub(r"[^A-Za-z0-9_]", "_", name.upper()) + store_webhook_secret(ref, value) + return ref + + +def _resolve_route_secret(route: dict) -> str: + """Resolve a route through the same helper used by migration/runtime.""" + ref = route.get("secret_ref") + if not ref: + return str(route.get("secret", "") or "") + from hermes_cli.migrations.webhook_secret_refs import resolve_webhook_secret + + return str(resolve_webhook_secret(str(ref)) or "") + + def _get_webhook_config() -> dict: - """Load webhook platform config. Returns {} if not configured.""" + """Return the legacy dict shape backed by effective webhook config.""" try: - from hermes_cli.config import load_config - cfg = load_config() - return cfg_get(cfg, "platforms", "webhook", default={}) + effective = _effective_webhook_config() + return { + "enabled": effective.enabled, + "extra": { + "host": effective.host, + "port": effective.port, + }, + } except Exception: return {} def _is_webhook_enabled() -> bool: - return bool(_get_webhook_config().get("enabled")) + try: + return _effective_webhook_config().enabled + except Exception: + return bool(_get_webhook_config().get("enabled")) def _get_webhook_base_url() -> str: @@ -118,19 +150,15 @@ def _setup_hint() -> str: enabled: true extra: port: 8644 - secret: "your-global-hmac-secret" + secret_ref: WEBHOOK_SECRET - 3. Or set environment variables in {_dhh}/.env: - WEBHOOK_ENABLED=true - WEBHOOK_PORT=8644 - WEBHOOK_SECRET=your-global-secret + 3. Or configure the profile secret backend with WEBHOOK_SECRET. Then start the gateway: hermes gateway run """ def _require_webhook_enabled() -> bool: - """Check webhook is enabled. Print setup guide and return False if not.""" if _is_webhook_enabled(): return True print(_setup_hint()) @@ -142,10 +170,16 @@ def webhook_command(args): sub = getattr(args, "webhook_action", None) if not sub: - print("Usage: hermes webhook {subscribe|list|remove|test}") + print("Usage: hermes webhook {subscribe|list|remove|test|migrate-secrets}") print("Run 'hermes webhook --help' for details.") return + # Migration must remain available when a broken legacy route prevents the + # runtime platform from becoming enabled. + if sub == "migrate-secrets": + _cmd_migrate_secrets(args) + return + if not _require_webhook_enabled(): return @@ -167,19 +201,40 @@ def _cmd_subscribe(args): subs = _load_subscriptions() is_update = name in subs - - secret = args.secret or secrets.token_urlsafe(32) + existing_route = subs.get(name) if is_update else None + supplied_secret = bool(args.secret) + secret = args.secret or ("" if is_update else secrets.token_urlsafe(32)) events = [e.strip() for e in args.events.split(",")] if args.events else [] + secret_ref = None + if is_update and not supplied_secret and isinstance(existing_route, dict): + secret_ref = existing_route.get("secret_ref") + if not secret_ref: + secret = str(existing_route.get("secret", "") or "") + if not secret: + # A previously malformed/no-secret route must never be saved + # back into an unusable state. Mint a fresh credential and + # surface it once just like a new subscription. + secret = secrets.token_urlsafe(32) + supplied_secret = True + secret_ref = _store_route_secret(name, secret) + else: + secret_ref = _store_route_secret(name, secret) + 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", "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), } + if secret_ref: + route["secret_ref"] = secret_ref + else: + # Fail closed rather than persisting a route that will die at startup. + print("Error: webhook secret persistence did not return a reference") + return if getattr(args, "deliver_only", False): if route["deliver"] == "log": @@ -205,7 +260,10 @@ def _cmd_subscribe(args): print(f"\n {status} webhook subscription: {name}") print(f" URL: {base_url}/webhooks/{name}") - print(f" Secret: {secret}") + if not is_update or supplied_secret: + print(f" Secret: {secret}") + else: + print(" Secret: (unchanged; not displayed)") if events: print(f" Events: {', '.join(events)}") else: @@ -274,10 +332,12 @@ def _cmd_test(args): return route = subs[name] - secret = route.get("secret", "") + secret = _resolve_route_secret(route) + if not secret: + print(" Error: webhook secret reference could not be resolved") + return base_url = _get_webhook_base_url() url = f"{base_url}/webhooks/{name}" - payload = args.payload or '{"test": true, "event_type": "test", "message": "Hello from hermes webhook test"}' import hmac @@ -305,3 +365,37 @@ def _cmd_test(args): except Exception as e: print(f" Error: {e}") print(" Is the gateway running? (hermes gateway run)") + + +def _cmd_migrate_secrets(args): + """Migrate legacy webhook secrets, returning value-free receipts.""" + from hermes_cli.migrations.webhook_secret_refs import ( + migrate_webhook_config, + migrate_webhook_routes, + ) + + route_path = _subscriptions_path() + route_result = { + "migrated_routes": [], + "receipts": [], + "scrubbed_backups": [], + } + if route_path.exists(): + backups = tuple(route_path.parent.glob(route_path.name + ".bak*")) + route_result = migrate_webhook_routes(route_path, backup_paths=backups) + + config_path = _hermes_home() / "config.yaml" + config_result = {"migrated": False, "receipts": []} + if config_path.exists(): + config_result = migrate_webhook_config(config_path) + + result = {"routes": route_result, "config": config_result} + if getattr(args, "json", False): + print(json.dumps(result, indent=2, sort_keys=True)) + else: + print( + "Webhook secret migration complete: " + f"{len(route_result.get('migrated_routes', []))} route(s), " + f"config={'migrated' if config_result.get('migrated') else 'unchanged'}." + ) + return result diff --git a/hermes_cli/webhook_secrets.py b/hermes_cli/webhook_secrets.py new file mode 100644 index 000000000000..f07e37d66a1a --- /dev/null +++ b/hermes_cli/webhook_secrets.py @@ -0,0 +1,87 @@ +"""Canonical webhook secret-reference persistence and resolution. + +Resolution delegates to the gateway's production authority instead of +reimplementing its fallback chain. All webhook secret writers share one bounded +cross-process lock so CLI updates and runtime migration cannot race each other. +""" +from __future__ import annotations + +import os +import time +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + +_LOCK_TIMEOUT_SECONDS = 10.0 +_LOCK_STALE_SECONDS = 60.0 + + +def resolve_webhook_secret(secret_ref: object) -> str: + """Resolve through the gateway's canonical Task 8 secret authority.""" + if not isinstance(secret_ref, str) or not secret_ref.strip(): + return "" + from gateway.platforms.webhook import WebhookAdapter + + return WebhookAdapter._resolve_secret_ref(secret_ref.strip()) + + +@contextmanager +def webhook_secret_write_lock() -> Iterator[None]: + """Serialize webhook secret writers across CLI/gateway processes.""" + from hermes_constants import get_hermes_home + + home = Path(get_hermes_home()) + home.mkdir(parents=True, exist_ok=True) + lock_path = home / ".webhook-secrets.lock" + deadline = time.monotonic() + _LOCK_TIMEOUT_SECONDS + fd: int | None = None + while fd is None: + try: + fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + os.write(fd, f"{os.getpid()}\n".encode()) + os.fsync(fd) + except FileExistsError: + try: + if time.time() - lock_path.stat().st_mtime > _LOCK_STALE_SECONDS: + lock_path.unlink(missing_ok=True) + continue + except OSError: + pass + if time.monotonic() >= deadline: + raise TimeoutError("Timed out waiting for webhook secret writer lock") + time.sleep(0.05) + try: + yield + finally: + try: + os.close(fd) + finally: + try: + lock_path.unlink(missing_ok=True) + except OSError: + pass + + +def store_webhook_secret_unlocked(secret_ref: str, value: str) -> None: + """Persist while the caller already owns :func:`webhook_secret_write_lock`.""" + if not isinstance(secret_ref, str) or not secret_ref.strip(): + raise ValueError("webhook secret reference must be non-empty") + if not isinstance(value, str) or not value: + raise ValueError("webhook secret value must be non-empty") + from hermes_cli.config import save_env_value + + save_env_value(secret_ref.strip(), value) + + +def store_webhook_secret(secret_ref: str, value: str) -> None: + """Persist one webhook secret through the profile .env owner.""" + with webhook_secret_write_lock(): + store_webhook_secret_unlocked(secret_ref, value) + + +__all__ = [ + "resolve_webhook_secret", + "store_webhook_secret", + "store_webhook_secret_unlocked", + "webhook_secret_write_lock", +] diff --git a/tests/gateway/test_webhook_effective_config.py b/tests/gateway/test_webhook_effective_config.py new file mode 100644 index 000000000000..ca4cbbca74e1 --- /dev/null +++ b/tests/gateway/test_webhook_effective_config.py @@ -0,0 +1,107 @@ +"""Table-driven coverage for effective webhook configuration resolution.""" + +from pathlib import Path + +import pytest + +from gateway.webhook_config import resolve_effective_webhook_config + + +@pytest.fixture +def isolated_profiles(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + (tmp_path / "home").mkdir() + monkeypatch.delenv("WEBHOOK_ENABLED", raising=False) + monkeypatch.delenv("WEBHOOK_HOST", raising=False) + monkeypatch.delenv("WEBHOOK_PORT", raising=False) + monkeypatch.delenv("WEBHOOK_SECRET", raising=False) + return tmp_path + + +def _write_yaml(home: Path, body: str) -> None: + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text(body, encoding="utf-8") + + +@pytest.mark.parametrize( + ("case", "yaml_body", "env", "expected", "sources"), + [ + ( + "default", + "", + {}, + {"enabled": False, "host": None, "port": 8644, "secret_ref": None}, + {"enabled": "default", "host": "default", "port": "default", "global_secret_ref": "default"}, + ), + ( + "yaml-only", + "platforms:\n webhook:\n enabled: true\n extra:\n host: 127.0.0.1\n port: 9123\n secret: yaml-secret\n", + {}, + {"enabled": True, "host": "127.0.0.1", "port": 9123, "secret_ref": "WEBHOOK_SECRET"}, + {"enabled": "yaml", "host": "yaml", "port": "yaml", "global_secret_ref": "yaml"}, + ), + ( + "env-only", + "", + {"WEBHOOK_ENABLED": "true", "WEBHOOK_HOST": "env.example", "WEBHOOK_PORT": "9234", "WEBHOOK_SECRET": "env-secret"}, + {"enabled": True, "host": "env.example", "port": 9234, "secret_ref": "WEBHOOK_SECRET"}, + {"enabled": "env", "host": "env", "port": "env", "global_secret_ref": "env"}, + ), + ( + "env-over-yaml", + "platforms:\n webhook:\n enabled: false\n extra:\n host: yaml.example\n port: 9123\n secret: yaml-secret\n", + {"WEBHOOK_ENABLED": "true", "WEBHOOK_HOST": "env.example", "WEBHOOK_PORT": "9234", "WEBHOOK_SECRET": "env-secret"}, + {"enabled": True, "host": "env.example", "port": 9234, "secret_ref": "WEBHOOK_SECRET"}, + {"enabled": "env", "host": "env", "port": "env", "global_secret_ref": "env"}, + ), + ], +) +def test_effective_webhook_config_precedence( + isolated_profiles, case, yaml_body, env, expected, sources, monkeypatch +): + home = isolated_profiles / "home" + _write_yaml(home, yaml_body) + for key, value in env.items(): + monkeypatch.setenv(key, value) + + config = resolve_effective_webhook_config() + + assert { + "enabled": config.enabled, + "host": config.host, + "port": config.port, + "secret_ref": config.global_secret_ref, + } == expected, case + assert {key: config.source_map[key] for key in sources} == sources + assert config.profile == "default" + assert config.routes_path == home / "webhook_subscriptions.json" + + +def test_named_profile_uses_its_own_yaml_and_profile_environment(isolated_profiles, monkeypatch): + root = isolated_profiles + default_home = root / "home" + profile_home = default_home / "profiles" / "worker" + _write_yaml( + default_home, + "platforms:\n webhook:\n enabled: true\n extra:\n port: 8001\n", + ) + _write_yaml( + profile_home, + "platforms:\n webhook:\n enabled: false\n extra:\n host: worker.example\n port: 8002\n", + ) + (profile_home / ".env").write_text( + "WEBHOOK_ENABLED=true\nWEBHOOK_HOST=profile.example\nWEBHOOK_PORT=8003\n", + encoding="utf-8", + ) + monkeypatch.setenv("WEBHOOK_HOST", "process.example") + monkeypatch.setenv("WEBHOOK_PORT", "8999") + + config = resolve_effective_webhook_config("worker") + + assert config.enabled is True + assert config.host == "profile.example" + assert config.port == 8003 + assert config.source_map["enabled"] == "profile" + assert config.source_map["host"] == "profile" + assert config.source_map["port"] == "profile" + assert config.routes_path == profile_home / "webhook_subscriptions.json" diff --git a/tests/gateway/test_webhook_profile_admission_multiplex_allowlist.py b/tests/gateway/test_webhook_profile_admission_multiplex_allowlist.py new file mode 100644 index 000000000000..fedcd8891d17 --- /dev/null +++ b/tests/gateway/test_webhook_profile_admission_multiplex_allowlist.py @@ -0,0 +1,34 @@ +"""Regression coverage for webhook multiplex profile allowlist propagation.""" + +from types import SimpleNamespace + +from gateway.platforms.webhook import WebhookAdapter + + +class _Request: + match_info = {"profile": "worker"} + + +def test_profile_admission_passes_configured_multiplex_allowlist(monkeypatch): + """Profile admission must use the same selective set as gateway startup.""" + calls = [] + + def fake_profiles_to_serve(*, multiplex, profile_allowlist=None): + calls.append((multiplex, profile_allowlist)) + return [("default", "/profiles/default"), ("worker", "/profiles/worker")] + + monkeypatch.setattr( + "hermes_cli.profiles.profiles_to_serve", + fake_profiles_to_serve, + ) + + adapter = WebhookAdapter.__new__(WebhookAdapter) + adapter.gateway_runner = SimpleNamespace( + config=SimpleNamespace( + multiplex_profiles=True, + multiplex_profile_allowlist=["worker"], + ) + ) + + assert adapter._resolve_request_profile(_Request()) == "worker" + assert calls == [(True, ["worker"])] diff --git a/tests/gateway/test_webhook_profile_admission_seam.py b/tests/gateway/test_webhook_profile_admission_seam.py new file mode 100644 index 000000000000..1026ca4a1228 --- /dev/null +++ b/tests/gateway/test_webhook_profile_admission_seam.py @@ -0,0 +1,36 @@ +"""Seam tests for the webhook profile-admission mixin extraction.""" + +import typing + +from aiohttp import web + +from gateway.platforms.base import BasePlatformAdapter +from gateway.platforms.webhook import WebhookAdapter, _PROFILE_REJECTED as legacy_rejected +from gateway.platforms.webhook_profile_admission import ( + WebhookProfileAdmissionMixin, + _PROFILE_REJECTED as admission_rejected, +) + + +def test_webhook_composes_profile_admission_mixin_without_wrappers(): + assert WebhookAdapter.__mro__[:3] == ( + WebhookAdapter, + WebhookProfileAdmissionMixin, + BasePlatformAdapter, + ) + assert ( + WebhookAdapter._resolve_request_profile + is WebhookProfileAdmissionMixin._resolve_request_profile + ) + assert ( + WebhookAdapter._route_allows_profile + is WebhookProfileAdmissionMixin._route_allows_profile + ) + + +def test_profile_admission_request_annotation_resolves_at_runtime(): + assert typing.get_type_hints(WebhookAdapter._resolve_request_profile)["request"] is web.Request + + +def test_legacy_profile_rejection_sentinel_is_the_canonical_object(): + assert legacy_rejected is admission_rejected diff --git a/tests/hermes_cli/test_gateway_setup.py b/tests/hermes_cli/test_gateway_setup.py new file mode 100644 index 000000000000..0d12ce50496f --- /dev/null +++ b/tests/hermes_cli/test_gateway_setup.py @@ -0,0 +1,10 @@ +"""Gateway setup menu regression coverage.""" + +from hermes_cli import gateway as gateway_cli +from hermes_cli import setup as setup_mod + + +def test_webhooks_are_listed_and_dispatch_to_setup(): + platform = next(item for item in gateway_cli._all_platforms() if item["key"] == "webhook") + assert gateway_cli._builtin_setup_fn(platform["key"]) is setup_mod._setup_webhooks + assert gateway_cli._builtin_setup_fn("webhooks") is setup_mod._setup_webhooks diff --git a/tests/hermes_cli/test_webhook_cli.py b/tests/hermes_cli/test_webhook_cli.py index 4fecf7f279c7..9023a3bfa57d 100644 --- a/tests/hermes_cli/test_webhook_cli.py +++ b/tests/hermes_cli/test_webhook_cli.py @@ -10,6 +10,7 @@ webhook_command, _get_webhook_base_url, _load_subscriptions, + _resolve_route_secret, _save_subscriptions, _subscriptions_path, ) @@ -18,10 +19,9 @@ @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 - ) + # Default: webhooks enabled through the same runtime env path used by + # production. Individual tests can override the effective resolver. + monkeypatch.setenv("WEBHOOK_ENABLED", "true") def _make_args(**kwargs): @@ -58,12 +58,20 @@ def test_custom_secret(self): webhook_command(_make_args( webhook_action="subscribe", name="s", secret="my-secret" )) - assert _load_subscriptions()["s"]["secret"] == "my-secret" + route = _load_subscriptions()["s"] + # Secrets are persisted by reference; the plaintext lives in the + # profile resolver, not the route JSON. + assert "secret" not in route + assert "secret_ref" in route + assert _resolve_route_secret(route) == "my-secret" def test_auto_secret(self): webhook_command(_make_args(webhook_action="subscribe", name="s")) - secret = _load_subscriptions()["s"]["secret"] + route = _load_subscriptions()["s"] + assert "secret" not in route + assert "secret_ref" in route + secret = _resolve_route_secret(route) assert len(secret) > 20 @@ -135,7 +143,6 @@ def test_blocks_list_when_disabled(self, capsys, monkeypatch): 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 @@ -153,3 +160,11 @@ def test_real_check_disabled(self, monkeypatch): import hermes_cli.webhook as wh_mod assert wh_mod._is_webhook_enabled() is False + def test_env_only_runtime_config_enables_list(self, capsys, monkeypatch): + monkeypatch.delenv("WEBHOOK_ENABLED", raising=False) + monkeypatch.setenv("WEBHOOK_ENABLED", "true") + webhook_command(_make_args(webhook_action="list")) + out = capsys.readouterr().out + assert "not enabled" not in out.lower() + assert "No dynamic webhook subscriptions" in out + diff --git a/tests/hermes_cli/test_webhook_secret_migration.py b/tests/hermes_cli/test_webhook_secret_migration.py new file mode 100644 index 000000000000..7d1242a172dc --- /dev/null +++ b/tests/hermes_cli/test_webhook_secret_migration.py @@ -0,0 +1,52 @@ +"""Webhook secret migration contract tests.""" +import json + +import pytest + +from hermes_cli.migrations.webhook_secret_refs import ( + WebhookSecretMigrationError, + migrate_webhook_routes, +) + + +def test_failure_before_secure_persistence_leaves_source_untouched(tmp_path): + source = tmp_path / "routes.json" + original = json.dumps({"alerts": {"secret": "sentinel-route-secret", "prompt": "ok"}}, indent=2) + source.write_text(original, encoding="utf-8") + + def fail_store(_ref, _value): + raise OSError("backend unavailable") + + with pytest.raises(WebhookSecretMigrationError, match="source left untouched"): + migrate_webhook_routes(source, store=fail_store) + assert source.read_text(encoding="utf-8") == original + + +def test_success_verifies_then_switches_and_scrubs_backup(tmp_path): + source = tmp_path / "routes.json" + backup = tmp_path / "routes.json.bak" + payload = {"alerts": {"secret": "sentinel-route-secret", "prompt": "ok"}} + source.write_text(json.dumps(payload), encoding="utf-8") + backup.write_text(json.dumps(payload), encoding="utf-8") + stored = {} + + def store(ref, value): + stored[ref] = value + + result = migrate_webhook_routes( + source, + store=store, + resolve=lambda ref: stored.get(ref), + backup_paths=(backup,), + ) + + migrated = json.loads(source.read_text(encoding="utf-8")) + scrubbed = json.loads(backup.read_text(encoding="utf-8")) + assert migrated["alerts"]["secret_ref"] == "WEBHOOK_ROUTE_ALERTS" + assert "secret" not in migrated["alerts"] + assert "sentinel-route-secret" not in source.read_text(encoding="utf-8") + assert "secret" not in scrubbed["alerts"] + assert "sentinel-route-secret" not in backup.read_text(encoding="utf-8") + assert result["receipts"][0]["stored"] is True + assert result["receipts"][0]["verified"] is True + assert result["rollback"]["source_preserved_on_pre_switch_failure"] is True diff --git a/tests/hermes_cli/test_webhook_setup.py b/tests/hermes_cli/test_webhook_setup.py new file mode 100644 index 000000000000..31bc241981d6 --- /dev/null +++ b/tests/hermes_cli/test_webhook_setup.py @@ -0,0 +1,35 @@ +"""Focused tests for the webhook setup flow and gateway menu wiring.""" + +from hermes_cli import gateway as gateway_cli +from hermes_cli import setup as setup_mod + + +def test_setup_webhooks_reports_effective_host_and_port(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + for name in ("WEBHOOK_ENABLED", "WEBHOOK_HOST", "WEBHOOK_PORT", "WEBHOOK_SECRET"): + monkeypatch.delenv(name, raising=False) + + def answer(question, *args, **kwargs): + if question.startswith("Webhook bind host"): + return "hooks.example" + if question.startswith("Webhook port"): + return "9777" + if question.startswith("Global HMAC secret"): + return "test-secret" + raise AssertionError(f"unexpected prompt: {question}") + + monkeypatch.setattr(setup_mod, "prompt", answer) + monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False) + + setup_mod._setup_webhooks() + + output = capsys.readouterr().out + assert "hooks.example:9777/webhooks/" in output + assert output.count("Open config in your editor") == 1 + + +def test_webhooks_menu_entry_dispatches_to_setup(monkeypatch): + platforms = gateway_cli._all_platforms() + entry = next(platform for platform in platforms if platform["key"] == "webhook") + assert gateway_cli._builtin_setup_fn(entry["key"]) is setup_mod._setup_webhooks + assert gateway_cli._builtin_setup_fn("webhook") is setup_mod._setup_webhooks diff --git a/tests/hermes_cli/test_webhook_task8_current.py b/tests/hermes_cli/test_webhook_task8_current.py new file mode 100644 index 000000000000..9955e5169d3d --- /dev/null +++ b/tests/hermes_cli/test_webhook_task8_current.py @@ -0,0 +1,79 @@ +"""Current-train regression contracts for webhook Task 8.""" +from __future__ import annotations + +import inspect +import threading +import time +from types import SimpleNamespace + +from hermes_cli import webhook as webhook_cli +from hermes_cli import webhook_secrets + + +def _args(**overrides): + values = { + "name": "alerts", + "secret": "", + "events": "", + "description": "", + "prompt": "", + "skills": "", + "deliver": "log", + "deliver_only": False, + "script": "", + "deliver_chat_id": "", + } + values.update(overrides) + return SimpleNamespace(**values) + + +def test_update_of_legacy_no_secret_route_mints_reference(monkeypatch): + saved = {} + stored = [] + monkeypatch.setattr(webhook_cli, "_load_subscriptions", lambda: {"alerts": {"prompt": "old"}}) + monkeypatch.setattr(webhook_cli, "_save_subscriptions", lambda value: saved.update(value)) + monkeypatch.setattr(webhook_cli, "_store_route_secret", lambda name, value: stored.append((name, value)) or "WEBHOOK_ROUTE_ALERTS") + monkeypatch.setattr(webhook_cli, "_get_webhook_base_url", lambda: "http://localhost:8644") + + webhook_cli._cmd_subscribe(_args()) + + route = saved["alerts"] + assert route["secret_ref"] == "WEBHOOK_ROUTE_ALERTS" + assert "secret" not in route + assert stored and stored[0][0] == "alerts" + assert stored[0][1] + + +def test_campaign_watermark_is_not_shipped(): + assert "WEBHOOK_REVOLUTION_TASK8_MIGRATION_COMMAND_V1" not in inspect.getsource(webhook_cli) + + +def test_secret_writers_are_serialized(monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + active = 0 + maximum = 0 + guard = threading.Lock() + + def fake_save(_key, _value): + nonlocal active, maximum + with guard: + active += 1 + maximum = max(maximum, active) + time.sleep(0.05) + with guard: + active -= 1 + + monkeypatch.setattr("hermes_cli.config.save_env_value", fake_save) + threads = [ + threading.Thread( + target=webhook_secrets.store_webhook_secret, + args=(f"WEBHOOK_ROUTE_{index}", f"secret-{index}"), + ) + for index in range(4) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=2) + assert not thread.is_alive() + assert maximum == 1 diff --git a/tests/security/test_webhook_secret_egress.py b/tests/security/test_webhook_secret_egress.py new file mode 100644 index 000000000000..ebde51030bbd --- /dev/null +++ b/tests/security/test_webhook_secret_egress.py @@ -0,0 +1,46 @@ +"""End-to-end secret egress gates for Webhook Revolution Task 8.""" + +import json + +from hermes_cli.migrations.webhook_secret_refs import migrate_webhook_routes + + +SENTINEL = "WR_SENTINEL_WEBHOOK_SECRET_7f39d8" + + +def test_real_sentinel_is_removed_from_routes_backups_and_receipts(tmp_path): + source = tmp_path / "webhook_subscriptions.json" + backup = tmp_path / "webhook_subscriptions.json.bak" + value = {"alerts": {"secret": SENTINEL, "prompt": "ok"}} + source.write_text(json.dumps(value), encoding="utf-8") + backup.write_text(json.dumps(value), encoding="utf-8") + secret_backend = {} + + result = migrate_webhook_routes( + source, + store=lambda ref, secret: secret_backend.__setitem__(ref, secret), + resolve=secret_backend.get, + backup_paths=(backup,), + ) + + assert secret_backend["WEBHOOK_ROUTE_ALERTS"] == SENTINEL + assert SENTINEL not in source.read_text(encoding="utf-8") + assert SENTINEL not in backup.read_text(encoding="utf-8") + assert SENTINEL not in json.dumps(result) + assert json.loads(source.read_text())["alerts"]["secret_ref"] == "WEBHOOK_ROUTE_ALERTS" + + +def test_pre_switch_failure_preserves_exact_plaintext_source_for_retry(tmp_path): + source = tmp_path / "webhook_subscriptions.json" + original = json.dumps({"alerts": {"secret": SENTINEL}}, indent=2) + source.write_text(original, encoding="utf-8") + + try: + migrate_webhook_routes( + source, + store=lambda _ref, _value: (_ for _ in ()).throw(OSError("offline")), + ) + except Exception: + pass + + assert source.read_text(encoding="utf-8") == original diff --git a/tests/test_web_server_webhooks_seam.py b/tests/test_web_server_webhooks_seam.py new file mode 100644 index 000000000000..c7549da244a1 --- /dev/null +++ b/tests/test_web_server_webhooks_seam.py @@ -0,0 +1,73 @@ +"""Focused seam contract for the extracted webhook dashboard router.""" + + +def test_webhook_router_preserves_routes_and_web_server_compatibility(monkeypatch): + import asyncio + + import hermes_cli.web_server as web_server + from hermes_cli.web_routers import webhooks + + route_methods = {(route.path, tuple(sorted(route.methods))) for route in webhooks.router.routes} + assert route_methods == { + ("/api/webhooks", ("GET",)), + ("/api/webhooks", ("POST",)), + ("/api/webhooks/enable", ("POST",)), + ("/api/webhooks/{name}", ("DELETE",)), + ("/api/webhooks/{name}/enabled", ("PUT",)), + } + app_route_methods = { + (route.path, tuple(sorted(route.methods))) + for route in web_server.app.routes + if hasattr(route, "methods") + } + assert route_methods <= app_route_methods + + for name in ( + "list_webhooks", + "enable_webhooks", + "create_webhook", + "delete_webhook", + "set_webhook_enabled", + "_webhook_route_summary", + ): + assert getattr(web_server, name) is getattr(webhooks, name) + + calls = [] + monkeypatch.setattr(web_server, "_write_platform_enabled", lambda *args: calls.append(("write", args))) + monkeypatch.setattr( + web_server, + "_restart_gateway_after_webhook_enable", + lambda: {"restart_started": True, "restart_action": "gateway-restart", "restart_pid": 7}, + ) + + result = asyncio.run(webhooks.enable_webhooks()) + + assert calls == [("write", ("webhook", True))] + assert result["restart_started"] is True + assert result["restart_pid"] == 7 + + +def test_webhook_list_route_uses_web_server_summary_seam(monkeypatch): + import asyncio + + import hermes_cli.web_server as web_server + import hermes_cli.webhook as webhook + + monkeypatch.setattr(webhook, "_get_webhook_base_url", lambda: "https://hooks.test") + monkeypatch.setattr( + webhook, + "_load_subscriptions", + lambda: {"build": {"description": "Build events"}}, + ) + monkeypatch.setattr(webhook, "_is_webhook_enabled", lambda: True) + + patched_summary = {"name": "patched-by-web-server"} + monkeypatch.setattr( + web_server, + "_webhook_route_summary", + lambda *args: patched_summary, + ) + + result = asyncio.run(web_server.list_webhooks()) + + assert result["subscriptions"] == [patched_summary]