mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
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
|
||||
# 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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user