fix(gateway): bypass slash commands during pending update prompts

This commit is contained in:
Yukipukii1
2026-04-26 05:05:28 +03:00
committed by Teknium
parent bdc1adf711
commit bdaf56a94d
3 changed files with 65 additions and 4 deletions

View File

@@ -3426,6 +3426,10 @@ class GatewayRunner:
# The update process (detached) wrote .update_prompt.json; the watcher
# forwarded it to the user; now the user's reply goes back via
# .update_response so the update process can continue.
#
# IMPORTANT: recognized slash commands must bypass this interception.
# Otherwise control/session commands like /new or /help get silently
# consumed as update answers instead of being dispatched normally.
_quick_key = self._session_key_for_source(source)
_update_prompts = getattr(self, "_update_prompt_pending", {})
if _update_prompts.get(_quick_key):
@@ -3437,7 +3441,22 @@ class GatewayRunner:
elif cmd in ("deny", "no"):
response_text = "n"
else:
response_text = raw
_recognized_cmd = None
if cmd:
try:
from hermes_cli.commands import resolve_command as _resolve_update_cmd
except Exception:
_resolve_update_cmd = None
if _resolve_update_cmd is not None:
try:
_cmd_def = _resolve_update_cmd(cmd)
_recognized_cmd = _cmd_def.name if _cmd_def else None
except Exception:
_recognized_cmd = None
if _recognized_cmd:
response_text = ""
else:
response_text = raw
if response_text:
response_path = _hermes_home / ".update_response"
try:
@@ -8808,7 +8827,7 @@ class GatewayRunner:
return True
def _clear_session_boundary_security_state(self, session_key: str) -> None:
"""Clear approval state that must not survive a real conversation switch."""
"""Clear per-session control state that must not survive a boundary switch."""
if not session_key:
return
@@ -8816,6 +8835,10 @@ class GatewayRunner:
if isinstance(pending_approvals, dict):
pending_approvals.pop(session_key, None)
update_prompt_pending = getattr(self, "_update_prompt_pending", None)
if isinstance(update_prompt_pending, dict):
update_prompt_pending.pop(session_key, None)
try:
from tools.approval import clear_session as _clear_approval_session
except Exception:

View File

