fix(relay): legacy gateway.json opt-out is advisory, like every platform

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.
This commit is contained in:
Ben Barclay
2026-09-11 10:27:14 +10:00
parent 5746b5bae6
commit 337d69856f
4 changed files with 22 additions and 52 deletions
-5
View File
@@ -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:
+13 -24
View File
@@ -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]:
+2 -4
View File
@@ -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
+7 -19
View File
@@ -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"