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"