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.
This commit is contained in:
James Huang 2026-07-02 21:57:44 +02:00 • committed by Teknium
parent 6b1b2e6f0e
commit 74db4bfe68
2 changed files with 126 additions and 0 deletions

View file

@ -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")

View file

@ -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)."""