Files
hermes-agent/tests/tools/conftest.py
teknium1 3e066dfedd fix(computer_use): approval goes through the shared gate; no callback now fails closed
computer_use kept its own approval decision: two module dicts
(_session_auto_approve / _always_allow) mirroring tools.approval's
session store and _persist_choice, a private verdict vocabulary
(approve_once/approve_session/always_approve) that hermes_cli mapped
back to once/session/always, and — the real problem — `if
_approval_callback is None: return None`. Only the interactive CLI ever
installed that callback, so every other host (gateway turns, cron,
api_server, tui_gateway, ACP) ran destructive desktop input with no
approval at all, ignoring cron_mode / unattended_mode / the permanent
allowlist, and "always" grants were invisible to `is_approved`,
`clear_session` and the messaging-platform approval buttons.

_request_approval now calls tools.approval._run_approval_gate with
pattern_key `cua:<action>:<background|foreground>` (the old scope shape,
so a background grant still never covers the visible foreground variant)
and fail_closed_when_no_human=True, the same posture as
request_tool_approval / the SSH-config write gate. The private dicts,
their release/atexit clearing, the verdict mapping in
hermes_cli/cli_modal_mixin.py and the extra callback install in cli.py
are deleted: the CLI's terminal_tool callback answers computer_use
prompts like any other tool. set_approval_callback stays as an optional
explicit-callback hook with the shared callback contract
(cb(command, description, **kw) -> once|session|always|deny|timeout);
no in-tree host uses it.

Behavior change:
- No approval callback and no gateway (cron, api_server/webhook,
  headless -q, plain library use): destructive actions are now REFUSED
  with a BLOCKED error and never reach the backend. Previously they
  silently ran. cron honors approvals.cron_mode, unattended platforms
  approvals.unattended_mode, -q approvals.single_query_mode.
- --yolo / gateway /yolo / approvals.mode: off still allow (unchanged).
- Gateway sessions (Telegram/Discord/Slack/...) now get a real pending
  approval with once/session/always buttons instead of default-allow.
- session/always grants live in tools.approval's store; "always" is one
  command_allowlist entry (`cua:click:background`) and is scoped to that
  action+mode — the old blanket "always_approve unlocks everything for
  the session" no longer exists.
- Denial wording is the shared gate's ("BLOCKED: User denied ...",
  "BLOCKED: Action timed out ..."); the error JSON keeps `action`.

Tests: tests/tools/test_computer_use_approval_isolation.py
::test_no_callback_refuses_unless_yolo (blocked + no backend call, then
yolo executes) and ::test_always_grant_lands_in_the_shared_store
(is_approved sees the cua:<action>:<mode> key; second call served from
the store). Sabotage: restoring the `callback is None -> allow`
short-circuit fails the first; swapping the shared gate for a private
grant set fails the second plus the three delivery-ladder scope tests.
tests/tools/conftest.py gains `grant_computer_use_approvals` for
dispatch tests that only care about routing.
2026-09-13 05:21:02 -07:00

148 lines
5.6 KiB
Python