@@ -76,6 +76,7 @@ def _make_resume_runner():
runner._running_agents_ts = {}
runner._busy_ack_ts = {}
runner._pending_approvals = {}
runner._update_prompt_pending = {}
runner._agent_cache_lock = None
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = current_entry
@@ -102,6 +103,7 @@ def _make_branch_runner():
runner._running_agents_ts = {}
runner._busy_ack_ts = {}
runner._pending_approvals = {}
runner._update_prompt_pending = {}
runner._agent_cache_lock = None
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = current_entry
@@ -127,6 +129,8 @@ async def test_resume_clears_session_scoped_approval_and_yolo_state():
enable_session_yolo(other_key)
runner._pending_approvals[session_key] = {"command": "rm -rf /tmp/demo"}
runner._pending_approvals[other_key] = {"command": "rm -rf /tmp/other"}
runner._update_prompt_pending[session_key] = True
runner._update_prompt_pending[other_key] = True
result = await runner._handle_resume_command(_make_event("/resume Resumed Work"))
@@ -134,9 +138,11 @@ async def test_resume_clears_session_scoped_approval_and_yolo_state():
assert is_approved(session_key, "recursive delete") is False
assert is_session_yolo_enabled(session_key) is False
assert session_key not in runner._pending_approvals
assert session_key not in runner._update_prompt_pending
assert is_approved(other_key, "recursive delete") is True
assert is_session_yolo_enabled(other_key) is True
assert other_key in runner._pending_approvals
assert other_key in runner._update_prompt_pending
@pytest.mark.asyncio
@@ -150,6 +156,8 @@ async def test_branch_clears_session_scoped_approval_and_yolo_state():
enable_session_yolo(other_key)
runner._pending_approvals[session_key] = {"command": "rm -rf /tmp/demo"}
runner._pending_approvals[other_key] = {"command": "rm -rf /tmp/other"}
runner._update_prompt_pending[session_key] = True
runner._update_prompt_pending[other_key] = True
result = await runner._handle_branch_command(_make_event("/branch"))
@@ -157,9 +165,11 @@ async def test_branch_clears_session_scoped_approval_and_yolo_state():
assert is_approved(session_key, "recursive delete") is False
assert is_session_yolo_enabled(session_key) is False
assert session_key not in runner._pending_approvals
assert session_key not in runner._update_prompt_pending
assert is_approved(other_key, "recursive delete") is True
assert is_session_yolo_enabled(other_key) is True
assert other_key in runner._pending_approvals
assert other_key in runner._update_prompt_pending
def test_clear_session_boundary_security_state_is_scoped():
@@ -172,6 +182,7 @@ def test_clear_session_boundary_security_state_is_scoped():
runner = object.__new__(GatewayRunner)
runner._pending_approvals = {}
runner._update_prompt_pending = {}
source = _make_source()
session_key = build_session_key(source)
@@ -183,6 +194,8 @@ def test_clear_session_boundary_security_state_is_scoped():
enable_session_yolo(other_key)
runner._pending_approvals[session_key] = {"command": "rm -rf /tmp/demo"}
runner._pending_approvals[other_key] = {"command": "rm -rf /tmp/other"}
runner._update_prompt_pending[session_key] = True
runner._update_prompt_pending[other_key] = True
runner._clear_session_boundary_security_state(session_key)
@@ -190,11 +203,14 @@ def test_clear_session_boundary_security_state_is_scoped():
assert is_approved(session_key, "recursive delete") is False
assert is_session_yolo_enabled(session_key) is False
assert session_key not in runner._pending_approvals
assert session_key not in runner._update_prompt_pending
# Other session untouched
assert is_approved(other_key, "recursive delete") is True
assert is_session_yolo_enabled(other_key) is True
assert other_key in runner._pending_approvals
assert other_key in runner._update_prompt_pending
# Empty session_key is a no-op
runner._clear_session_boundary_security_state("")
assert is_approved(other_key, "recursive delete") is True
assert other_key in runner._update_prompt_pending

View File

@@ -251,7 +251,7 @@ class TestWatchUpdateProgress:
"session_key": "agent:main:telegram:dm:111"}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
# Write output
(hermes_home / ".update_output.txt").write_text("→ Fetching updates...\n")
(hermes_home / ".update_output.txt").write_text("→ Fetching updates...\n", encoding="utf-8")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
@@ -261,7 +261,7 @@ class TestWatchUpdateProgress:
await asyncio.sleep(0.3)
(hermes_home / ".update_output.txt").write_text(
"→ Fetching updates...\n✓ Code updated!\n"
)
, encoding="utf-8")
(hermes_home / ".update_exit_code").write_text("0")
with patch("gateway.run._hermes_home", hermes_home):
@@ -489,6 +489,28 @@ class TestUpdatePromptInterception:
# Should clear the pending flag
assert session_key not in runner._update_prompt_pending
@pytest.mark.asyncio
async def test_recognized_slash_command_bypasses_pending_update_prompt(self, tmp_path):
"""Known slash commands must dispatch normally instead of being consumed."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
event = _make_event(text="/new", chat_id="67890")
session_key = "agent:main:telegram:dm:67890"
runner._update_prompt_pending[session_key] = True
runner._is_user_authorized = MagicMock(return_value=True)
runner._session_key_for_source = MagicMock(return_value=session_key)
runner._handle_reset_command = AsyncMock(return_value="reset ok")
with patch("gateway.run._hermes_home", hermes_home):
result = await runner._handle_message(event)
assert result == "reset ok"
runner._handle_reset_command.assert_awaited_once_with(event)
assert not (hermes_home / ".update_response").exists()
assert runner._update_prompt_pending[session_key] is True
@pytest.mark.asyncio
async def test_normal_message_when_no_prompt_pending(self, tmp_path):
"""Messages pass through normally when no prompt is pending."""