From 337d69856f4fa5043fdfab035f2c923a6240da60 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Fri, 11 Sep 2026 10:27:14 +1000 Subject: [PATCH] fix(relay): legacy gateway.json opt-out is advisory, like every platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loader stamps _enabled_explicit only from YAML blocks; a gateway.json `enabled: false` is re-enabled by env presence for slack, telegram and everyone else. Tagging the marker for platforms.relay alone in the legacy reader made relay the one platform whose gateway.json verdict beat the env — a per-platform pin nobody would find later. If legacy opt-outs should become authoritative, that is one rule for all platforms in its own PR. relay_explicitly_disabled() now reads the marker the loader itself sets instead of re-deriving the verdict from 'enabled' in block, so the standalone predicate and load_gateway_config() cannot disagree. --- gateway/config_loader.py | 5 --- gateway/relay/__init__.py | 37 +++++++------------- tests/gateway/relay/test_cold_opt_out.py | 6 ++-- tests/gateway/relay/test_explicit_disable.py | 26 ++++---------- 4 files changed, 22 insertions(+), 52 deletions(-) diff --git a/gateway/config_loader.py b/gateway/config_loader.py index a985c841b1..052d3f2fa7 100644 --- a/gateway/config_loader.py +++ b/gateway/config_loader.py @@ -27,11 +27,6 @@ def load_legacy_gateway_json(home: Path) -> Any: try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) or {} - # Relay accepts a legacy opt-out as authoritative, just like YAML. - platforms = data.get("platforms") if isinstance(data, dict) else None - relay = platforms.get("relay") if isinstance(platforms, dict) else None - if isinstance(relay, dict) and "enabled" in relay: - _dict_slot(relay, "extra")["_enabled_explicit"] = True logger.info("Loaded legacy %s — consider moving settings to config.yaml", path) return data except Exception as e: diff --git a/gateway/relay/__init__.py b/gateway/relay/__init__.py index f0fbfa7c7d..148715a65a 100644 --- a/gateway/relay/__init__.py +++ b/gateway/relay/__init__.py @@ -71,38 +71,27 @@ def _env_or_cfg_url(env_var: str, cfg_key: str) -> Optional[str]: def relay_explicitly_disabled() -> bool: - """The profile's normalized opt-out, before any URL/credential activation. + """``platforms.relay.enabled: false`` in the profile's YAML (user or managed). - Reuse the gateway's platform merge and boolean normalization without running - its env/plugin side effects. Absence is not a disable: URL-only deployments - predate the platform flag. The raw loader includes managed configuration. + Same merge and boolean normalization as the gateway loader, minus its env/plugin + side effects, so a standalone scheduler can ask without bootstrapping the gateway. + Mirrors the loader's ``_enabled_explicit`` rule: only a YAML ``enabled`` key is + authoritative — a legacy ``gateway.json`` block is advisory for relay exactly as it + is for every other platform, and an absent key keeps URL-only activation. """ from gateway.config import Platform, PlatformConfig - from gateway.config_loader import ( - bridge_platform_shared_keys, load_legacy_gateway_json, merge_platform_sections, - ) - from hermes_constants import get_hermes_home + from gateway.config_loader import bridge_platform_shared_keys, merge_platform_sections from hermes_cli.config import read_raw_config from hermes_cli.managed_scope import apply_managed_overlay - # Do not import gateway.run here: standalone routing must not bootstrap - # runtime flags, config-to-env bridges or dotenv. The shared cached reader - # resolves the context-local home; the same managed overlay is used by the - # runtime loader, without introducing CLI defaults into platform precedence. cfg = apply_managed_overlay(read_raw_config()) - data = load_legacy_gateway_json(get_hermes_home()) - platforms = merge_platform_sections(cfg, cfg.get("gateway"), data) - gateway = cfg.get("gateway") or {} - bridge_platform_shared_keys( - cfg, gateway.get("platforms") if isinstance(gateway, dict) else None, - data, platforms, [Platform.RELAY], - ) + gateway = cfg.get("gateway") if isinstance(cfg.get("gateway"), dict) else {} + platforms = merge_platform_sections(cfg, gateway, {}) + bridge_platform_shared_keys(cfg, gateway.get("platforms"), {}, platforms, [Platform.RELAY]) block = platforms.get("relay") - return ( - isinstance(block, dict) - and "enabled" in block - and not PlatformConfig.from_dict(block).enabled - ) + if not isinstance(block, dict) or not block.get("extra", {}).get("_enabled_explicit"): + return False + return not PlatformConfig.from_dict(block).enabled def relay_url() -> Optional[str]: diff --git a/tests/gateway/relay/test_cold_opt_out.py b/tests/gateway/relay/test_cold_opt_out.py index b113739fa5..ac6705e5db 100644 --- a/tests/gateway/relay/test_cold_opt_out.py +++ b/tests/gateway/relay/test_cold_opt_out.py @@ -10,7 +10,7 @@ import pytest import yaml -@pytest.mark.parametrize("case", ["native", "url-only", "disabled", "managed", "managed-only", "scoped", "scoped-json"]) +@pytest.mark.parametrize("case", ["native", "url-only", "disabled", "managed", "managed-only", "scoped"]) def test_cold_fronted_platforms_is_read_only(tmp_path, case): home = tmp_path / "primary" home.mkdir() @@ -27,8 +27,6 @@ def test_cold_fronted_platforms_is_read_only(tmp_path, case): (managed / "config.yaml").write_text("platforms:\n relay:\n enabled: false\n") if case == "scoped": (scoped / "config.yaml").write_text("platforms:\n relay:\n enabled: false\n") - elif case == "scoped-json": - (scoped / "gateway.json").write_text(json.dumps({"platforms": {"relay": {"enabled": False}}})) for root in (home, scoped): (root / ".env").write_text("GATEWAY_RELAY_SECRET=dotenv-only-test-secret\n") # Allowlist only: never inherit live credentials, profile selectors, or @@ -45,7 +43,7 @@ def test_cold_fronted_platforms_is_read_only(tmp_path, case): code = textwrap.dedent(""" import json, os, sys from hermes_constants import set_hermes_home_override - if sys.argv[1].startswith("scoped"): + if sys.argv[1] == "scoped": set_hermes_home_override(sys.argv[2]) before = dict(os.environ) assert "gateway.run" not in sys.modules diff --git a/tests/gateway/relay/test_explicit_disable.py b/tests/gateway/relay/test_explicit_disable.py index b20f677770..de5c04485a 100644 --- a/tests/gateway/relay/test_explicit_disable.py +++ b/tests/gateway/relay/test_explicit_disable.py @@ -64,10 +64,7 @@ def configure(home, state, source, monkeypatch): monkeypatch.setenv("GATEWAY_RELAY_URL", URL) assert not (home / "config.yaml").exists() return - if source == "legacy-json": - (home / "gateway.json").write_text(json.dumps({"platforms": cfg.pop("platforms")})) - monkeypatch.setenv("GATEWAY_RELAY_URL", URL) - elif source == "env": + if source == "env": monkeypatch.setenv("GATEWAY_RELAY_URL", URL) elif source == "managed": managed = home / "managed" @@ -200,17 +197,9 @@ async def test_production_startup_respects_profile_opt_out(profile, monkeypatch, assert "relay-fronted" in error -@pytest.mark.asyncio -@pytest.mark.parametrize("pinned", [False, True], ids=["unprovisioned", "inherited-secret"]) -async def test_legacy_json_disable_preserves_startup_and_native_delivery(profile, monkeypatch, pinned): - await test_production_startup_respects_profile_opt_out( - profile, monkeypatch, False, "legacy-json", pinned, - ) - - @pytest.mark.parametrize("yaml_state", ["absent", "empty", "sibling", "enabled"]) @pytest.mark.parametrize("disabled", [False, "false"]) -def test_legacy_json_disable_agrees_with_normalized_config(profile, monkeypatch, yaml_state, disabled): +def test_legacy_json_disable_is_advisory_like_other_platforms(profile, monkeypatch, yaml_state, disabled): (profile / "gateway.json").write_text(json.dumps({"platforms": { "relay": {"enabled": disabled}, "slack": {"enabled": True, "token": "native-test-token"}, @@ -225,11 +214,12 @@ def test_legacy_json_disable_agrees_with_normalized_config(profile, monkeypatch, (profile / "config.yaml").write_text(yaml.safe_dump(cfg)) monkeypatch.setenv("GATEWAY_RELAY_URL", URL) monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "native-telegram-test-token") - # Other legacy platforms keep their existing credential-driven enablement. monkeypatch.setenv("GATEWAY_RELAY_ALLOW_DIRECT_PLATFORMS", "true") config = load_gateway_config() - assert relay.relay_explicitly_disabled() is (yaml_state != "enabled") - assert config.platforms[Platform.RELAY].enabled is (yaml_state == "enabled") + # A legacy gateway.json ``enabled`` is advisory for relay exactly as it is for telegram: + # env presence (the URL / the token) re-enables both. Only YAML sets ``_enabled_explicit``. + assert relay.relay_explicitly_disabled() is False + assert config.platforms[Platform.RELAY].enabled assert config.platforms[Platform.SLACK].enabled assert config.platforms[Platform.TELEGRAM].enabled @@ -289,7 +279,7 @@ def test_managed_layer_agrees_with_native_config(profile, monkeypatch, user_yaml @pytest.mark.asyncio @pytest.mark.parametrize("entry", ["url", "provision", "policy", "registration", "forced-registration", "fronted"]) -@pytest.mark.parametrize("spelling", ["top", "nested", "normalized", "legacy-json", "yaml-wins", "gateway-relay", "top-relay", "managed-disable", "sibling-keeps-disable"]) +@pytest.mark.parametrize("spelling", ["top", "nested", "normalized", "yaml-wins", "gateway-relay", "top-relay", "managed-disable", "sibling-keeps-disable"]) async def test_disabled_standalone_paths_have_no_relay_side_effects(profile, monkeypatch, entry, spelling): block = {"enabled": "false" if spelling == "normalized" else False} cfg = {"gateway": {"relay_url": URL}} @@ -301,8 +291,6 @@ async def test_disabled_standalone_paths_have_no_relay_side_effects(profile, mon elif spelling == "top-relay": cfg["relay"] = block cfg["gateway"]["relay"] = {"enabled": True} - elif spelling == "legacy-json": - (profile / "gateway.json").write_text(json.dumps({"platforms": {"relay": block}})) elif spelling == "managed-disable": cfg["platforms"] = {"relay": {"enabled": True}} managed = profile / "managed"