-
Notifications
You must be signed in to change notification settings - Fork 0
fix(gateway): route plain-text approval responses (salvage #46924) #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -4732,6 +4732,81 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session | |||||
| ) | ||||||
| return True | ||||||
|
|
||||||
| # --- Approval response routing (#46866) --- | ||||||
| # When the agent is blocked waiting for a dangerous-command approval, | ||||||
| # plain-text responses like "yes" or "approve" must be routed to the | ||||||
| # approval handler instead of being steered/queued/interrupted. | ||||||
| # Otherwise approval via messaging platforms never succeeds — the | ||||||
| # reply is queued behind a turn that can't start until the approval | ||||||
| # resolves, so the approval times out and auto-denies (a deadlock). | ||||||
| # | ||||||
| # Slash forms (/approve, /deny) already bypass to the runner at the | ||||||
| # base-adapter guard. This handles the bare-word forms (Signal/SMS | ||||||
| # users naturally type "yes" rather than "/approve"). Gating on | ||||||
| # has_blocking_approval(session_key) is the disambiguator that keeps | ||||||
| # a conversational "yes" from triggering a dangerous command when no | ||||||
| # approval is actually pending (design intent — see run.py "Pending | ||||||
| # exec approvals are handled by /approve and /deny" note). | ||||||
| # | ||||||
| # We reuse the canonical /approve and /deny handlers rather than | ||||||
| # re-deriving the resolution + i18n messaging: they resolve the | ||||||
| # waiting thread, resume typing, AND return a localized confirmation | ||||||
| # string. The busy-handler path does not auto-send that return, so | ||||||
| # we deliver it ourselves (mirroring the draining-case send above). | ||||||
| try: | ||||||
| from tools.approval import has_blocking_approval | ||||||
| if has_blocking_approval(session_key): | ||||||
| _raw_text = (event.text or "").strip().lower() | ||||||
| _approve_words = {"approve", "yes", "ok", "okay", "confirm", "y", "👍"} | ||||||
| _deny_words = {"deny", "no", "reject", "cancel", "n", "👎"} | ||||||
| _approval_handler = None | ||||||
| _normalized_args = "" | ||||||
| if _raw_text in _approve_words: | ||||||
| _approval_handler = self._handle_approve_command | ||||||
| elif _raw_text in _deny_words: | ||||||
| _approval_handler = self._handle_deny_command | ||||||
| elif _raw_text in {"always", "approve always", "always approve"}: | ||||||
| _approval_handler = self._handle_approve_command | ||||||
| _normalized_args = "always" | ||||||
| elif _raw_text in {"session", "approve session", "session approve"}: | ||||||
| _approval_handler = self._handle_approve_command | ||||||
| _normalized_args = "session" | ||||||
| if _approval_handler is not None: | ||||||
| # Synthesize the canonical "/approve [args]" / "/deny" | ||||||
| # command text so the slash handlers parse modifiers via | ||||||
| # event.get_command_args(). Always use a literal "/" — | ||||||
| # MessageEvent.is_command()/get_command_args() only | ||||||
| # recognize the "/" prefix, not the per-platform display | ||||||
| # prefix ("!" on Slack/Matrix). | ||||||
| _verb = "approve" if _approval_handler is self._handle_approve_command else "deny" | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Bound-method identity check (is) always evaluates to False, causing wrong verb in synthesized command (bug) At _verb = "approve" if _approval_handler is self._handle_approve_command else "deny"In CPython, every access to Impact:
💡 Suggestion: Change
Suggested change
📋 Prompt for AI AgentsIn gateway/run.py at line 4781, fix a single-character bug: change |
||||||
| _synth = f"/{_verb}" | ||||||
| if _normalized_args: | ||||||
| _synth = f"{_synth} {_normalized_args}" | ||||||
| event.text = _synth | ||||||
| _reply = await _approval_handler(event) | ||||||
| logger.info( | ||||||
| "Approval response via plain text: session=%s verb=%s args=%r", | ||||||
| session_key, _verb, _normalized_args, | ||||||
| ) | ||||||
| _adapter = self.adapters.get(event.source.platform) | ||||||
| if _adapter and _reply: | ||||||
| _text, _eph_ttl = _adapter._unwrap_ephemeral(_reply) | ||||||
| if _text: | ||||||
| _anchor = self._reply_anchor_for_event(event) | ||||||
| await _adapter._send_with_retry( | ||||||
| chat_id=event.source.chat_id, | ||||||
| content=_text, | ||||||
| reply_to=_anchor, | ||||||
| metadata=self._thread_metadata_for_source(event.source, _anchor), | ||||||
| ) | ||||||
| return True | ||||||
| except Exception: | ||||||
| logger.warning( | ||||||
| "Plain-text approval routing failed for session %s; " | ||||||
| "falling through to busy handling", | ||||||
| session_key, exc_info=True, | ||||||
| ) | ||||||
|
Comment on lines
+4803
to
+4808
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Exception handler falls through to busy handling after approval resolution, leaking mutated event state (bug) In gateway/run.py, the Consequence A — resolved approval not handled: If the approval is successfully resolved (entry.event.set() called inside Consequence B — mutated event.text leaks: At line 4785, Consequence C — handler failure silently dropped: If 💡 Suggestion: Restructure the exception handling so that: (1) the approval handler call and reply send are in separate try/except scopes, (2) after the approval handler returns successfully, the function always returns True regardless of reply send success, and (3) if the approval handler itself raises, restore event.text to its original value and propagate the exception so the base adapter can surface the error to the user. 📋 Prompt for AI AgentsIn gateway/run.py, in the _handle_active_session_busy_message method around lines 4756-4808, restructure the try/except block. Save event.text before mutation (line 4785). Move the approval handler call (line 4786) outside the broad try. Wrap only the reply send (lines 4791-4801) in its own try/except that logs and consumes exceptions without falling through. Ensure return True after the approval handler succeeds. If the approval handler itself raises, restore event.text and propagate the exception so the base adapter can log it and the user receives an error message, matching the slash-command dispatch path behavior. |
||||||
|
|
||||||
| # Normal busy case (agent actively running a task) | ||||||
| adapter = self.adapters.get(event.source.platform) | ||||||
| if not adapter: | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| """Tests for #46866: plain-text approval responses must resolve a blocking | ||
| dangerous-command approval instead of being steered/queued. | ||
|
|
||
| When the agent is blocked inside tools/approval.py waiting for a dangerous | ||
| command to be approved, a messaging user who replies "yes" / "approve" / | ||
| "deny" (without the leading slash) must have that response routed to the | ||
| approval handler. Previously the bare-word reply fell through to the | ||
| steer/queue/interrupt logic in _handle_active_session_busy_message — the | ||
| approval never resolved, timed out, and auto-denied. | ||
|
|
||
| Slash forms (/approve, /deny) already bypass at the base-adapter guard; | ||
| this covers the bare-word forms Signal/SMS users naturally type. | ||
| """ | ||
|
|
||
| import asyncio | ||
| from types import SimpleNamespace | ||
| from unittest.mock import AsyncMock, MagicMock | ||
|
|
||
| import pytest | ||
|
|
||
| from gateway.config import GatewayConfig, Platform, PlatformConfig | ||
| from gateway.platforms.base import MessageEvent, MessageType | ||
| from gateway.session import SessionSource | ||
|
|
||
|
|
||
| def _make_source() -> SessionSource: | ||
| return SessionSource( | ||
| platform=Platform.TELEGRAM, | ||
| user_id="u1", | ||
| chat_id="c1", | ||
| user_name="tester", | ||
| chat_type="dm", | ||
| ) | ||
|
|
||
|
|
||
| def _make_event(text: str) -> MessageEvent: | ||
| return MessageEvent( | ||
| text=text, | ||
| message_type=MessageType.TEXT, | ||
| source=_make_source(), | ||
| message_id="m1", | ||
| ) | ||
|
|
||
|
|
||
| def _clear_approval_state(): | ||
| from tools import approval as mod | ||
| mod._gateway_queues.clear() | ||
| mod._gateway_notify_cbs.clear() | ||
| mod._session_approved.clear() | ||
| mod._permanent_approved.clear() | ||
| mod._pending.clear() | ||
|
|
||
|
|
||
| def _make_runner(): | ||
| """Minimal GatewayRunner that exercises the real busy-session handler.""" | ||
| from gateway.run import GatewayRunner | ||
|
|
||
| runner = object.__new__(GatewayRunner) | ||
| runner.config = GatewayConfig( | ||
| platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")} | ||
| ) | ||
| adapter = MagicMock() | ||
| adapter.send = AsyncMock() | ||
| adapter._send_with_retry = AsyncMock( | ||
| return_value=SimpleNamespace(success=True, message_id="reply1") | ||
| ) | ||
| # _unwrap_ephemeral is a real base-adapter method; emulate its contract. | ||
| adapter._unwrap_ephemeral = lambda r: (r, 0) if isinstance(r, str) else (None, 0) | ||
| runner.adapters = {Platform.TELEGRAM: adapter} | ||
| runner._running_agents = {} | ||
| runner._running_agents_ts = {} | ||
| runner._pending_messages = {} | ||
| runner._pending_approvals = {} | ||
| runner._busy_ack_ts = {} | ||
| runner._draining = False | ||
| runner.session_store = None | ||
| runner._is_user_authorized = lambda _source: True | ||
| # _handle_active_session_busy_message uses these only on the | ||
| # non-approval fall-through path; harmless to stub. | ||
| runner._busy_input_mode = "interrupt" | ||
| runner._busy_text_mode = "interrupt" | ||
| return runner, adapter | ||
|
|
||
|
|
||
| def _register_blocking_approval(runner): | ||
| """Register a real blocking approval entry for the runner's session.""" | ||
| from tools.approval import _ApprovalEntry, _gateway_queues | ||
| source = _make_source() | ||
| session_key = runner._session_key_for_source(source) | ||
| entry = _ApprovalEntry({"command": "rm -rf /tmp/test"}) | ||
| _gateway_queues.setdefault(session_key, []).append(entry) | ||
| return session_key, entry | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("reply", ["yes", "approve", "ok", "y", "confirm"]) | ||
| def test_plaintext_yes_resolves_approval(reply): | ||
| _clear_approval_state() | ||
| runner, adapter = _make_runner() | ||
| session_key, entry = _register_blocking_approval(runner) | ||
|
|
||
| handled = asyncio.run( | ||
| runner._handle_active_session_busy_message(_make_event(reply), session_key) | ||
| ) | ||
|
|
||
| assert handled is True | ||
| assert entry.event.is_set() | ||
| assert entry.result == "once" | ||
| # The user gets a confirmation reply, not silence. | ||
| adapter._send_with_retry.assert_awaited() | ||
| _clear_approval_state() | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("reply", ["no", "deny", "reject", "n", "cancel"]) | ||
| def test_plaintext_no_denies_approval(reply): | ||
| _clear_approval_state() | ||
| runner, adapter = _make_runner() | ||
| session_key, entry = _register_blocking_approval(runner) | ||
|
|
||
| handled = asyncio.run( | ||
| runner._handle_active_session_busy_message(_make_event(reply), session_key) | ||
| ) | ||
|
|
||
| assert handled is True | ||
| assert entry.event.is_set() | ||
| assert entry.result == "deny" | ||
| adapter._send_with_retry.assert_awaited() | ||
| _clear_approval_state() | ||
|
|
||
|
|
||
| def test_plaintext_always_maps_to_permanent_choice(): | ||
| _clear_approval_state() | ||
| runner, adapter = _make_runner() | ||
| session_key, entry = _register_blocking_approval(runner) | ||
|
|
||
| handled = asyncio.run( | ||
| runner._handle_active_session_busy_message(_make_event("always"), session_key) | ||
| ) | ||
|
|
||
| assert handled is True | ||
| assert entry.result == "always" | ||
| _clear_approval_state() | ||
|
|
||
|
|
||
| def test_plaintext_session_maps_to_session_choice(): | ||
| _clear_approval_state() | ||
| runner, adapter = _make_runner() | ||
| session_key, entry = _register_blocking_approval(runner) | ||
|
|
||
| handled = asyncio.run( | ||
| runner._handle_active_session_busy_message(_make_event("session"), session_key) | ||
| ) | ||
|
|
||
| assert handled is True | ||
| assert entry.result == "session" | ||
| _clear_approval_state() | ||
|
|
||
|
|
||
| def test_no_pending_approval_does_not_consume_conversational_yes(): | ||
| """A bare 'yes' with NO blocking approval must NOT be treated as an | ||
| approval — it falls through to normal busy handling (design intent: | ||
| 'yes' in conversation must not execute a dangerous command).""" | ||
| _clear_approval_state() | ||
| runner, adapter = _make_runner() | ||
| source = _make_source() | ||
| session_key = runner._session_key_for_source(source) | ||
| # No approval registered. | ||
|
|
||
| handled = asyncio.run( | ||
| runner._handle_active_session_busy_message(_make_event("yes"), session_key) | ||
| ) | ||
|
|
||
| # No approval existed, so nothing was resolved — the "yes" is treated | ||
| # as ordinary text, not as a dangerous-command approval (design intent). | ||
| # (It still flows through normal busy handling, which may send a busy | ||
| # ack; the contract here is only that no approval was consumed.) | ||
| from tools.approval import _gateway_queues | ||
| assert session_key not in _gateway_queues | ||
| _clear_approval_state() | ||
|
|
||
|
|
||
| def test_unrelated_text_with_pending_approval_falls_through(): | ||
| """Text that is neither approve nor deny vocab must NOT resolve the | ||
| approval — it falls through to normal busy handling.""" | ||
| _clear_approval_state() | ||
| runner, adapter = _make_runner() | ||
| session_key, entry = _register_blocking_approval(runner) | ||
|
|
||
| handled = asyncio.run( | ||
| runner._handle_active_session_busy_message( | ||
| _make_event("what files are here?"), session_key | ||
| ) | ||
| ) | ||
|
|
||
| # Approval still pending — not resolved by unrelated text. | ||
| assert not entry.event.is_set() | ||
| _clear_approval_state() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Session key mismatch breaks plain-text approval routing under multiplex_profiles (bug)
The plain-text approval routing added in
gateway/run.pyline 4758 uses thesession_keyparameter received from the base adapter. The adapter computes this key atgateway/platforms/base.py:4383viabuild_session_key(event.source, ...)WITHOUT aprofileargument, always producing keys in theagent:mainnamespace. However, the approval queue entries intools/approval.pyare keyed by the agent's session key, which includes the profile namespace whengateway.multiplex_profiles=True(seegateway/session.py:1023-1030and_session_key_namespaceat line 734-751).When a non-default profile is active under multiplexing,
has_blocking_approval(base_adapter_key)returnsFalseeven when a blocking approval exists under the runner's key (agent:<profile>:...). The plain-text response silently falls through to normal busy handling, and the approval times out and auto-denies — exactly the deadlock this PR (NousResearch#46866) was designed to fix. The existing slash-command path (/approve,/deny) is unaffected because it routes through the GatewayRunner's handler which computes its own key via_session_key_for_source().💡 Suggestion: Use
self._session_key_for_source(event.source)instead of the adapter-providedsession_keyparameter for thehas_blocking_approvalcheck and for the logger message at line 4789. This ensures the gate check uses the same key namespace as the approval queue entries.📋 Prompt for AI Agents
In gateway/run.py, _handle_active_session_busy_message at line 4758: replace the adapter-provided session_key with a recomputed key. Change:
to:
Also update the logger at line 4789 to use
_resolved_keyinstead ofsession_keyso the log message reflects the correct session namespace.