"""Shared fixtures for tests/tools/ web-provider tests.
Per-file subprocess isolation means each test file gets a fresh interpreter,
so module-level state (like the web-search-provider registry) is empty when
a file starts. The ``web_registry_populated`` fixture registers all bundled
providers before each test and resets the registry afterwards — tests that
depend on the registry being populated should use it explicitly or via
``@pytest.mark.usefixtures("web_registry_populated")``.
"""
from unittest.mock import patch
import pytest
@pytest.fixture(autouse=True)
def _no_host_browser_use_cli():
"""Keep the host's browser-use/uvx install out of tests.
Browser Use mode is default-on when the CLI is runnable, so a developer
machine with uvx on PATH would silently flip every built-in-browser test
into CLI mode. Pin discovery to "not installed"; tests that exercise the
CLI path monkeypatch ``bu_cli._find_cli`` themselves.
"""
try:
import tools.browser_use_cli as bu_cli
except Exception:
yield
return
# Keep a handle to the real discovery function so TestFindCli (and any
# test that wants genuine PATH probing) can restore it explicitly.
if not hasattr(bu_cli, "_find_cli_unpatched"):
bu_cli._find_cli_unpatched = bu_cli._find_cli
with patch.object(bu_cli, "_find_cli", lambda: None):
yield
@pytest.fixture(autouse=True)
def _materialize_mcp_sdk_symbols():
"""Materialize the lazily-imported MCP SDK before each tools test.
``tools/mcp_tool.py`` defers the ~260ms ``mcp`` SDK import until first
real use (CLI startup perf). Tests in this directory patch SDK symbols
(``ClientSession``, ``stdio_client``, ``_MCP_HTTP_AVAILABLE``, ...) on
the module and expect the pre-lazy eager-import world: symbols bound,
availability flags reflecting the installed SDK. Ensure that state up
front so ``mock.patch`` sees real originals and ``_ensure_mcp_sdk()``
can never clobber a patched flag mid-test (it no-ops once attempted).
"""
try:
from tools import mcp_tool
mcp_tool._ensure_mcp_sdk()
except Exception:
pass
yield
@pytest.fixture(autouse=True)
def _clear_web_result_cache():
"""Reset the web_search TTL memo between tests.
The memo is module-global state in tools/web_result_cache.py; without
this, a test that exercised web_search_tool leaves a cached response
that a later test with the same query would receive instead of its own
mocked provider result.
"""
from tools.web_result_cache import search_memo
search_memo.clear()
yield
search_memo.clear()
def register_all_web_providers():
"""Register all bundled web-search providers into the global registry.
This is the single source of truth for the provider list used by
test classes that need the registry populated for dispatch checks.
"""
from agent.web_search_registry import register_provider, _reset_for_tests
from plugins.web.brave_free.provider import BraveFreeWebSearchProvider
from plugins.web.ddgs.provider import DDGSWebSearchProvider
from plugins.web.exa.provider import ExaWebSearchProvider
from plugins.web.firecrawl.provider import FirecrawlWebSearchProvider
from plugins.web.parallel.provider import ParallelWebSearchProvider
from plugins.web.keenable.provider import KeenableWebSearchProvider
from plugins.web.tavily.provider import TavilyWebSearchProvider
from plugins.web.perplexity.provider import PerplexityWebSearchProvider
from plugins.web.searxng.provider import SearXNGWebSearchProvider
from plugins.web.xai.provider import XAIWebSearchProvider
_reset_for_tests()
for cls in (
BraveFreeWebSearchProvider,
DDGSWebSearchProvider,
ExaWebSearchProvider,
FirecrawlWebSearchProvider,
ParallelWebSearchProvider,
KeenableWebSearchProvider,
TavilyWebSearchProvider,
PerplexityWebSearchProvider,
SearXNGWebSearchProvider,
XAIWebSearchProvider,
):
register_provider(cls())
@pytest.fixture
def grant_computer_use_approvals(monkeypatch):
"""Answer every computer_use approval prompt with "once" through the shared gate.
computer_use fails CLOSED when nobody can answer (no interactive user, no
gateway), so dispatch tests that only care about routing must present an
interactive CLI with a granting callback. "once" persists nothing, so no
grant leaks into ``tools.approval``'s session/permanent stores.
"""
from tools.computer_use import tool as cu_tool
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
cu_tool.set_approval_callback(lambda command, description, **kw: "once")
yield
cu_tool.set_approval_callback(None)
@pytest.fixture
def web_registry_populated():
"""Populate the web-search-provider registry for one test, then reset."""
register_all_web_providers()
yield
from agent.web_search_registry import _reset_for_tests
_reset_for_tests()
@pytest.fixture
def disable_lazy_stt_install():
"""Disarm the runtime lazy-install probe so static ``_HAS_FASTER_WHISPER``
patches accurately simulate 'faster-whisper not installed'.
Without this, ``_try_lazy_install_stt()`` calls
``importlib.util.find_spec("faster_whisper")``, which returns truthy
whenever the package is installed in the dev / CI environment —
defeating the test's ``_HAS_FASTER_WHISPER=False`` patch.
Opt in at module scope with
``pytestmark = pytest.mark.usefixtures("disable_lazy_stt_install")``.
"""
with patch("tools.transcription_tools._try_lazy_install_stt", return_value=False):
yield