diff --git a/cron/scheduler_delivery.py b/cron/scheduler_delivery.py index 92f99b4c9e..d2589cebab 100644 --- a/cron/scheduler_delivery.py +++ b/cron/scheduler_delivery.py @@ -1133,15 +1133,28 @@ def _resolve_target_transport( """Resolve ``(transport, pconfig, runtime_adapter, target_adapters)`` for one target, or ``(None, error)`` when it cannot be served (relay-fronted with no live transport, or not configured/enabled).""" - from gateway.delivery import resolve_delivery_transport + from gateway.delivery import DeliveryTransport, resolve_delivery_transport target_adapters = adapters + transport = None if isinstance(adapters, _preflight.SharedRouteAdapters): # Credentialless satellite: the primary adapter serves THIS target only when an exact # primary route maps it to this profile; a miss fails closed below. # See #101113. shared = adapters.get(platform, target) target_adapters = {platform: shared} if shared is not None else {} - transport = resolve_delivery_transport(platform, config, target_adapters) + if shared is not None: + # The PRIMARY's route authorized this exact native adapter. The satellite's own + # ``platforms.

`` block describes a connector it never runs (no credential), so + # neither its absence nor ``enabled: false`` may veto the shared transport; only its + # non-credential settings (continuable surface, reply mode) are kept (#89302, #103701). + from dataclasses import replace + from gateway.config import PlatformConfig + own = config.platforms.get(platform) + transport = DeliveryTransport( + shared, replace(own, enabled=True) if own is not None else PlatformConfig(enabled=True), + platform) + if transport is None: + transport = resolve_delivery_transport(platform, config, target_adapters) if transport is not None: pconfig = transport.config runtime_adapter = transport.adapter @@ -1159,9 +1172,11 @@ def _resolve_target_transport( pconfig = config.platforms.get(platform) runtime_adapter = None - if transport is not None and transport.is_relay: - # Relay transport carries the RELAY adapter's config (enablement already checked). The - # logical platform is deliberately NOT natively enabled, so the native gate must not apply. + if transport is not None and (transport.is_relay or pconfig is None): + # Relay transport carries the RELAY adapter's config (enablement already checked): the + # logical platform is deliberately NOT natively enabled. A live NATIVE adapter with no + # ``platforms.

`` block is the same shape — the owning process already authorized the + # adapter; "no config" is not "disabled" (#89302). if pconfig is None: from gateway.config import PlatformConfig pconfig = PlatformConfig(enabled=True) diff --git a/cron/scheduler_preflight.py b/cron/scheduler_preflight.py index 8712cc4f71..21d524a937 100644 --- a/cron/scheduler_preflight.py +++ b/cron/scheduler_preflight.py @@ -198,12 +198,17 @@ class SharedRouteAdapters: chat_id = str(target.get("chat_id") or "") or None thread_id = target.get("thread_id") thread_id = str(thread_id) if thread_id else None + # A cron target carries no inbound guild anchor, so a route's guild_id is matched against + # itself — the target-exact discriminators (chat_id/thread_id) authorize the send. Without + # this the documented ``guild_id + chat_id`` Discord route never authorized cron output. for route in self._routes: if str(route.platform).lower() != platform_key: continue if not (route.chat_id or route.thread_id): continue # guild-only routes are not target-exact - if route.matches(str(route.platform), chat_id=chat_id, thread_id=thread_id): + if route.matches( + str(route.platform), guild_id=route.guild_id, chat_id=chat_id, thread_id=thread_id, + ): return adapter return default diff --git a/tests/cron/test_cron_multiplex_shared_route_delivery.py b/tests/cron/test_cron_multiplex_shared_route_delivery.py index 6802d22119..83977115ba 100644 --- a/tests/cron/test_cron_multiplex_shared_route_delivery.py +++ b/tests/cron/test_cron_multiplex_shared_route_delivery.py @@ -107,3 +107,56 @@ def test_satellite_routes_exact_target_through_primary_adapter(tmp_path, monkeyp def test_shared_view_is_falsy_without_routes_or_primary_adapters(): assert not SharedRouteAdapters({}, []) assert SharedRouteAdapters({Platform.DISCORD: object()}, []).get(Platform.DISCORD) is None + + +def test_guild_scoped_route_authorizes_cron_target_even_when_satellite_has_no_platform_block( + tmp_path, monkeypatch, +): + """The documented Discord route shape is ``guild_id + chat_id``. A cron target carries no guild + anchor, so the route must be matched on its target-exact discriminators; and the satellite's + missing/disabled ``platforms.discord`` block must not veto the PRIMARY's authorized transport + (#89302 sibling) — before, both fell to standalone "DISCORD_BOT_TOKEN is not set".""" + root = tmp_path / "root" + sat_home = root / "profiles" / "fitness" + sat_home.mkdir(parents=True) + (root / "config.yaml").write_text(yaml.safe_dump({ + "gateway": {"multiplex_profiles": True, "profile_routes": [ + {"platform": "discord", "guild_id": "G1", "chat_id": "C1", "profile": "fitness"}]}, + }), encoding="utf-8") + monkeypatch.setattr("hermes_constants.get_default_hermes_root", lambda: root) + primary = _primary_adapter() + + token = set_hermes_home_override(str(sat_home)) + try: + shared = SharedRouteAdapters({Platform.DISCORD: primary}, _primary_profile_routes_for_current_home()) + for satellite_platforms in ({}, {Platform.DISCORD: PlatformConfig(enabled=False)}): + primary.sent.clear() + config = MagicMock() + config.platforms = satellite_platforms + config.get_home_channel = lambda p: None + with patch("gateway.config.load_gateway_config", return_value=config): + error, standalone = _run(_job("C1"), shared) + assert error is None, error + assert primary.sent == ["C1"] and standalone == [] + finally: + reset_hermes_home_override(token) + + +def test_live_native_adapter_without_platform_block_is_not_treated_as_disabled(): + """#89302: a live native adapter handed in by the gateway is the authorization; an absent + ``platforms.

`` block in the firing profile means "no config", not "disabled".""" + from cron.scheduler_delivery import _resolve_target_transport + + config = MagicMock() + config.platforms = {} + adapter = object() + resolved, err = _resolve_target_transport( + {"id": "j"}, Platform.DISCORD, "discord", {"platform": "discord", "chat_id": "C1"}, + {Platform.DISCORD: adapter}, config) + assert err is None and resolved[2] is adapter and resolved[1].enabled + # an explicitly disabled block still vetoes + config.platforms = {Platform.DISCORD: PlatformConfig(enabled=False)} + resolved, err = _resolve_target_transport( + {"id": "j"}, Platform.DISCORD, "discord", {"platform": "discord", "chat_id": "C1"}, + {Platform.DISCORD: adapter}, config) + assert resolved is None and "not configured/enabled" in err