From 74db4bfe684de68740653f1515f97789c0df58e6 Mon Sep 17 00:00:00 2001 From: James Huang Date: Thu, 2 Jul 2026 21:57:44 +0200 Subject: [PATCH] fix(gateway): bridge top-level port/host into extra for webhook and api_server WebhookAdapter and ApiServerAdapter read port/host from config.extra, but PlatformConfig.from_dict only populates extra from the 'extra:' sub-key in the YAML platform section. Top-level keys like port and host are silently ignored, causing the adapter to fall back to DEFAULT_PORT (8644). This causes silent port conflicts in multi-profile setups: a profile that configures 'platforms.webhook.port: 8649' still binds 8644, colliding with the default profile's webhook on the same port. Fix: extend the shared-key bridging loop in load_gateway_config() to bridge top-level port/host/secret into extra for WEBHOOK, MSGRAPH_WEBHOOK, and API_SERVER platforms, following the same pattern already used for dm_policy, allow_from, gateway_restart_notification, and other keys. The extra dict takes precedence: if port is already under 'extra:', the top-level value does not clobber it. --- gateway/config.py | 18 ++++++ tests/gateway/test_config.py | 108 +++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/gateway/config.py b/gateway/config.py index b43485fefeb9..07e57e255420 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -1497,6 +1497,24 @@ def load_gateway_config() -> GatewayConfig: bridged["typing_indicator"] = platform_cfg["typing_indicator"] if "typing_status_text" in platform_cfg: bridged["typing_status_text"] = platform_cfg["typing_status_text"] + # Bridge top-level port/host/secret into extra for platforms + # whose adapters read these from config.extra (webhook, + # msgraph_webhook, api_server). Without this, YAML like: + # platforms: + # webhook: + # enabled: true + # port: 8649 + # silently falls back to the hardcoded DEFAULT_PORT because + # PlatformConfig.from_dict only extracts ``extra`` from the + # ``extra:`` sub-key, not from the top level. + if plat in {Platform.WEBHOOK, Platform.MSGRAPH_WEBHOOK}: + for _bridge_key in ("port", "host", "secret"): + if _bridge_key in platform_cfg and _bridge_key not in platform_cfg.get("extra", {}): + bridged[_bridge_key] = platform_cfg[_bridge_key] + if plat == Platform.API_SERVER: + for _bridge_key in ("port", "host"): + if _bridge_key in platform_cfg and _bridge_key not in platform_cfg.get("extra", {}): + bridged[_bridge_key] = platform_cfg[_bridge_key] has_channel_overrides = "channel_overrides" in platform_cfg if has_channel_overrides: raw_overrides = platform_cfg.get("channel_overrides") diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 0b272ab74eb8..2a3db0f1863f 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -1855,6 +1855,114 @@ class TestLoadGatewayConfig: assert Platform.API_SERVER not in config.platforms +class TestWebhookPortBridging: + """Top-level port/host in the YAML platform section must be bridged into + PlatformConfig.extra so the webhook/api_server adapters can read them. + + The adapters (WebhookAdapter, ApiServerAdapter) read port/host from + ``config.extra``, but users naturally write them at the top level of the + platform section in config.yaml: + + platforms: + webhook: + enabled: true + host: 0.0.0.0 + port: 8649 + + Without bridging, the port silently falls back to DEFAULT_PORT (8644), + causing port conflicts between profiles that configure different ports.""" + + def test_webhook_port_bridged_from_toplevel(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "platforms:\n" + " webhook:\n" + " enabled: true\n" + " host: 0.0.0.0\n" + " port: 8649\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("WEBHOOK_ENABLED", raising=False) + monkeypatch.delenv("WEBHOOK_PORT", raising=False) + + config = load_gateway_config() + + assert Platform.WEBHOOK in config.platforms + wh = config.platforms[Platform.WEBHOOK] + assert wh.enabled is True + assert wh.extra.get("port") == 8649 + assert wh.extra.get("host") == "0.0.0.0" + + def test_webhook_port_in_extra_not_overwritten_by_toplevel(self, tmp_path, monkeypatch): + """If port is already under extra, the top-level value must not clobber it.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "platforms:\n" + " webhook:\n" + " enabled: true\n" + " extra:\n" + " port: 8650\n" + " port: 8649\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("WEBHOOK_ENABLED", raising=False) + monkeypatch.delenv("WEBHOOK_PORT", raising=False) + + config = load_gateway_config() + + wh = config.platforms[Platform.WEBHOOK] + assert wh.extra.get("port") == 8650 + + def test_api_server_port_bridged_from_toplevel(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "platforms:\n" + " api_server:\n" + " enabled: true\n" + " host: 127.0.0.1\n" + " port: 8648\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("API_SERVER_ENABLED", raising=False) + monkeypatch.delenv("API_SERVER_PORT", raising=False) + + config = load_gateway_config() + + assert Platform.API_SERVER in config.platforms + api = config.platforms[Platform.API_SERVER] + assert api.extra.get("port") == 8648 + assert api.extra.get("host") == "127.0.0.1" + + def test_webhook_port_defaults_when_not_configured(self, tmp_path, monkeypatch): + """No port anywhere -> adapter uses its hardcoded DEFAULT_PORT.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "platforms:\n" + " webhook:\n" + " enabled: true\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("WEBHOOK_ENABLED", raising=False) + monkeypatch.delenv("WEBHOOK_PORT", raising=False) + + config = load_gateway_config() + + wh = config.platforms[Platform.WEBHOOK] + assert "port" not in wh.extra or wh.extra.get("port") is None + + class TestHomeChannelEnvOverrides: """Home channel env vars should apply even when the platform was already configured via config.yaml (not just when credential env vars create it)."""