fix(gateway): bypass slash commands during pending update prompts
This commit is contained in:
@@ -3426,6 +3426,10 @@ class GatewayRunner:
|
|||||||
# The update process (detached) wrote .update_prompt.json; the watcher
|
# The update process (detached) wrote .update_prompt.json; the watcher
|
||||||
# forwarded it to the user; now the user's reply goes back via
|
# forwarded it to the user; now the user's reply goes back via
|
||||||
# .update_response so the update process can continue.
|
# .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)
|
_quick_key = self._session_key_for_source(source)
|
||||||
_update_prompts = getattr(self, "_update_prompt_pending", {})
|
_update_prompts = getattr(self, "_update_prompt_pending", {})
|
||||||
if _update_prompts.get(_quick_key):
|
if _update_prompts.get(_quick_key):
|
||||||
@@ -3437,7 +3441,22 @@ class GatewayRunner:
|
|||||||
elif cmd in ("deny", "no"):
|
elif cmd in ("deny", "no"):
|
||||||
response_text = "n"
|
response_text = "n"
|
||||||
else:
|
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:
|
if response_text:
|
||||||
response_path = _hermes_home / ".update_response"
|
response_path = _hermes_home / ".update_response"
|
||||||
try:
|
try:
|
||||||
@@ -8808,7 +8827,7 @@ class GatewayRunner:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def _clear_session_boundary_security_state(self, session_key: str) -> None:
|
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:
|
if not session_key:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -8816,6 +8835,10 @@ class GatewayRunner:
|
|||||||
if isinstance(pending_approvals, dict):
|
if isinstance(pending_approvals, dict):
|
||||||
pending_approvals.pop(session_key, None)
|
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:
|
try:
|
||||||
from tools.approval import clear_session as _clear_approval_session
|
from tools.approval import clear_session as _clear_approval_session
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ def _make_resume_runner():
|
|||||||
runner._running_agents_ts = {}
|
runner._running_agents_ts = {}
|
||||||
runner._busy_ack_ts = {}
|
runner._busy_ack_ts = {}
|
||||||
runner._pending_approvals = {}
|
runner._pending_approvals = {}
|
||||||
|
runner._update_prompt_pending = {}
|
||||||
runner._agent_cache_lock = None
|
runner._agent_cache_lock = None
|
||||||
runner.session_store = MagicMock()
|
runner.session_store = MagicMock()
|
||||||
runner.session_store.get_or_create_session.return_value = current_entry
|
runner.session_store.get_or_create_session.return_value = current_entry
|
||||||
@@ -102,6 +103,7 @@ def _make_branch_runner():
|
|||||||
runner._running_agents_ts = {}
|
runner._running_agents_ts = {}
|
||||||
runner._busy_ack_ts = {}
|
runner._busy_ack_ts = {}
|
||||||
runner._pending_approvals = {}
|
runner._pending_approvals = {}
|
||||||
|
runner._update_prompt_pending = {}
|
||||||
runner._agent_cache_lock = None
|
runner._agent_cache_lock = None
|
||||||
runner.session_store = MagicMock()
|
runner.session_store = MagicMock()
|
||||||
runner.session_store.get_or_create_session.return_value = current_entry
|
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)
|
enable_session_yolo(other_key)
|
||||||
runner._pending_approvals[session_key] = {"command": "rm -rf /tmp/demo"}
|
runner._pending_approvals[session_key] = {"command": "rm -rf /tmp/demo"}
|
||||||
runner._pending_approvals[other_key] = {"command": "rm -rf /tmp/other"}
|
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"))
|
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_approved(session_key, "recursive delete") is False
|
||||||
assert is_session_yolo_enabled(session_key) 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._pending_approvals
|
||||||
|
assert session_key not in runner._update_prompt_pending
|
||||||
assert is_approved(other_key, "recursive delete") is True
|
assert is_approved(other_key, "recursive delete") is True
|
||||||
assert is_session_yolo_enabled(other_key) is True
|
assert is_session_yolo_enabled(other_key) is True
|
||||||
assert other_key in runner._pending_approvals
|
assert other_key in runner._pending_approvals
|
||||||
|
assert other_key in runner._update_prompt_pending
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -150,6 +156,8 @@ async def test_branch_clears_session_scoped_approval_and_yolo_state():
|
|||||||
enable_session_yolo(other_key)
|
enable_session_yolo(other_key)
|
||||||
runner._pending_approvals[session_key] = {"command": "rm -rf /tmp/demo"}
|
runner._pending_approvals[session_key] = {"command": "rm -rf /tmp/demo"}
|
||||||
runner._pending_approvals[other_key] = {"command": "rm -rf /tmp/other"}
|
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"))
|
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_approved(session_key, "recursive delete") is False
|
||||||
assert is_session_yolo_enabled(session_key) 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._pending_approvals
|
||||||
|
assert session_key not in runner._update_prompt_pending
|
||||||
assert is_approved(other_key, "recursive delete") is True
|
assert is_approved(other_key, "recursive delete") is True
|
||||||
assert is_session_yolo_enabled(other_key) is True
|
assert is_session_yolo_enabled(other_key) is True
|
||||||
assert other_key in runner._pending_approvals
|
assert other_key in runner._pending_approvals
|
||||||
|
assert other_key in runner._update_prompt_pending
|
||||||
|
|
||||||
|
|
||||||
def test_clear_session_boundary_security_state_is_scoped():
|
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 = object.__new__(GatewayRunner)
|
||||||
runner._pending_approvals = {}
|
runner._pending_approvals = {}
|
||||||
|
runner._update_prompt_pending = {}
|
||||||
|
|
||||||
source = _make_source()
|
source = _make_source()
|
||||||
session_key = build_session_key(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)
|
enable_session_yolo(other_key)
|
||||||
runner._pending_approvals[session_key] = {"command": "rm -rf /tmp/demo"}
|
runner._pending_approvals[session_key] = {"command": "rm -rf /tmp/demo"}
|
||||||
runner._pending_approvals[other_key] = {"command": "rm -rf /tmp/other"}
|
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)
|
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_approved(session_key, "recursive delete") is False
|
||||||
assert is_session_yolo_enabled(session_key) 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._pending_approvals
|
||||||
|
assert session_key not in runner._update_prompt_pending
|
||||||
# Other session untouched
|
# Other session untouched
|
||||||
assert is_approved(other_key, "recursive delete") is True
|
assert is_approved(other_key, "recursive delete") is True
|
||||||
assert is_session_yolo_enabled(other_key) is True
|
assert is_session_yolo_enabled(other_key) is True
|
||||||
assert other_key in runner._pending_approvals
|
assert other_key in runner._pending_approvals
|
||||||
|
assert other_key in runner._update_prompt_pending
|
||||||
|
|
||||||
# Empty session_key is a no-op
|
# Empty session_key is a no-op
|
||||||
runner._clear_session_boundary_security_state("")
|
runner._clear_session_boundary_security_state("")
|
||||||
assert is_approved(other_key, "recursive delete") is True
|
assert is_approved(other_key, "recursive delete") is True
|
||||||
|
assert other_key in runner._update_prompt_pending
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ class TestWatchUpdateProgress:
|
|||||||
"session_key": "agent:main:telegram:dm:111"}
|
"session_key": "agent:main:telegram:dm:111"}
|
||||||
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
|
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
|
||||||
# Write output
|
# 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()
|
mock_adapter = AsyncMock()
|
||||||
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
||||||
@@ -261,7 +261,7 @@ class TestWatchUpdateProgress:
|
|||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
(hermes_home / ".update_output.txt").write_text(
|
(hermes_home / ".update_output.txt").write_text(
|
||||||
"→ Fetching updates...\n✓ Code updated!\n"
|
"→ Fetching updates...\n✓ Code updated!\n"
|
||||||
)
|
, encoding="utf-8")
|
||||||
(hermes_home / ".update_exit_code").write_text("0")
|
(hermes_home / ".update_exit_code").write_text("0")
|
||||||
|
|
||||||
with patch("gateway.run._hermes_home", hermes_home):
|
with patch("gateway.run._hermes_home", hermes_home):
|
||||||
@@ -489,6 +489,28 @@ class TestUpdatePromptInterception:
|
|||||||
# Should clear the pending flag
|
# Should clear the pending flag
|
||||||
assert session_key not in runner._update_prompt_pending
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_normal_message_when_no_prompt_pending(self, tmp_path):
|
async def test_normal_message_when_no_prompt_pending(self, tmp_path):
|
||||||
"""Messages pass through normally when no prompt is pending."""
|
"""Messages pass through normally when no prompt is pending."""
|
||||||
|
|||||||
Reference in New Issue
Block a user