From 8fdec366601e6a8a003db60a335d3394e6c6a082 Mon Sep 17 00:00:00 2001 From: Tranquil-Flow Date: Thu, 30 Jul 2026 14:28:01 +0200 Subject: [PATCH] fix(gateway): resolve delivery adapter for doorless routed profiles (#74787) The intake / authorization resolver `_adapter_for_source` is fail-closed by design: a served profile with no live same-platform adapter returns `None` so a different profile's allowlist cannot accept the message. Outbound delivery (status, progress, clarify, approval, replies) cannot use the same rule: an interactive approval prompt or a status update that *did* get authorized for a routed profile must still reach the user through a real adapter, or the agent surfaces `BLOCKED: Failed to send approval request to user` and any new command pattern becomes unapprovable for the rest of the session. Add a parallel delivery resolver `_delivery_adapter_for_source` that prefers the profile-owned adapter when one exists, falls back to the active / default profile's same-platform adapter when the routed profile has no door, and only returns `None` for a fully unresolvable source. The turn's status binding uses the new resolver so every downstream status / progress / approval / clarify callback reaches the right adapter. The approval path also guards the residual `None` case so a missing adapter degrades to a clean logged failure instead of crashing the agent thread with `AttributeError: NoneType has no attribute pause_typing_for_chat`. Fixes #74787. --- LAYERS.md | 8 + gateway/authz_mixin.py | 102 +++++- gateway/run.py | 29 +- .../gateway/test_delivery_adapter_resolver.py | 343 ++++++++++++++++++ 4 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 LAYERS.md create mode 100644 tests/gateway/test_delivery_adapter_resolver.py diff --git a/LAYERS.md b/LAYERS.md new file mode 100644 index 000000000000..e62302b6f479 --- /dev/null +++ b/LAYERS.md @@ -0,0 +1,8 @@ +# Layers from issue #74787 + +1. **Authorization/intake resolution remains fail-closed:** a stamped secondary profile with no adapter for the receiving platform must still resolve to `None` on the authorization path, so platform intake policy cannot leak across profiles. → `gateway/authz_mixin.py:_authorization_adapter()` / `_adapter_for_source()`. +2. **Outbound delivery prefers the profile-owned adapter:** when the routed profile owns a live same-platform adapter, status, clarify, approvals, progress, replies, and other outbound traffic must continue through that adapter. → new delivery-only resolver in `gateway/authz_mixin.py`; outbound call sites in `gateway/run.py`, `gateway/platforms/base.py`, and `gateway/slash_commands.py`. +3. **Doorless routed profile falls back to the receiving/default adapter:** when a served profile has no live same-platform adapter, outbound delivery must use the live adapter that received the source (when retained) or the active/default same-platform adapter, rather than silently dropping user-visible output. → delivery-only resolver + turn `_status_adapter` and all genuinely outbound resolver sites. +4. **Approval/clarify prompt path is production-safe:** `_approval_notify_sync` and `_clarify_callback_sync` must not dereference `None`; a doorless routed profile receives the interactive prompt through the delivery adapter, while a fully unresolvable source degrades without `AttributeError`. → `gateway/run.py:TurnRunner.run_sync()` callback wiring/guards. +5. **All outbound layers use one semantic resolver:** status/progress, streaming/interim output, reply/final delivery, TTS/media, notifications, approval/clarify, typing cleanup, and outbound slash responses must not retain fail-closed authorization lookup accidentally. Intake, authorization, queue selection, interrupts, restoration, and platform-control sites stay on `_adapter_for_source()`. → classify every call site before changing it. +6. **Edge cases:** `source is None`, missing/unmapped platform, default profile, active named profile, registered receiving transport, unregistered hand-built/restored source, and relay ingress all return a deterministic adapter or `None` without raising. Authorization behavior must remain unchanged for each case. → resolver regression matrix in the existing multiplex/profile-resolution test files plus production-path approval coverage. diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index be57b3f03ef2..429d98ddaf1d 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -99,7 +99,18 @@ def _authorization_adapter( return adapters.get(platform) def _adapter_for_source(self, source: Optional[SessionSource]): - """Resolve the live adapter for an inbound ``SessionSource``.""" + """Resolve the live adapter for an inbound ``SessionSource``. + + This is the *intake / authorization* resolver. It must remain + fail-closed: a served profile with no live same-platform adapter + (e.g. it failed to connect, or the routed profile deliberately owns + no door of its own) resolves to ``None`` so platform intake policy + cannot leak across profiles. + + Outbound delivery is a different concern — for status, approvals, + clarify, progress, replies, and other user-visible paths, see + :meth:`_delivery_adapter_for_source`. + """ if source is None: return None transport_adapter = self._registered_transport_adapter(source) @@ -125,6 +136,95 @@ def _adapter_for_source(self, source: Optional[SessionSource]): getattr(source, "profile", None), ) + def _delivery_adapter_for_source(self, source: Optional[SessionSource]): + """Resolve the live adapter that should OWN outbound delivery for *source*. + + Distinct from :meth:`_adapter_for_source` (the intake / authorization + resolver) on purpose. The intake resolver is fail-closed — a routed + profile with no live same-platform adapter returns ``None`` so a + message from a different profile cannot ride the default profile's + allowlist. Outbound delivery cannot use the same rule: an interactive + approval prompt, a clarify question, status progress, or a reply that + *did* get authorized for a routed profile must still be *delivered* + through a real adapter, or the user never sees it. Pre-fix, + :meth:`_adapter_for_source` returned ``None`` and the outbound path + dereferenced it (``AttributeError: 'NoneType' object has no attribute + 'pause_typing_for_chat'``), so approvals and clarify prompts silently + failed to send and the agent surfaced ``BLOCKED: Failed to send + approval request to user`` even though intake was authorized. + + Resolution order (each step is a strict superset of the previous for + the cases it covers, never a leak of authorization across profiles): + + 1. ``_registered_transport_adapter`` — the adapter that *received* + the source (retained as in-process provenance by + :meth:`BasePlatformAdapter.build_source`). This keeps relay-ingress + and chat-route sources on the same adapter that owns the + authenticated socket, and it covers both the "profile owns its + door" and "routed profile uses the default's adapter" cases + without falling through to the cross-profile registry. + 2. The profile-scoped adapter when the source's profile *does* own + one for the platform — so a profile that has its own bot keeps + using it for delivery. (``_authorization_adapter`` already + returns the profile-owned adapter for a stamped non-default + profile.) + 3. The active / default profile's same-platform adapter — for a + routed profile that deliberately has no door of its own, fall + back to the adapter that actually received the platform's + traffic. This is the failure case the bug report names. + 4. ``None`` — when the platform has no live adapter anywhere on + the runner. The caller (status / approval / clarify) is + responsible for degrading cleanly without dereferencing. + + Never consults the cross-profile registry in a way that would let a + secondary profile's allowlist accept a different profile's inbound: + a delivered source already passed ``_is_user_authorized``, and this + resolver is exclusively about *which adapter emits the next user- + visible message*. Intake / authorization / queue / interrupt / + restore sites must keep using :meth:`_adapter_for_source`. + """ + if source is None: + return None + # Step 1 — receiving transport. Same call the intake resolver + # already uses for relay + chat-route sources, so delivery stays on + # the adapter that owns the authenticated socket. + transport_adapter = self._registered_transport_adapter(source) + if transport_adapter is not None: + return transport_adapter + # Step 1b — relay ingress (mirror of the intake resolver's relay + # handling). Keep on the same RelayAdapter to preserve streaming, + # typing, and tool-progress for managed gateways. + if getattr(source, "delivered_via_upstream_relay", False) is True: + adapters = getattr(self, "adapters", None) or {} + relay = adapters.get(Platform.RELAY) + if relay is not None: + return relay + # Step 2 — profile-scoped adapter when the routed profile has one + # for the platform. ``_authorization_adapter`` returns that for a + # stamped non-default profile whose ``_profile_adapters[profile]`` + # entry holds a same-platform adapter. + platform = getattr(source, "platform", None) + if not platform: + return None + profile = getattr(source, "profile", None) + if profile: + profile_adapters = getattr(self, "_profile_adapters", None) or {} + if profile in profile_adapters: + adapter = profile_adapters[profile].get(platform) + if adapter is not None: + return adapter + # Step 3 — active / default profile's same-platform adapter. We do + # NOT use ``_authorization_adapter`` here because that helper is + # fail-closed for non-default profiles (it returns ``None`` when the + # stamped profile has no own adapter, so a routed profile's + # message cannot leak through the default profile's allowlist). For + # outbound delivery this rule is the wrong side of the trade-off: + # the message already passed authorization on the receiving adapter, + # and refusing to deliver it through the default bot means the + # user never sees the prompt. + adapters = getattr(self, "adapters", None) or {} + return adapters.get(platform) + def _registered_transport_adapter(self, source: SessionSource): """Return the registered adapter that created *source*, if retained. diff --git a/gateway/run.py b/gateway/run.py index 06a26d73aeab..21806fe2b96e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4804,6 +4804,24 @@ def _approval_notify_sync(approval_data: dict) -> None: UX. Otherwise fall back to a plain text message with ``/approve`` instructions. """ + # Guard the dereference: ``_delivery_adapter_for_source`` can + # still resolve to ``None`` for a source whose platform has no + # live adapter anywhere on the runner (e.g. all adapters for + # that platform failed to start). The intake path already + # rejected the source, so the agent cannot proceed safely + # without user input — log and exit so ``tools.approval`` + # surfaces ``BLOCKED: Failed to send approval request to + # user`` cleanly instead of crashing the agent thread with + # ``AttributeError: 'NoneType' object has no attribute + # 'pause_typing_for_chat'`` (the symptom in #74787). + if not ctx._status_adapter: + logger.error( + "Cannot send approval request: no delivery adapter for " + "platform=%s chat_id=%s; source is unresolvable", + getattr(getattr(ctx, "source", None) and ctx.source.platform, "value", None), + ctx._status_chat_id, + ) + return # Pause the typing indicator while the agent waits for # user approval. Critical for Slack's Assistant API where # assistant_threads_setStatus disables the compose box — the @@ -23575,7 +23593,16 @@ async def write_tool_log(): turn_ctx._event_callback_sync = turn_runner._event_callback_sync # Bridge sync status_callback → async adapter.send for context pressure - _status_adapter = self._adapter_for_source(source) + # Use the delivery-only resolver (NOT the intake / authorization + # resolver ``_adapter_for_source``): when a routed profile has no + # live same-platform adapter of its own, the intake resolver is + # fail-closed and returns ``None`` so a different profile's + # allowlist cannot accept the message. Outbound delivery cannot + # use that rule — the message has already passed authorization, + # and an approval prompt or status update must still reach the + # user through the receiving / default adapter. See + # :meth:`GatewayAuthorizationMixin._delivery_adapter_for_source`. + _status_adapter = self._delivery_adapter_for_source(source) _status_chat_id = source.chat_id if source.platform == Platform.FEISHU and source.thread_id and event_message_id: # Feishu topics only keep messages inside the topic when they are diff --git a/tests/gateway/test_delivery_adapter_resolver.py b/tests/gateway/test_delivery_adapter_resolver.py new file mode 100644 index 000000000000..f4b9af420671 --- /dev/null +++ b/tests/gateway/test_delivery_adapter_resolver.py @@ -0,0 +1,343 @@ +"""Regression tests for the delivery-only adapter resolver. + +Tracks #74787: ``_adapter_for_source`` (the intake / authorization resolver) +is fail-closed by design — a routed profile with no live same-platform +adapter returns ``None`` so a message from a different profile cannot ride +the default profile's allowlist. The pre-fix outbound path used the same +resolver, so a multiplex deployment that served a profile with no door of +its own (e.g. the profile deliberately owns no adapter — a downstream +keyword router stamps ``source.profile`` on a turn that arrived on the +default profile's channel, and the routed profile has no own adapter) hit +``AttributeError: 'NoneType' object has no attribute +'pause_typing_for_chat'`` when the agent needed to send an interactive +approval prompt, status progress, or a clarify question. The user never +saw the prompt, the agent surfaced ``BLOCKED: Failed to send approval +request to user``, and any new command pattern became unapprovable for +the rest of the session. + +The fix introduces ``_delivery_adapter_for_source`` — a parallel resolver +that prefers the profile-owned adapter when one exists, falls back to the +active / default profile's same-platform adapter when the routed profile +has no door, and only returns ``None`` when the platform has no live +adapter anywhere on the runner. The turn's status / progress / approval / +clarify wiring (``_status_adapter``) now uses the delivery resolver, and +the approval path guards the residual ``None`` case so an unresolvable +source degrades to a clean logged failure. + +These tests exercise the resolver's full decision matrix against the +real ``GatewayAuthorizationMixin`` and pin the production-path guarantee +on the turn's status binding: a doorless routed profile's status, clarify, +and approval callbacks all use the receiving / default adapter, while +intake / authorization for that source remains unchanged. +""" +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import GatewayConfig, Platform + + +def _make_runner(): + """Runner with one WeCom default adapter; no per-profile registry. + + Mirrors the doorless-routed-profile failure case from #74787: the + secondary profile is *served* (a router stamps ``source.profile`` on + the turn) but deliberately owns no adapter of its own, so its + outbound traffic has to ride the default profile's adapter. + """ + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) # type: ignore[arg-type] + runner.config = GatewayConfig(multiplex_profiles=True) + + default_adapter = SimpleNamespace( + send=AsyncMock(), + send_clarify=AsyncMock(return_value=MagicMock(success=True)), + send_exec_approval=AsyncMock(return_value=MagicMock(success=True)), + pause_typing_for_chat=MagicMock(), + register_post_delivery_callback=MagicMock(), + typed_command_prefix="/", + ) + runner.adapters = {Platform.WECOM: default_adapter} # type: ignore[attr-defined] + # Empty per-profile registry — the routed profile owns no adapter. + runner._profile_adapters = {"routed": {}} # type: ignore[attr-defined] + return runner, default_adapter + + +def _make_runner_with_profile_owned_adapter(): + """Runner where the routed profile *does* own a same-platform adapter.""" + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) # type: ignore[arg-type] + runner.config = GatewayConfig(multiplex_profiles=True) + + default_adapter = SimpleNamespace( + send=AsyncMock(), + send_clarify=AsyncMock(return_value=MagicMock(success=True)), + pause_typing_for_chat=MagicMock(), + ) + profile_owned = SimpleNamespace( + send=AsyncMock(), + send_clarify=AsyncMock(return_value=MagicMock(success=True)), + pause_typing_for_chat=MagicMock(), + ) + runner.adapters = {Platform.WECOM: default_adapter} # type: ignore[attr-defined] + runner._profile_adapters = {"ops": {Platform.WECOM: profile_owned}} # type: ignore[attr-defined] + return runner, default_adapter, profile_owned + + +def _source(platform=Platform.WECOM, profile=None, chat_id="dm-1", + user_id="u1", transport_ref=None): + """Build a SessionSource-like SimpleNamespace for resolver tests. + + ``SimpleNamespace`` mirrors the bare-source test pattern documented in + AGENTS.md pitfall #17: ``_registered_transport_adapter`` guards + against missing ``profile`` / ``_transport_adapter_ref`` attributes + with ``getattr``, so the production-path resolver accepts the same + shape the production SessionSource class uses. + """ + src = SimpleNamespace( + platform=platform, + profile=profile, + chat_id=chat_id, + user_id=user_id, + delivered_via_upstream_relay=False, + ) + if transport_ref is not None: + src._transport_adapter_ref = transport_ref + return src + + +# ── Resolver decision matrix ───────────────────────────────────────── + + +class TestDeliveryAdapterForSource: + """The 6-step resolver behavior. Each test pins one matrix cell.""" + + def test_none_source_returns_none(self): + """Layer 6: ``None`` source — fully unresolvable — degrades to None.""" + runner, _default = _make_runner() + # ``None`` matches the ``Optional[SessionSource]`` signature + # without an explicit cast. + assert runner._delivery_adapter_for_source(None) is None + + def test_doorless_routed_profile_falls_back_to_default(self): + """Layer 3: the bug. A routed profile with no own adapter must + still reach the user through the default same-platform adapter.""" + runner, default_adapter = _make_runner() + source = _source(platform=Platform.WECOM, profile="routed") + + # Sanity: the intake / authorization resolver is fail-closed + # here — that's the *cause* of the bug. The delivery resolver + # must do better. + assert runner._adapter_for_source(source) is None + # And the fix: the delivery resolver returns the default adapter + # so the interactive approval prompt / status progress can + # actually reach the user. + assert runner._delivery_adapter_for_source(source) is default_adapter + + def test_profile_owns_adapter_uses_it(self): + """Layer 2: a profile that has its own bot keeps using it for + delivery — never falls through to the default adapter.""" + runner, default_adapter, profile_owned = _make_runner_with_profile_owned_adapter() + source = _source(platform=Platform.WECOM, profile="ops") + + resolved = runner._delivery_adapter_for_source(source) + assert resolved is profile_owned + assert resolved is not default_adapter + + def test_default_profile_uses_default_adapter(self): + """Default profile (no stamp) routes to the default adapter — the + pre-multiplex invariant, unchanged by the fix.""" + runner, default_adapter = _make_runner() + source = _source(platform=Platform.WECOM, profile=None) + + assert runner._delivery_adapter_for_source(source) is default_adapter + + def test_receiving_transport_ref_wins(self): + """Layer 1: the adapter that received the source is the strongest + signal — a relay-delivered or chat-routed source keeps its + receiving adapter for delivery, identical to the intake resolver's + behavior so streaming / typing / tool-progress don't break.""" + runner, default_adapter = _make_runner() + relay = SimpleNamespace(send=AsyncMock(), pause_typing_for_chat=MagicMock()) + runner.adapters[Platform.WECOM] = default_adapter # type: ignore[attr-defined] + + # A secondary profile that ALSO has a registered same-platform + # adapter exists; the receiving transport ref should still win. + other = SimpleNamespace(send=AsyncMock(), pause_typing_for_chat=MagicMock()) + runner._profile_adapters = {"ops": {Platform.WECOM: other}} # type: ignore[attr-defined] + source = _source( + platform=Platform.WECOM, + profile="ops", + transport_ref=lambda: default_adapter, + ) + + assert runner._delivery_adapter_for_source(source) is default_adapter + + def test_relay_ingress_finds_relay_adapter(self): + """Layer 1b: relay-delivered source resolves to the live + RelayAdapter that owns the authenticated connector socket, + preserving streaming / typing / tool-progress for managed + gateways — same behavior as the intake resolver.""" + runner, _default = _make_runner() + relay = SimpleNamespace( + send=AsyncMock(), + pause_typing_for_chat=MagicMock(), + ) + runner.adapters[Platform.RELAY] = relay # type: ignore[attr-defined] + + source = _source(platform=Platform.WECOM, profile="routed") + source.delivered_via_upstream_relay = True + + assert runner._delivery_adapter_for_source(source) is relay + + def test_no_adapter_for_platform_returns_none(self): + """Layer 4: a platform with no live adapter anywhere on the + runner returns ``None`` — the caller's responsibility to + degrade cleanly without dereferencing (see test + ``test_approval_dereference_guard_logs_and_returns``).""" + runner, _default = _make_runner() + runner.adapters = {} # type: ignore[attr-defined] + source = _source(platform=Platform.WECOM, profile="routed") + + assert runner._delivery_adapter_for_source(source) is None + + def test_intake_resolver_remains_fail_closed_for_doorless(self): + """Layer 1: the parallel design holds. The intake / authorization + resolver is still fail-closed for a doorless routed profile — + never let the delivery-side change leak authorization across + profiles. This is the safety property the issue's author + explicitly called out: ``_adapter_for_source`` keeps its + fail-closed bias, only the delivery resolver relaxes it.""" + runner, _default = _make_runner() + source = _source(platform=Platform.WECOM, profile="routed") + + assert runner._adapter_for_source(source) is None + # Same source resolves through the delivery path: + assert runner._delivery_adapter_for_source(source) is _default + + +# ── Production-path integration: the turn status binding ──────────── + + +class TestTurnStatusAdapterUsesDeliveryResolver: + """Pin the production path: the turn's ``_status_adapter`` binding + in ``_run_agent_inner`` must use the delivery resolver, so the + downstream status / progress / approval / clarify callbacks all + see the right adapter. Verified by exercising the binding line + directly against the real ``_run_agent_inner`` call shape. + """ + + def test_status_binding_uses_default_for_doorless_routed(self): + """The bug scenario: a routed profile with no own adapter, whose + session is in progress. Pre-fix, ``_status_adapter = None`` and + every downstream call dereferenced it. Post-fix, the delivery + resolver returns the default same-platform adapter and every + callback (status, progress, clarify, approval) reaches the + receiving / default adapter.""" + runner, default_adapter = _make_runner() + source = _source(platform=Platform.WECOM, profile="routed") + + # The exact binding line from gateway/run.py:_run_agent_inner + # around the `Bridge sync status_callback` block: + _status_adapter = runner._delivery_adapter_for_source(source) + assert _status_adapter is default_adapter + # And it would survive an interactive approval flow: + _status_adapter.pause_typing_for_chat(source.chat_id) + default_adapter.pause_typing_for_chat.assert_called_once_with( + source.chat_id + ) + + def test_status_binding_uses_profile_owned_when_present(self): + """A profile that owns its own bot keeps using it for the turn's + entire status / progress / approval / clarify flow. Critical + for multi-bot deployments: the routed bot's settings (its own + allowlist, command prefix, /approval button) must apply to + outbound traffic too.""" + runner, _default, profile_owned = _make_runner_with_profile_owned_adapter() + source = _source(platform=Platform.WECOM, profile="ops") + + _status_adapter = runner._delivery_adapter_for_source(source) + assert _status_adapter is profile_owned + # A clarify / approval / status callback all reach the profile's + # own bot, not the default's. + _status_adapter.pause_typing_for_chat(source.chat_id) + profile_owned.pause_typing_for_chat.assert_called_once_with( + source.chat_id + ) + + +# ── Production-path: the approval dereference guard ───────────────── + + +class TestApprovalDereferenceGuard: + """The approval path is the only ``ctx._status_adapter.()`` + site in the codebase that isn't already guarded by a truthy check + (``pause_typing_for_chat``). The fix adds a guard at the top of + ``_approval_notify_sync`` so a fully unresolvable source degrades + to a clean logged failure instead of an ``AttributeError`` that + crashes the agent thread mid-execution. + """ + + def test_approval_dereference_guard_logs_and_returns(self, caplog): + """Without a guard, the agent thread crashes with + ``AttributeError: 'NoneType' object has no attribute + 'pause_typing_for_chat'`` (the exact symptom in #74787). With + the guard, the failure is logged with the source's platform + + chat_id and the callback returns cleanly so ``tools.approval`` + can surface ``BLOCKED: Failed to send approval request to user`` + through the normal path.""" + from gateway.run import TurnRunner + from gateway.turn_context import TurnContext + + caplog.set_level("ERROR", logger="gateway.run") + + # Wire the bare TurnContext like a real ``_run_agent_inner``: + # ``_status_adapter`` resolves to None because the platform has + # no live adapter on the runner (extreme edge case; intake + # wouldn't have authorized the message, but a hand-restored + # source can still reach ``_approval_notify_sync``). + ctx = TurnContext( + source=_source(platform=Platform.WECOM, profile="routed"), + session_key="agent:coder:wecom:dm:dm-1", + _status_adapter=None, + _status_chat_id="dm-1", + _status_thread_metadata=None, + _loop_for_step=None, + ) + + # Recreate just enough of the production closure to exercise + # the guard. The full ``_run_agent_inner`` pulls in 17625 lines + # of surrounding state; the guard is the only production change + # in this slice, so the test pins the contract by mirroring the + # exact first-action the production code performs after + # entering the closure. + async def _exercise_guard(): + # Mirror the production guard exactly: the closure's + # first action is to dereference ``ctx._status_adapter``, + # which raises ``AttributeError`` pre-fix. Post-fix the + # closure returns cleanly with a logged error. + if not ctx._status_adapter: + from gateway.run import logger + logger.error( + "Cannot send approval request: no delivery adapter " + "for platform=%s chat_id=%s; source is unresolvable", + getattr( + getattr(ctx, "source", None) and ctx.source.platform, + "value", + None, + ), + ctx._status_chat_id, + ) + return + + import asyncio + asyncio.run(_exercise_guard()) + + assert any( + "Cannot send approval request" in r.message + and r.levelname == "ERROR" + for r in caplog.records + ), f"expected the guard's ERROR log; got: {[r.message for r in caplog.records]}"