Files
hermes-agent/tests/gateway/test_destructive_slash_always_persist_report.py
MaxFreedomPollard eeaba3a88d fix(gateway): do not claim a destructive-slash opt-out that was not saved
Answering "Always Approve" on the /clear, /new, /reset and /undo
confirmation calls save_config_value("approvals.destructive_slash_confirm",
False) and then appended "Future /clear, /new, /reset, and /undo will run
without confirmation" unconditionally.

save_config_value catches its own exceptions and reports the outcome in the
return value, so the caller's try/except could never observe a failed write,
and the return value was ignored. On any install whose config.yaml is not
writable the user was told the preference stuck when it had not, and the
prompt returned on the next restart with no explanation.

Check the return value. The approved action still runs either way, but when
the write did not land, say so and point at the config key instead of
promising an opt-out that was never written.
2026-07-31 22:35:03 -07:00

133 lines
3.9 KiB
Python

"""Answering "Always Approve" must not claim an opt-out that was not saved.
``save_config_value`` swallows its own errors and reports the outcome through
its return value, so the caller's ``try``/``except`` never sees a failed write.
``_on_confirm`` ignored that return value and appended "Future /clear, /new,
/reset, and /undo will run without confirmation" unconditionally, so on any
install whose ``config.yaml`` is not writable (read-only bind mount, container
recreation) the user was told the preference stuck when it had not. The prompt
comes back on the next restart with no explanation.
"""
from __future__ import annotations
import sys
import types
import pytest
import gateway.run as gw
class _Source:
platform = "telegram"
class _Event:
source = _Source()
def _runner():
"""Bare object carrying only what _maybe_confirm_destructive_slash touches."""
obj = types.SimpleNamespace()
obj._read_user_config = lambda: {"approvals": {"destructive_slash_confirm": True}}
obj._session_key_for_source = lambda source: "sess-1"
obj._typed_command_prefix_for = lambda platform: "/"
captured = {}
async def _request_slash_confirm(*, event, command, title, message, handler):
captured["handler"] = handler
return "prompted"
obj._request_slash_confirm = _request_slash_confirm
obj._captured = captured
return obj
async def _resolve(runner, choice, *, result="🧹 Conversation cleared."):
"""Drive the gate, then invoke the captured handler with *choice*."""
async def _execute():
return result
await gw.GatewayRunner._maybe_confirm_destructive_slash(
runner,
event=_Event(),
command="clear",
title="Clear conversation",
detail="This discards history.",
execute=_execute,
)
return await runner._captured["handler"](choice)
@pytest.fixture
def fake_cli(monkeypatch):
"""Install a stub `cli` module whose save_config_value outcome is settable."""
calls = []
module = types.ModuleType("cli")
def save_config_value(key_path, value):
calls.append((key_path, value))
return module._outcome
module.save_config_value = save_config_value
module._outcome = True
monkeypatch.setitem(sys.modules, "cli", module)
module.calls = calls
return module
@pytest.mark.asyncio
async def test_failed_persist_is_reported_as_failed(fake_cli):
fake_cli._outcome = False
out = await _resolve(_runner(), "always")
assert fake_cli.calls == [("approvals.destructive_slash_confirm", False)]
# The action the user approved still ran.
assert out.startswith("🧹 Conversation cleared.")
# ...but the message must not promise an opt-out that was never written.
assert "will run without confirmation" not in out
assert "Could not save that preference" in out
@pytest.mark.asyncio
async def test_successful_persist_still_reports_success(fake_cli):
fake_cli._outcome = True
out = await _resolve(_runner(), "always")
assert fake_cli.calls == [("approvals.destructive_slash_confirm", False)]
assert "will run without confirmation" in out
assert "Could not save that preference" not in out
@pytest.mark.asyncio
async def test_raising_persist_is_also_reported_as_failed(fake_cli):
def _boom(key_path, value):
raise OSError(30, "Read-only file system")
fake_cli.save_config_value = _boom
out = await _resolve(_runner(), "always")
assert out.startswith("🧹 Conversation cleared.")
assert "will run without confirmation" not in out
@pytest.mark.asyncio
async def test_once_neither_persists_nor_annotates(fake_cli):
out = await _resolve(_runner(), "once")
assert fake_cli.calls == []
assert out == "🧹 Conversation cleared."
@pytest.mark.asyncio
async def test_cancel_does_not_persist(fake_cli):
out = await _resolve(_runner(), "cancel")
assert fake_cli.calls == []
assert "cancelled" in out.lower()