Merge branch 'main' into rewbs/tool-use-charge-to-subscription
This commit is contained in:
@@ -201,3 +201,52 @@ class TestSecretCapturePayloadRedaction:
|
||||
text = '{"raw_secret": "ghp_abc123def456ghi789jkl"}'
|
||||
result = redact_sensitive_text(text)
|
||||
assert "abc123def456" not in result
|
||||
|
||||
|
||||
class TestElevenLabsTavilyExaKeys:
|
||||
"""Regression tests for ElevenLabs (sk_), Tavily (tvly-), and Exa (exa_) keys."""
|
||||
|
||||
def test_elevenlabs_key_redacted(self):
|
||||
text = "ELEVENLABS_API_KEY=sk_abc123def456ghi789jklmnopqrstu"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "abc123def456ghi" not in result
|
||||
|
||||
def test_elevenlabs_key_in_log_line(self):
|
||||
text = "Connecting to ElevenLabs with key sk_abc123def456ghi789jklmnopqrstu"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "abc123def456ghi" not in result
|
||||
|
||||
def test_tavily_key_redacted(self):
|
||||
text = "TAVILY_API_KEY=tvly-ABCdef123456789GHIJKL0000"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "ABCdef123456789" not in result
|
||||
|
||||
def test_tavily_key_in_log_line(self):
|
||||
text = "Initialising Tavily client with tvly-ABCdef123456789GHIJKL0000"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "ABCdef123456789" not in result
|
||||
|
||||
def test_exa_key_redacted(self):
|
||||
text = "EXA_API_KEY=exa_XYZ789abcdef000000000000000"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "XYZ789abcdef" not in result
|
||||
|
||||
def test_exa_key_in_log_line(self):
|
||||
text = "Using Exa client with key exa_XYZ789abcdef000000000000000"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "XYZ789abcdef" not in result
|
||||
|
||||
def test_all_three_in_env_dump(self):
|
||||
env_dump = (
|
||||
"HOME=/home/user\n"
|
||||
"ELEVENLABS_API_KEY=sk_abc123def456ghi789jklmnopqrstu\n"
|
||||
"TAVILY_API_KEY=tvly-ABCdef123456789GHIJKL0000\n"
|
||||
"EXA_API_KEY=exa_XYZ789abcdef000000000000000\n"
|
||||
"SHELL=/bin/bash\n"
|
||||
)
|
||||
result = redact_sensitive_text(env_dump)
|
||||
assert "abc123def456ghi" not in result
|
||||
assert "ABCdef123456789" not in result
|
||||
assert "XYZ789abcdef" not in result
|
||||
assert "HOME=/home/user" in result
|
||||
assert "SHELL=/bin/bash" in result
|
||||
|
||||
@@ -15,6 +15,7 @@ class DummyTelegramAdapter(BasePlatformAdapter):
|
||||
super().__init__(PlatformConfig(enabled=True, token="fake-token"), Platform.TELEGRAM)
|
||||
self.sent = []
|
||||
self.typing = []
|
||||
self.processing_hooks = []
|
||||
|
||||
async def connect(self) -> bool:
|
||||
return True
|
||||
@@ -40,6 +41,12 @@ class DummyTelegramAdapter(BasePlatformAdapter):
|
||||
async def get_chat_info(self, chat_id: str):
|
||||
return {"id": chat_id}
|
||||
|
||||
async def on_processing_start(self, event: MessageEvent) -> None:
|
||||
self.processing_hooks.append(("start", event.message_id))
|
||||
|
||||
async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
|
||||
self.processing_hooks.append(("complete", event.message_id, success))
|
||||
|
||||
|
||||
def _make_event(chat_id: str, thread_id: str, message_id: str = "1") -> MessageEvent:
|
||||
return MessageEvent(
|
||||
@@ -133,3 +140,83 @@ class TestBasePlatformTopicSessions:
|
||||
"metadata": {"thread_id": "17585"},
|
||||
}
|
||||
]
|
||||
assert adapter.processing_hooks == [
|
||||
("start", "1"),
|
||||
("complete", "1", True),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_background_marks_total_send_failure_unsuccessful(self):
|
||||
adapter = DummyTelegramAdapter()
|
||||
|
||||
async def handler(_event):
|
||||
await asyncio.sleep(0)
|
||||
return "ack"
|
||||
|
||||
async def failing_send(*_args, **_kwargs):
|
||||
return SendResult(success=False, error="send failed")
|
||||
|
||||
async def hold_typing(_chat_id, interval=2.0, metadata=None):
|
||||
await asyncio.Event().wait()
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
adapter.send = failing_send
|
||||
adapter._keep_typing = hold_typing
|
||||
|
||||
event = _make_event("-1001", "17585")
|
||||
await adapter._process_message_background(event, build_session_key(event.source))
|
||||
|
||||
assert adapter.processing_hooks == [
|
||||
("start", "1"),
|
||||
("complete", "1", False),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_background_marks_exception_unsuccessful(self):
|
||||
adapter = DummyTelegramAdapter()
|
||||
|
||||
async def handler(_event):
|
||||
await asyncio.sleep(0)
|
||||
raise RuntimeError("boom")
|
||||
|
||||
async def hold_typing(_chat_id, interval=2.0, metadata=None):
|
||||
await asyncio.Event().wait()
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
adapter._keep_typing = hold_typing
|
||||
|
||||
event = _make_event("-1001", "17585")
|
||||
await adapter._process_message_background(event, build_session_key(event.source))
|
||||
|
||||
assert adapter.processing_hooks == [
|
||||
("start", "1"),
|
||||
("complete", "1", False),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_background_marks_cancellation_unsuccessful(self):
|
||||
adapter = DummyTelegramAdapter()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def handler(_event):
|
||||
await release.wait()
|
||||
return "ack"
|
||||
|
||||
async def hold_typing(_chat_id, interval=2.0, metadata=None):
|
||||
await asyncio.Event().wait()
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
adapter._keep_typing = hold_typing
|
||||
|
||||
event = _make_event("-1001", "17585")
|
||||
task = asyncio.create_task(adapter._process_message_background(event, build_session_key(event.source)))
|
||||
await asyncio.sleep(0)
|
||||
task.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert adapter.processing_hooks == [
|
||||
("start", "1"),
|
||||
("complete", "1", False),
|
||||
]
|
||||
|
||||
170
tests/gateway/test_discord_reactions.py
Normal file
170
tests/gateway/test_discord_reactions.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Tests for Discord message reactions tied to processing lifecycle hooks."""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType, SendResult
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
|
||||
|
||||
def _ensure_discord_mock():
|
||||
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
|
||||
return
|
||||
|
||||
discord_mod = MagicMock()
|
||||
discord_mod.Intents.default.return_value = MagicMock()
|
||||
discord_mod.DMChannel = type("DMChannel", (), {})
|
||||
discord_mod.Thread = type("Thread", (), {})
|
||||
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||
discord_mod.Interaction = object
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
choices=lambda **kwargs: (lambda fn: fn),
|
||||
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
|
||||
)
|
||||
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
commands_mod.Bot = MagicMock
|
||||
ext_mod.commands = commands_mod
|
||||
|
||||
sys.modules.setdefault("discord", discord_mod)
|
||||
sys.modules.setdefault("discord.ext", ext_mod)
|
||||
sys.modules.setdefault("discord.ext.commands", commands_mod)
|
||||
|
||||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
class FakeTree:
|
||||
def __init__(self):
|
||||
self.commands = {}
|
||||
|
||||
def command(self, *, name, description):
|
||||
def decorator(fn):
|
||||
self.commands[name] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter():
|
||||
config = PlatformConfig(enabled=True, token="***")
|
||||
adapter = DiscordAdapter(config)
|
||||
adapter._client = SimpleNamespace(
|
||||
tree=FakeTree(),
|
||||
get_channel=lambda _id: None,
|
||||
fetch_channel=AsyncMock(),
|
||||
user=SimpleNamespace(id=99999, name="HermesBot"),
|
||||
)
|
||||
return adapter
|
||||
|
||||
|
||||
def _make_event(message_id: str, raw_message) -> MessageEvent:
|
||||
return MessageEvent(
|
||||
text="hello",
|
||||
message_type=MessageType.TEXT,
|
||||
source=SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="123",
|
||||
chat_type="dm",
|
||||
user_id="42",
|
||||
user_name="Jezza",
|
||||
),
|
||||
raw_message=raw_message,
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_background_adds_and_swaps_reactions(adapter):
|
||||
raw_message = SimpleNamespace(
|
||||
add_reaction=AsyncMock(),
|
||||
remove_reaction=AsyncMock(),
|
||||
)
|
||||
|
||||
async def handler(_event):
|
||||
await asyncio.sleep(0)
|
||||
return "ack"
|
||||
|
||||
async def hold_typing(_chat_id, interval=2.0, metadata=None):
|
||||
await asyncio.Event().wait()
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="999"))
|
||||
adapter._keep_typing = hold_typing
|
||||
|
||||
event = _make_event("1", raw_message)
|
||||
await adapter._process_message_background(event, build_session_key(event.source))
|
||||
|
||||
assert raw_message.add_reaction.await_args_list[0].args == ("👀",)
|
||||
assert raw_message.remove_reaction.await_args_list[0].args == ("👀", adapter._client.user)
|
||||
assert raw_message.add_reaction.await_args_list[1].args == ("✅",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interaction_backed_events_do_not_attempt_reactions(adapter):
|
||||
interaction = SimpleNamespace(guild_id=123456789)
|
||||
|
||||
async def handler(_event):
|
||||
await asyncio.sleep(0)
|
||||
return None
|
||||
|
||||
async def hold_typing(_chat_id, interval=2.0, metadata=None):
|
||||
await asyncio.Event().wait()
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
adapter._add_reaction = AsyncMock()
|
||||
adapter._remove_reaction = AsyncMock()
|
||||
adapter._keep_typing = hold_typing
|
||||
|
||||
event = MessageEvent(
|
||||
text="/status",
|
||||
message_type=MessageType.COMMAND,
|
||||
source=SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="123",
|
||||
chat_type="dm",
|
||||
user_id="42",
|
||||
user_name="Jezza",
|
||||
),
|
||||
raw_message=interaction,
|
||||
message_id="2",
|
||||
)
|
||||
|
||||
await adapter._process_message_background(event, build_session_key(event.source))
|
||||
|
||||
adapter._add_reaction.assert_not_awaited()
|
||||
adapter._remove_reaction.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reaction_helper_failures_do_not_break_message_flow(adapter):
|
||||
raw_message = SimpleNamespace(
|
||||
add_reaction=AsyncMock(side_effect=[RuntimeError("no perms"), RuntimeError("no perms")]),
|
||||
remove_reaction=AsyncMock(side_effect=RuntimeError("no perms")),
|
||||
)
|
||||
|
||||
async def handler(_event):
|
||||
await asyncio.sleep(0)
|
||||
return "ack"
|
||||
|
||||
async def hold_typing(_chat_id, interval=2.0, metadata=None):
|
||||
await asyncio.Event().wait()
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="999"))
|
||||
adapter._keep_typing = hold_typing
|
||||
|
||||
event = _make_event("3", raw_message)
|
||||
await adapter._process_message_background(event, build_session_key(event.source))
|
||||
|
||||
adapter.send.assert_awaited_once()
|
||||
340
tests/gateway/test_matrix_voice.py
Normal file
340
tests/gateway/test_matrix_voice.py
Normal file
@@ -0,0 +1,340 @@
|
||||
"""Tests for Matrix voice message support (MSC3245)."""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
nio = pytest.importorskip("nio", reason="matrix-nio not installed")
|
||||
|
||||
from gateway.platforms.base import MessageType
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapter helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_adapter():
|
||||
"""Create a MatrixAdapter with mocked config."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
token="***",
|
||||
extra={
|
||||
"homeserver": "https://matrix.example.org",
|
||||
"user_id": "@bot:example.org",
|
||||
},
|
||||
)
|
||||
adapter = MatrixAdapter(config)
|
||||
return adapter
|
||||
|
||||
|
||||
def _make_room(room_id: str = "!test:example.org", member_count: int = 2):
|
||||
"""Create a mock Matrix room."""
|
||||
room = MagicMock()
|
||||
room.room_id = room_id
|
||||
room.member_count = member_count
|
||||
return room
|
||||
|
||||
|
||||
def _make_audio_event(
|
||||
event_id: str = "$audio_event",
|
||||
sender: str = "@alice:example.org",
|
||||
body: str = "Voice message",
|
||||
url: str = "mxc://example.org/abc123",
|
||||
is_voice: bool = False,
|
||||
mimetype: str = "audio/ogg",
|
||||
timestamp: float = 9999999999000, # ms
|
||||
):
|
||||
"""
|
||||
Create a mock RoomMessageAudio event that passes isinstance checks.
|
||||
|
||||
Args:
|
||||
is_voice: If True, adds org.matrix.msc3245.voice field to content
|
||||
"""
|
||||
import nio
|
||||
|
||||
# Build the source dict that nio events expose via .source
|
||||
content = {
|
||||
"msgtype": "m.audio",
|
||||
"body": body,
|
||||
"url": url,
|
||||
"info": {
|
||||
"mimetype": mimetype,
|
||||
},
|
||||
}
|
||||
|
||||
if is_voice:
|
||||
content["org.matrix.msc3245.voice"] = {}
|
||||
|
||||
# Create a real nio RoomMessageAudio-like object
|
||||
# We use MagicMock but configure __class__ to pass isinstance check
|
||||
event = MagicMock(spec=nio.RoomMessageAudio)
|
||||
event.event_id = event_id
|
||||
event.sender = sender
|
||||
event.body = body
|
||||
event.url = url
|
||||
event.server_timestamp = timestamp
|
||||
event.source = {
|
||||
"type": "m.room.message",
|
||||
"content": content,
|
||||
}
|
||||
# For MIME type extraction - needs to be a dict
|
||||
event.content = content
|
||||
|
||||
return event
|
||||
|
||||
|
||||
def _make_download_response(body: bytes = b"fake audio data"):
|
||||
"""Create a mock nio.MemoryDownloadResponse."""
|
||||
import nio
|
||||
resp = MagicMock()
|
||||
resp.body = body
|
||||
resp.__class__ = nio.MemoryDownloadResponse
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: MSC3245 Voice Detection (RED -> GREEN)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatrixVoiceMessageDetection:
|
||||
"""Test that MSC3245 voice messages are detected and tagged correctly."""
|
||||
|
||||
def setup_method(self):
|
||||
self.adapter = _make_adapter()
|
||||
self.adapter._user_id = "@bot:example.org"
|
||||
self.adapter._startup_ts = 0.0
|
||||
self.adapter._dm_rooms = {}
|
||||
self.adapter._message_handler = AsyncMock()
|
||||
# Mock _mxc_to_http to return a fake HTTP URL
|
||||
self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}"
|
||||
# Mock client for authenticated download
|
||||
self.adapter._client = MagicMock()
|
||||
self.adapter._client.download = AsyncMock(return_value=_make_download_response())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_message_has_type_voice(self):
|
||||
"""Voice messages (with MSC3245 field) should be MessageType.VOICE."""
|
||||
room = _make_room()
|
||||
event = _make_audio_event(is_voice=True)
|
||||
|
||||
# Capture the MessageEvent passed to handle_message
|
||||
captured_event = None
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._on_room_message_media(room, event)
|
||||
|
||||
assert captured_event is not None, "No event was captured"
|
||||
assert captured_event.message_type == MessageType.VOICE, \
|
||||
f"Expected MessageType.VOICE, got {captured_event.message_type}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_message_has_local_path(self):
|
||||
"""Voice messages should have a local cached path in media_urls."""
|
||||
room = _make_room()
|
||||
event = _make_audio_event(is_voice=True)
|
||||
|
||||
captured_event = None
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._on_room_message_media(room, event)
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.media_urls is not None
|
||||
assert len(captured_event.media_urls) > 0
|
||||
# Should be a local path, not an HTTP URL
|
||||
assert not captured_event.media_urls[0].startswith("http"), \
|
||||
f"media_urls should contain local path, got {captured_event.media_urls[0]}"
|
||||
self.adapter._client.download.assert_awaited_once_with(mxc=event.url)
|
||||
assert captured_event.media_types == ["audio/ogg"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audio_without_msc3245_stays_audio_type(self):
|
||||
"""Regular audio uploads (no MSC3245 field) should remain MessageType.AUDIO."""
|
||||
room = _make_room()
|
||||
event = _make_audio_event(is_voice=False) # NOT a voice message
|
||||
|
||||
captured_event = None
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._on_room_message_media(room, event)
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.message_type == MessageType.AUDIO, \
|
||||
f"Expected MessageType.AUDIO for non-voice, got {captured_event.message_type}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_audio_has_http_url(self):
|
||||
"""Regular audio uploads should keep HTTP URL (not cached locally)."""
|
||||
room = _make_room()
|
||||
event = _make_audio_event(is_voice=False)
|
||||
|
||||
captured_event = None
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._on_room_message_media(room, event)
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.media_urls is not None
|
||||
# Should be HTTP URL, not local path
|
||||
assert captured_event.media_urls[0].startswith("http"), \
|
||||
f"Non-voice audio should have HTTP URL, got {captured_event.media_urls[0]}"
|
||||
self.adapter._client.download.assert_not_awaited()
|
||||
assert captured_event.media_types == ["audio/ogg"]
|
||||
|
||||
|
||||
class TestMatrixVoiceCacheFallback:
|
||||
"""Test graceful fallback when voice caching fails."""
|
||||
|
||||
def setup_method(self):
|
||||
self.adapter = _make_adapter()
|
||||
self.adapter._user_id = "@bot:example.org"
|
||||
self.adapter._startup_ts = 0.0
|
||||
self.adapter._dm_rooms = {}
|
||||
self.adapter._message_handler = AsyncMock()
|
||||
self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}"
|
||||
self.adapter._client = MagicMock()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_cache_failure_falls_back_to_http_url(self):
|
||||
"""If caching fails, voice message should still be delivered with HTTP URL."""
|
||||
room = _make_room()
|
||||
event = _make_audio_event(is_voice=True)
|
||||
|
||||
# Make download fail
|
||||
import nio
|
||||
error_resp = MagicMock()
|
||||
error_resp.__class__ = nio.DownloadError
|
||||
self.adapter._client.download = AsyncMock(return_value=error_resp)
|
||||
|
||||
captured_event = None
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._on_room_message_media(room, event)
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.media_urls is not None
|
||||
# Should fall back to HTTP URL
|
||||
assert captured_event.media_urls[0].startswith("http"), \
|
||||
f"Should fall back to HTTP URL on cache failure, got {captured_event.media_urls[0]}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_cache_exception_falls_back_to_http_url(self):
|
||||
"""Unexpected download exceptions should also fall back to HTTP URL."""
|
||||
room = _make_room()
|
||||
event = _make_audio_event(is_voice=True)
|
||||
|
||||
self.adapter._client.download = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
captured_event = None
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._on_room_message_media(room, event)
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.media_urls is not None
|
||||
assert captured_event.media_urls[0].startswith("http"), \
|
||||
f"Should fall back to HTTP URL on exception, got {captured_event.media_urls[0]}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: send_voice includes MSC3245 field (RED -> GREEN)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatrixSendVoiceMSC3245:
|
||||
"""Test that send_voice includes MSC3245 field for native voice rendering."""
|
||||
|
||||
def setup_method(self):
|
||||
self.adapter = _make_adapter()
|
||||
self.adapter._user_id = "@bot:example.org"
|
||||
# Mock client with successful upload
|
||||
self.adapter._client = MagicMock()
|
||||
self.upload_call = None
|
||||
|
||||
async def mock_upload(*args, **kwargs):
|
||||
self.upload_call = (args, kwargs)
|
||||
import nio
|
||||
resp = MagicMock()
|
||||
resp.content_uri = "mxc://example.org/uploaded"
|
||||
resp.__class__ = nio.UploadResponse
|
||||
return resp, None
|
||||
|
||||
self.adapter._client.upload = mock_upload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_voice_includes_msc3245_field(self):
|
||||
"""send_voice should include org.matrix.msc3245.voice in message content."""
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# Create a temp audio file
|
||||
with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
|
||||
f.write(b"fake audio data")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
# Capture the message content sent to room_send
|
||||
sent_content = None
|
||||
|
||||
async def mock_room_send(room_id, event_type, content):
|
||||
nonlocal sent_content
|
||||
sent_content = content
|
||||
resp = MagicMock()
|
||||
resp.event_id = "$sent_event"
|
||||
import nio
|
||||
resp.__class__ = nio.RoomSendResponse
|
||||
return resp
|
||||
|
||||
self.adapter._client.room_send = mock_room_send
|
||||
|
||||
await self.adapter.send_voice(
|
||||
chat_id="!room:example.org",
|
||||
audio_path=temp_path,
|
||||
caption="Test voice",
|
||||
)
|
||||
|
||||
assert sent_content is not None, "No message was sent"
|
||||
assert "org.matrix.msc3245.voice" in sent_content, \
|
||||
f"MSC3245 voice field missing from content: {sent_content.keys()}"
|
||||
assert sent_content["msgtype"] == "m.audio"
|
||||
assert sent_content["info"]["mimetype"] == "audio/ogg"
|
||||
assert self.upload_call is not None, "Expected upload() to be called"
|
||||
args, kwargs = self.upload_call
|
||||
assert isinstance(args[0], io.BytesIO)
|
||||
assert kwargs["content_type"] == "audio/ogg"
|
||||
assert kwargs["filename"].endswith(".ogg")
|
||||
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
@@ -212,6 +212,49 @@ class TestSessionHygieneWarnThreshold:
|
||||
assert post_compress_tokens < warn_threshold
|
||||
|
||||
|
||||
class TestCompressionWarnRateLimit:
|
||||
"""Compression warning messages must be rate-limited per chat_id."""
|
||||
|
||||
def _make_runner(self):
|
||||
from unittest.mock import MagicMock, patch
|
||||
with patch("gateway.run.load_gateway_config"), \
|
||||
patch("gateway.run.SessionStore"), \
|
||||
patch("gateway.run.DeliveryRouter"):
|
||||
from gateway.run import GatewayRunner
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._compression_warn_sent = {}
|
||||
runner._compression_warn_cooldown = 3600
|
||||
return runner
|
||||
|
||||
def test_first_warn_is_sent(self):
|
||||
runner = self._make_runner()
|
||||
now = 1_000_000.0
|
||||
last = runner._compression_warn_sent.get("chat:1", 0)
|
||||
assert now - last >= runner._compression_warn_cooldown
|
||||
|
||||
def test_second_warn_suppressed_within_cooldown(self):
|
||||
runner = self._make_runner()
|
||||
now = 1_000_000.0
|
||||
runner._compression_warn_sent["chat:1"] = now - 60 # 1 minute ago
|
||||
last = runner._compression_warn_sent.get("chat:1", 0)
|
||||
assert now - last < runner._compression_warn_cooldown
|
||||
|
||||
def test_warn_allowed_after_cooldown(self):
|
||||
runner = self._make_runner()
|
||||
now = 1_000_000.0
|
||||
runner._compression_warn_sent["chat:1"] = now - 3601 # just past cooldown
|
||||
last = runner._compression_warn_sent.get("chat:1", 0)
|
||||
assert now - last >= runner._compression_warn_cooldown
|
||||
|
||||
def test_rate_limit_is_per_chat(self):
|
||||
"""Rate-limiting one chat must not suppress warnings for another."""
|
||||
runner = self._make_runner()
|
||||
now = 1_000_000.0
|
||||
runner._compression_warn_sent["chat:1"] = now - 60 # suppressed
|
||||
last_other = runner._compression_warn_sent.get("chat:2", 0)
|
||||
assert now - last_other >= runner._compression_warn_cooldown
|
||||
|
||||
|
||||
class TestEstimatedTokenThreshold:
|
||||
"""Verify that hygiene thresholds are always below the model's context
|
||||
limit — for both actual and estimated token counts.
|
||||
|
||||
@@ -126,9 +126,20 @@ class TestAppMentionHandler:
|
||||
"user": "testbot",
|
||||
})
|
||||
|
||||
# Mock AsyncWebClient so multi-workspace auth_test is awaitable
|
||||
mock_web_client = AsyncMock()
|
||||
mock_web_client.auth_test = AsyncMock(return_value={
|
||||
"user_id": "U_BOT",
|
||||
"user": "testbot",
|
||||
"team_id": "T_FAKE",
|
||||
"team": "FakeTeam",
|
||||
})
|
||||
|
||||
with patch.object(_slack_mod, "AsyncApp", return_value=mock_app), \
|
||||
patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), \
|
||||
patch.object(_slack_mod, "AsyncSocketModeHandler", return_value=MagicMock()), \
|
||||
patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), \
|
||||
patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \
|
||||
patch("asyncio.create_task"):
|
||||
asyncio.run(adapter.connect())
|
||||
|
||||
|
||||
110
tests/gateway/test_telegram_group_gating.py
Normal file
110
tests/gateway/test_telegram_group_gating.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from gateway.config import Platform, PlatformConfig, load_gateway_config
|
||||
|
||||
|
||||
def _make_adapter(require_mention=None, free_response_chats=None, mention_patterns=None):
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
|
||||
extra = {}
|
||||
if require_mention is not None:
|
||||
extra["require_mention"] = require_mention
|
||||
if free_response_chats is not None:
|
||||
extra["free_response_chats"] = free_response_chats
|
||||
if mention_patterns is not None:
|
||||
extra["mention_patterns"] = mention_patterns
|
||||
|
||||
adapter = object.__new__(TelegramAdapter)
|
||||
adapter.platform = Platform.TELEGRAM
|
||||
adapter.config = PlatformConfig(enabled=True, token="***", extra=extra)
|
||||
adapter._bot = SimpleNamespace(id=999, username="hermes_bot")
|
||||
adapter._message_handler = AsyncMock()
|
||||
adapter._pending_text_batches = {}
|
||||
adapter._pending_text_batch_tasks = {}
|
||||
adapter._text_batch_delay_seconds = 0.01
|
||||
adapter._mention_patterns = adapter._compile_mention_patterns()
|
||||
return adapter
|
||||
|
||||
|
||||
def _group_message(text="hello", *, chat_id=-100, reply_to_bot=False, entities=None, caption=None, caption_entities=None):
|
||||
reply_to_message = None
|
||||
if reply_to_bot:
|
||||
reply_to_message = SimpleNamespace(from_user=SimpleNamespace(id=999))
|
||||
return SimpleNamespace(
|
||||
text=text,
|
||||
caption=caption,
|
||||
entities=entities or [],
|
||||
caption_entities=caption_entities or [],
|
||||
chat=SimpleNamespace(id=chat_id, type="group"),
|
||||
reply_to_message=reply_to_message,
|
||||
)
|
||||
|
||||
|
||||
def _mention_entity(text, mention="@hermes_bot"):
|
||||
offset = text.index(mention)
|
||||
return SimpleNamespace(type="mention", offset=offset, length=len(mention))
|
||||
|
||||
|
||||
def test_group_messages_can_be_opened_via_config():
|
||||
adapter = _make_adapter(require_mention=False)
|
||||
|
||||
assert adapter._should_process_message(_group_message("hello everyone")) is True
|
||||
|
||||
|
||||
def test_group_messages_can_require_direct_trigger_via_config():
|
||||
adapter = _make_adapter(require_mention=True)
|
||||
|
||||
assert adapter._should_process_message(_group_message("hello everyone")) is False
|
||||
assert adapter._should_process_message(_group_message("hi @hermes_bot", entities=[_mention_entity("hi @hermes_bot")])) is True
|
||||
assert adapter._should_process_message(_group_message("replying", reply_to_bot=True)) is True
|
||||
assert adapter._should_process_message(_group_message("/status"), is_command=True) is True
|
||||
|
||||
|
||||
def test_free_response_chats_bypass_mention_requirement():
|
||||
adapter = _make_adapter(require_mention=True, free_response_chats=["-200"])
|
||||
|
||||
assert adapter._should_process_message(_group_message("hello everyone", chat_id=-200)) is True
|
||||
assert adapter._should_process_message(_group_message("hello everyone", chat_id=-201)) is False
|
||||
|
||||
|
||||
def test_regex_mention_patterns_allow_custom_wake_words():
|
||||
adapter = _make_adapter(require_mention=True, mention_patterns=[r"^\s*chompy\b"])
|
||||
|
||||
assert adapter._should_process_message(_group_message("chompy status")) is True
|
||||
assert adapter._should_process_message(_group_message(" chompy help")) is True
|
||||
assert adapter._should_process_message(_group_message("hey chompy")) is False
|
||||
|
||||
|
||||
def test_invalid_regex_patterns_are_ignored():
|
||||
adapter = _make_adapter(require_mention=True, mention_patterns=[r"(", r"^\s*chompy\b"])
|
||||
|
||||
assert adapter._should_process_message(_group_message("chompy status")) is True
|
||||
assert adapter._should_process_message(_group_message("hello everyone")) is False
|
||||
|
||||
|
||||
def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
"telegram:\n"
|
||||
" require_mention: true\n"
|
||||
" mention_patterns:\n"
|
||||
" - \"^\\\\s*chompy\\\\b\"\n"
|
||||
" free_response_chats:\n"
|
||||
" - \"-123\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("TELEGRAM_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("TELEGRAM_MENTION_PATTERNS", raising=False)
|
||||
monkeypatch.delenv("TELEGRAM_FREE_RESPONSE_CHATS", raising=False)
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config is not None
|
||||
assert __import__("os").environ["TELEGRAM_REQUIRE_MENTION"] == "true"
|
||||
assert json.loads(__import__("os").environ["TELEGRAM_MENTION_PATTERNS"]) == [r"^\s*chompy\b"]
|
||||
assert __import__("os").environ["TELEGRAM_FREE_RESPONSE_CHATS"] == "-123"
|
||||
@@ -60,6 +60,7 @@ def _make_runner(platform: Platform, config: GatewayConfig):
|
||||
runner.adapters = {platform: adapter}
|
||||
runner.pairing_store = MagicMock()
|
||||
runner.pairing_store.is_approved.return_value = False
|
||||
runner.pairing_store._is_rate_limited.return_value = False
|
||||
return runner, adapter
|
||||
|
||||
|
||||
@@ -142,6 +143,56 @@ async def test_unauthorized_whatsapp_dm_can_be_ignored(monkeypatch):
|
||||
adapter.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limited_user_gets_no_response(monkeypatch):
|
||||
"""When a user is already rate-limited, pairing messages are silently ignored."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)},
|
||||
)
|
||||
runner, adapter = _make_runner(Platform.WHATSAPP, config)
|
||||
runner.pairing_store._is_rate_limited.return_value = True
|
||||
|
||||
result = await runner._handle_message(
|
||||
_make_event(
|
||||
Platform.WHATSAPP,
|
||||
"15551234567@s.whatsapp.net",
|
||||
"15551234567@s.whatsapp.net",
|
||||
)
|
||||
)
|
||||
|
||||
assert result is None
|
||||
runner.pairing_store.generate_code.assert_not_called()
|
||||
adapter.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejection_message_records_rate_limit(monkeypatch):
|
||||
"""After sending a 'too many requests' rejection, rate limit is recorded
|
||||
so subsequent messages are silently ignored."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)},
|
||||
)
|
||||
runner, adapter = _make_runner(Platform.WHATSAPP, config)
|
||||
runner.pairing_store.generate_code.return_value = None # triggers rejection
|
||||
|
||||
result = await runner._handle_message(
|
||||
_make_event(
|
||||
Platform.WHATSAPP,
|
||||
"15551234567@s.whatsapp.net",
|
||||
"15551234567@s.whatsapp.net",
|
||||
)
|
||||
)
|
||||
|
||||
assert result is None
|
||||
adapter.send.assert_awaited_once()
|
||||
assert "Too many" in adapter.send.await_args.args[1]
|
||||
runner.pairing_store._record_rate_limit.assert_called_once_with(
|
||||
"whatsapp", "15551234567@s.whatsapp.net"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_ignore_suppresses_pairing_reply(monkeypatch):
|
||||
_clear_auth_env(monkeypatch)
|
||||
|
||||
42
tests/hermes_cli/test_launcher.py
Normal file
42
tests/hermes_cli/test_launcher.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Tests for the top-level `./hermes` launcher script."""
|
||||
|
||||
import runpy
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_launcher_delegates_to_argparse_entrypoint(monkeypatch):
|
||||
"""`./hermes` should use `hermes_cli.main`, not the legacy Fire wrapper."""
|
||||
launcher_path = Path(__file__).resolve().parents[2] / "hermes"
|
||||
called = []
|
||||
|
||||
fake_main_module = types.ModuleType("hermes_cli.main")
|
||||
|
||||
def fake_main():
|
||||
called.append("hermes_cli.main")
|
||||
|
||||
fake_main_module.main = fake_main
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.main", fake_main_module)
|
||||
|
||||
fake_cli_module = types.ModuleType("cli")
|
||||
|
||||
def legacy_cli_main(*args, **kwargs):
|
||||
raise AssertionError("launcher should not import cli.main")
|
||||
|
||||
fake_cli_module.main = legacy_cli_main
|
||||
monkeypatch.setitem(sys.modules, "cli", fake_cli_module)
|
||||
|
||||
fake_fire_module = types.ModuleType("fire")
|
||||
|
||||
def legacy_fire(*args, **kwargs):
|
||||
raise AssertionError("launcher should not invoke fire.Fire")
|
||||
|
||||
fake_fire_module.Fire = legacy_fire
|
||||
monkeypatch.setitem(sys.modules, "fire", fake_fire_module)
|
||||
|
||||
monkeypatch.setattr(sys, "argv", [str(launcher_path), "gateway", "status"])
|
||||
|
||||
runpy.run_path(str(launcher_path), run_name="__main__")
|
||||
|
||||
assert called == ["hermes_cli.main"]
|
||||
@@ -42,3 +42,55 @@ def test_get_nous_subscription_features_prefers_managed_modal_in_auto_mode(monke
|
||||
assert features.modal.active is True
|
||||
assert features.modal.managed_by_nous is True
|
||||
assert features.modal.direct_override is False
|
||||
|
||||
|
||||
def test_get_nous_subscription_features_prefers_camofox_over_managed_browserbase(monkeypatch):
|
||||
env = {"CAMOFOX_URL": "http://localhost:9377"}
|
||||
|
||||
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
|
||||
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
|
||||
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
|
||||
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
|
||||
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
ns,
|
||||
"is_managed_tool_gateway_ready",
|
||||
lambda vendor: vendor == "browserbase",
|
||||
)
|
||||
|
||||
features = ns.get_nous_subscription_features(
|
||||
{"browser": {"cloud_provider": "browserbase"}}
|
||||
)
|
||||
|
||||
assert features.browser.available is True
|
||||
assert features.browser.active is True
|
||||
assert features.browser.managed_by_nous is False
|
||||
assert features.browser.direct_override is True
|
||||
assert features.browser.current_provider == "Camofox"
|
||||
|
||||
|
||||
def test_get_nous_subscription_features_requires_agent_browser_for_browserbase(monkeypatch):
|
||||
env = {
|
||||
"BROWSERBASE_API_KEY": "bb-key",
|
||||
"BROWSERBASE_PROJECT_ID": "bb-project",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
|
||||
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: False)
|
||||
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
|
||||
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: False)
|
||||
|
||||
features = ns.get_nous_subscription_features(
|
||||
{"browser": {"cloud_provider": "browserbase"}}
|
||||
)
|
||||
|
||||
assert features.browser.available is False
|
||||
assert features.browser.active is False
|
||||
assert features.browser.managed_by_nous is False
|
||||
assert features.browser.current_provider == "Browserbase"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from hermes_cli.config import load_config, save_config, save_env_value
|
||||
from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures
|
||||
from hermes_cli.setup import _print_setup_summary, setup_model_provider
|
||||
|
||||
|
||||
@@ -471,3 +472,58 @@ def test_setup_summary_marks_anthropic_auth_as_vision_available(tmp_path, monkey
|
||||
|
||||
assert "Vision (image analysis)" in output
|
||||
assert "missing run 'hermes setup' to configure" not in output
|
||||
|
||||
|
||||
def test_setup_summary_shows_camofox_when_browser_feature_is_camofox(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_clear_provider_env(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.setup.get_nous_subscription_features",
|
||||
lambda config: NousSubscriptionFeatures(
|
||||
subscribed=False,
|
||||
nous_auth_present=False,
|
||||
provider_is_nous=False,
|
||||
features={
|
||||
"web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""),
|
||||
"image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""),
|
||||
"tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""),
|
||||
"browser": NousFeatureState("browser", "Browser automation", True, True, True, False, True, True, "Camofox"),
|
||||
"modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, "local"),
|
||||
},
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
|
||||
|
||||
_print_setup_summary(load_config(), tmp_path)
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "Browser Automation (Camofox)" in output
|
||||
|
||||
|
||||
def test_setup_summary_does_not_mark_incomplete_browserbase_as_available(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_clear_provider_env(monkeypatch)
|
||||
monkeypatch.setenv("BROWSERBASE_API_KEY", "bb-key")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.setup.get_nous_subscription_features",
|
||||
lambda config: NousSubscriptionFeatures(
|
||||
subscribed=False,
|
||||
nous_auth_present=False,
|
||||
provider_is_nous=False,
|
||||
features={
|
||||
"web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""),
|
||||
"image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""),
|
||||
"tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""),
|
||||
"browser": NousFeatureState("browser", "Browser automation", True, False, False, False, False, True, "Browserbase"),
|
||||
"modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, "local"),
|
||||
},
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
|
||||
|
||||
_print_setup_summary(load_config(), tmp_path)
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "Browser Automation (Browserbase)" not in output
|
||||
assert "Browser Automation" in output
|
||||
assert "BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID" in output
|
||||
|
||||
44
tests/hermes_cli/test_subprocess_timeouts.py
Normal file
44
tests/hermes_cli/test_subprocess_timeouts.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Tests for subprocess.run() timeout coverage in CLI utilities."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# Parameterise over every CLI module that calls subprocess.run
|
||||
_CLI_MODULES = [
|
||||
"hermes_cli/doctor.py",
|
||||
"hermes_cli/status.py",
|
||||
"hermes_cli/clipboard.py",
|
||||
"hermes_cli/banner.py",
|
||||
]
|
||||
|
||||
|
||||
def _subprocess_run_calls(filepath: str) -> list[dict]:
|
||||
"""Parse a Python file and return info about subprocess.run() calls."""
|
||||
source = Path(filepath).read_text()
|
||||
tree = ast.parse(source, filename=filepath)
|
||||
calls = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if (isinstance(func, ast.Attribute) and func.attr == "run"
|
||||
and isinstance(func.value, ast.Name)
|
||||
and func.value.id == "subprocess"):
|
||||
has_timeout = any(kw.arg == "timeout" for kw in node.keywords)
|
||||
calls.append({"line": node.lineno, "has_timeout": has_timeout})
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filepath", _CLI_MODULES)
|
||||
def test_all_subprocess_run_calls_have_timeout(filepath):
|
||||
"""Every subprocess.run() call in CLI modules must specify a timeout."""
|
||||
if not Path(filepath).exists():
|
||||
pytest.skip(f"{filepath} not found")
|
||||
calls = _subprocess_run_calls(filepath)
|
||||
missing = [c for c in calls if not c["has_timeout"]]
|
||||
assert not missing, (
|
||||
f"{filepath} has subprocess.run() without timeout at "
|
||||
f"line(s): {[c['line'] for c in missing]}"
|
||||
)
|
||||
190
tests/honcho_integration/test_config_isolation.py
Normal file
190
tests/honcho_integration/test_config_isolation.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Tests for Honcho config profile isolation.
|
||||
|
||||
Verifies that each Hermes profile writes to its own instance-local
|
||||
honcho.json ($HERMES_HOME/honcho.json) rather than the shared global
|
||||
~/.honcho/config.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from honcho_integration.cli import (
|
||||
_config_path,
|
||||
_local_config_path,
|
||||
_read_config,
|
||||
_write_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_home(tmp_path, monkeypatch):
|
||||
"""Create an isolated HERMES_HOME + real home for testing."""
|
||||
hermes_home = tmp_path / "profile_a"
|
||||
hermes_home.mkdir()
|
||||
global_dir = tmp_path / "home" / ".honcho"
|
||||
global_dir.mkdir(parents=True)
|
||||
global_config = global_dir / "config.json"
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path / "home"))
|
||||
# GLOBAL_CONFIG_PATH is a module-level constant cached at import time,
|
||||
# so we must patch it in both the defining module and the importing module.
|
||||
import honcho_integration.client as _client_mod
|
||||
import honcho_integration.cli as _cli_mod
|
||||
monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_config)
|
||||
monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_config)
|
||||
|
||||
return {
|
||||
"hermes_home": hermes_home,
|
||||
"global_config": global_config,
|
||||
"local_config": hermes_home / "honcho.json",
|
||||
}
|
||||
|
||||
|
||||
class TestLocalConfigPath:
|
||||
"""_local_config_path always returns $HERMES_HOME/honcho.json."""
|
||||
|
||||
def test_returns_hermes_home_path(self, isolated_home):
|
||||
assert _local_config_path() == isolated_home["local_config"]
|
||||
|
||||
def test_differs_from_global(self, isolated_home):
|
||||
from honcho_integration.client import GLOBAL_CONFIG_PATH
|
||||
assert _local_config_path() != GLOBAL_CONFIG_PATH
|
||||
|
||||
|
||||
class TestWriteConfigIsolation:
|
||||
"""_write_config defaults to the instance-local path."""
|
||||
|
||||
def test_write_creates_local_file(self, isolated_home):
|
||||
cfg = {"apiKey": "test-key", "hosts": {"hermes": {"enabled": True}}}
|
||||
_write_config(cfg)
|
||||
|
||||
assert isolated_home["local_config"].exists()
|
||||
written = json.loads(isolated_home["local_config"].read_text())
|
||||
assert written["apiKey"] == "test-key"
|
||||
|
||||
def test_write_does_not_touch_global(self, isolated_home):
|
||||
# Pre-populate global config
|
||||
isolated_home["global_config"].write_text(
|
||||
json.dumps({"apiKey": "global-key"})
|
||||
)
|
||||
|
||||
cfg = {"apiKey": "profile-key"}
|
||||
_write_config(cfg)
|
||||
|
||||
# Global should be untouched
|
||||
global_data = json.loads(isolated_home["global_config"].read_text())
|
||||
assert global_data["apiKey"] == "global-key"
|
||||
|
||||
# Local should have the new value
|
||||
local_data = json.loads(isolated_home["local_config"].read_text())
|
||||
assert local_data["apiKey"] == "profile-key"
|
||||
|
||||
def test_explicit_path_override_still_works(self, isolated_home):
|
||||
custom = isolated_home["hermes_home"] / "custom.json"
|
||||
_write_config({"custom": True}, path=custom)
|
||||
assert custom.exists()
|
||||
assert not isolated_home["local_config"].exists()
|
||||
|
||||
|
||||
class TestReadConfigFallback:
|
||||
"""_read_config falls back to global when no local file exists."""
|
||||
|
||||
def test_reads_local_when_exists(self, isolated_home):
|
||||
isolated_home["local_config"].write_text(
|
||||
json.dumps({"source": "local"})
|
||||
)
|
||||
cfg = _read_config()
|
||||
assert cfg["source"] == "local"
|
||||
|
||||
def test_falls_back_to_global(self, isolated_home):
|
||||
isolated_home["global_config"].write_text(
|
||||
json.dumps({"source": "global"})
|
||||
)
|
||||
# No local file exists
|
||||
assert not isolated_home["local_config"].exists()
|
||||
cfg = _read_config()
|
||||
assert cfg["source"] == "global"
|
||||
|
||||
def test_local_takes_priority_over_global(self, isolated_home):
|
||||
isolated_home["local_config"].write_text(
|
||||
json.dumps({"source": "local"})
|
||||
)
|
||||
isolated_home["global_config"].write_text(
|
||||
json.dumps({"source": "global"})
|
||||
)
|
||||
cfg = _read_config()
|
||||
assert cfg["source"] == "local"
|
||||
|
||||
|
||||
class TestMultiProfileIsolation:
|
||||
"""Two profiles writing config don't interfere with each other."""
|
||||
|
||||
def test_two_profiles_get_separate_configs(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
|
||||
|
||||
profile_a = tmp_path / "profile_a"
|
||||
profile_b = tmp_path / "profile_b"
|
||||
profile_a.mkdir()
|
||||
profile_b.mkdir()
|
||||
|
||||
# Profile A writes its config
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_a))
|
||||
_write_config({"apiKey": "key-a", "hosts": {"hermes": {"peerName": "alice"}}})
|
||||
|
||||
# Profile B writes its config
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_b))
|
||||
_write_config({"apiKey": "key-b", "hosts": {"hermes": {"peerName": "bob"}}})
|
||||
|
||||
# Verify isolation
|
||||
a_data = json.loads((profile_a / "honcho.json").read_text())
|
||||
b_data = json.loads((profile_b / "honcho.json").read_text())
|
||||
|
||||
assert a_data["hosts"]["hermes"]["peerName"] == "alice"
|
||||
assert b_data["hosts"]["hermes"]["peerName"] == "bob"
|
||||
|
||||
def test_first_setup_seeds_from_global(self, tmp_path, monkeypatch):
|
||||
"""First setup reads global config, writes to local."""
|
||||
home = tmp_path / "home"
|
||||
global_dir = home / ".honcho"
|
||||
global_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
|
||||
import honcho_integration.client as _client_mod
|
||||
import honcho_integration.cli as _cli_mod
|
||||
global_cfg_path = global_dir / "config.json"
|
||||
monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_cfg_path)
|
||||
monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_cfg_path)
|
||||
|
||||
# Existing global config
|
||||
global_config = global_dir / "config.json"
|
||||
global_config.write_text(json.dumps({
|
||||
"apiKey": "shared-key",
|
||||
"hosts": {"hermes": {"workspace": "shared-ws"}},
|
||||
}))
|
||||
|
||||
profile = tmp_path / "new_profile"
|
||||
profile.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile))
|
||||
|
||||
# Read seeds from global
|
||||
cfg = _read_config()
|
||||
assert cfg["apiKey"] == "shared-key"
|
||||
|
||||
# Modify and write goes to local
|
||||
cfg["hosts"]["hermes"]["peerName"] = "new-user"
|
||||
_write_config(cfg)
|
||||
|
||||
local_config = profile / "honcho.json"
|
||||
assert local_config.exists()
|
||||
local_data = json.loads(local_config.read_text())
|
||||
assert local_data["hosts"]["hermes"]["peerName"] == "new-user"
|
||||
|
||||
# Global unchanged
|
||||
global_data = json.loads(global_config.read_text())
|
||||
assert "peerName" not in global_data["hosts"]["hermes"]
|
||||
@@ -81,6 +81,19 @@ class TestBuildAnthropicClient:
|
||||
kwargs = mock_sdk.Anthropic.call_args[1]
|
||||
assert kwargs["base_url"] == "https://custom.api.com"
|
||||
|
||||
def test_minimax_anthropic_endpoint_uses_bearer_auth_for_regular_api_keys(self):
|
||||
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
|
||||
build_anthropic_client(
|
||||
"minimax-secret-123",
|
||||
base_url="https://api.minimax.io/anthropic",
|
||||
)
|
||||
kwargs = mock_sdk.Anthropic.call_args[1]
|
||||
assert kwargs["auth_token"] == "minimax-secret-123"
|
||||
assert "api_key" not in kwargs
|
||||
assert kwargs["default_headers"] == {
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
|
||||
}
|
||||
|
||||
|
||||
class TestReadClaudeCodeCredentials:
|
||||
def test_reads_valid_credentials(self, tmp_path, monkeypatch):
|
||||
|
||||
@@ -214,8 +214,9 @@ class TestStatusBarWidthSource:
|
||||
frags = cli_obj._get_status_bar_fragments()
|
||||
|
||||
total_text = "".join(text for _, text in frags)
|
||||
assert len(total_text) <= width + 4, ( # +4 for minor padding chars
|
||||
f"At width={width}, fragment total {len(total_text)} chars overflows "
|
||||
display_width = cli_obj._status_bar_display_width(total_text)
|
||||
assert display_width <= width + 4, ( # +4 for minor padding chars
|
||||
f"At width={width}, fragment total {display_width} cells overflows "
|
||||
f"({total_text!r})"
|
||||
)
|
||||
|
||||
|
||||
@@ -60,34 +60,43 @@ class TestToolsSlashList:
|
||||
|
||||
class TestToolsSlashDisableWithReset:
|
||||
|
||||
def test_disable_confirms_then_resets_session(self):
|
||||
def test_disable_applies_directly_and_resets_session(self):
|
||||
"""Disable applies immediately (no confirmation prompt) and resets session."""
|
||||
cli_obj = _make_cli(["web", "memory"])
|
||||
with patch("hermes_cli.tools_config.load_config",
|
||||
return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \
|
||||
patch("hermes_cli.tools_config.save_config"), \
|
||||
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \
|
||||
patch("hermes_cli.config.load_config", return_value={}), \
|
||||
patch.object(cli_obj, "new_session") as mock_reset, \
|
||||
patch("builtins.input", return_value="y"):
|
||||
patch.object(cli_obj, "new_session") as mock_reset:
|
||||
cli_obj._handle_tools_command("/tools disable web")
|
||||
mock_reset.assert_called_once()
|
||||
assert "web" not in cli_obj.enabled_toolsets
|
||||
|
||||
def test_disable_cancelled_does_not_reset(self):
|
||||
def test_disable_does_not_prompt_for_confirmation(self):
|
||||
"""Disable no longer uses input() — it applies directly."""
|
||||
cli_obj = _make_cli(["web", "memory"])
|
||||
with patch.object(cli_obj, "new_session") as mock_reset, \
|
||||
patch("builtins.input", return_value="n"):
|
||||
with patch("hermes_cli.tools_config.load_config",
|
||||
return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \
|
||||
patch("hermes_cli.tools_config.save_config"), \
|
||||
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \
|
||||
patch("hermes_cli.config.load_config", return_value={}), \
|
||||
patch.object(cli_obj, "new_session"), \
|
||||
patch("builtins.input") as mock_input:
|
||||
cli_obj._handle_tools_command("/tools disable web")
|
||||
mock_reset.assert_not_called()
|
||||
# Toolsets unchanged
|
||||
assert cli_obj.enabled_toolsets == {"web", "memory"}
|
||||
mock_input.assert_not_called()
|
||||
|
||||
def test_disable_eof_cancels(self):
|
||||
def test_disable_always_resets_session(self):
|
||||
"""Even without a confirmation prompt, disable always resets the session."""
|
||||
cli_obj = _make_cli(["web", "memory"])
|
||||
with patch.object(cli_obj, "new_session") as mock_reset, \
|
||||
patch("builtins.input", side_effect=EOFError):
|
||||
with patch("hermes_cli.tools_config.load_config",
|
||||
return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \
|
||||
patch("hermes_cli.tools_config.save_config"), \
|
||||
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \
|
||||
patch("hermes_cli.config.load_config", return_value={}), \
|
||||
patch.object(cli_obj, "new_session") as mock_reset:
|
||||
cli_obj._handle_tools_command("/tools disable web")
|
||||
mock_reset.assert_not_called()
|
||||
mock_reset.assert_called_once()
|
||||
|
||||
def test_disable_missing_name_prints_usage(self, capsys):
|
||||
cli_obj = _make_cli()
|
||||
@@ -101,15 +110,15 @@ class TestToolsSlashDisableWithReset:
|
||||
|
||||
class TestToolsSlashEnableWithReset:
|
||||
|
||||
def test_enable_confirms_then_resets_session(self):
|
||||
def test_enable_applies_directly_and_resets_session(self):
|
||||
"""Enable applies immediately (no confirmation prompt) and resets session."""
|
||||
cli_obj = _make_cli(["memory"])
|
||||
with patch("hermes_cli.tools_config.load_config",
|
||||
return_value={"platform_toolsets": {"cli": ["memory"]}}), \
|
||||
patch("hermes_cli.tools_config.save_config"), \
|
||||
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory", "web"}), \
|
||||
patch("hermes_cli.config.load_config", return_value={}), \
|
||||
patch.object(cli_obj, "new_session") as mock_reset, \
|
||||
patch("builtins.input", return_value="y"):
|
||||
patch.object(cli_obj, "new_session") as mock_reset:
|
||||
cli_obj._handle_tools_command("/tools enable web")
|
||||
mock_reset.assert_called_once()
|
||||
assert "web" in cli_obj.enabled_toolsets
|
||||
|
||||
18
tests/test_project_metadata.py
Normal file
18
tests/test_project_metadata.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Regression tests for packaging metadata in pyproject.toml."""
|
||||
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
|
||||
def _load_optional_dependencies():
|
||||
pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
||||
with pyproject_path.open("rb") as handle:
|
||||
project = tomllib.load(handle)["project"]
|
||||
return project["optional-dependencies"]
|
||||
|
||||
|
||||
def test_all_extra_includes_matrix_dependency():
|
||||
optional_dependencies = _load_optional_dependencies()
|
||||
|
||||
assert "matrix" in optional_dependencies
|
||||
assert "hermes-agent[matrix]" in optional_dependencies["all"]
|
||||
115
tests/test_trajectory_compressor_async.py
Normal file
115
tests/test_trajectory_compressor_async.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Tests for trajectory_compressor AsyncOpenAI event loop binding.
|
||||
|
||||
The AsyncOpenAI client was created once at __init__ time and stored as an
|
||||
instance attribute. When process_directory() calls asyncio.run() — which
|
||||
creates and closes a fresh event loop — the client's internal httpx
|
||||
transport remains bound to the now-closed loop. A second call to
|
||||
process_directory() would fail with "Event loop is closed".
|
||||
|
||||
The fix creates the AsyncOpenAI client lazily via _get_async_client() so
|
||||
each asyncio.run() gets a client bound to the current loop.
|
||||
"""
|
||||
|
||||
import types
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestAsyncClientLazyCreation:
|
||||
"""trajectory_compressor.py — _get_async_client()"""
|
||||
|
||||
def test_async_client_none_after_init(self):
|
||||
"""async_client should be None after __init__ (not eagerly created)."""
|
||||
from trajectory_compressor import TrajectoryCompressor
|
||||
|
||||
comp = TrajectoryCompressor.__new__(TrajectoryCompressor)
|
||||
comp.config = MagicMock()
|
||||
comp.config.base_url = "https://api.example.com/v1"
|
||||
comp.config.api_key_env = "TEST_API_KEY"
|
||||
comp._use_call_llm = False
|
||||
comp.async_client = None
|
||||
comp._async_client_api_key = "test-key"
|
||||
|
||||
assert comp.async_client is None
|
||||
|
||||
def test_get_async_client_creates_new_client(self):
|
||||
"""_get_async_client() should create a fresh AsyncOpenAI instance."""
|
||||
from trajectory_compressor import TrajectoryCompressor
|
||||
|
||||
comp = TrajectoryCompressor.__new__(TrajectoryCompressor)
|
||||
comp.config = MagicMock()
|
||||
comp.config.base_url = "https://api.example.com/v1"
|
||||
comp._async_client_api_key = "test-key"
|
||||
comp.async_client = None
|
||||
|
||||
mock_async_openai = MagicMock()
|
||||
with patch("openai.AsyncOpenAI", mock_async_openai):
|
||||
client = comp._get_async_client()
|
||||
|
||||
mock_async_openai.assert_called_once_with(
|
||||
api_key="test-key",
|
||||
base_url="https://api.example.com/v1",
|
||||
)
|
||||
assert comp.async_client is not None
|
||||
|
||||
def test_get_async_client_creates_fresh_each_call(self):
|
||||
"""Each call to _get_async_client() creates a NEW client instance,
|
||||
so it binds to the current event loop."""
|
||||
from trajectory_compressor import TrajectoryCompressor
|
||||
|
||||
comp = TrajectoryCompressor.__new__(TrajectoryCompressor)
|
||||
comp.config = MagicMock()
|
||||
comp.config.base_url = "https://api.example.com/v1"
|
||||
comp._async_client_api_key = "test-key"
|
||||
comp.async_client = None
|
||||
|
||||
call_count = 0
|
||||
instances = []
|
||||
|
||||
def mock_constructor(**kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
instance = MagicMock()
|
||||
instances.append(instance)
|
||||
return instance
|
||||
|
||||
with patch("openai.AsyncOpenAI", side_effect=mock_constructor):
|
||||
client1 = comp._get_async_client()
|
||||
client2 = comp._get_async_client()
|
||||
|
||||
# Should have created two separate instances
|
||||
assert call_count == 2
|
||||
assert instances[0] is not instances[1]
|
||||
|
||||
|
||||
class TestSourceLineVerification:
|
||||
"""Verify the actual source has the lazy pattern applied."""
|
||||
|
||||
@staticmethod
|
||||
def _read_file() -> str:
|
||||
import os
|
||||
base = os.path.dirname(os.path.dirname(__file__))
|
||||
with open(os.path.join(base, "trajectory_compressor.py")) as f:
|
||||
return f.read()
|
||||
|
||||
def test_no_eager_async_openai_in_init(self):
|
||||
"""__init__ should NOT create AsyncOpenAI eagerly."""
|
||||
src = self._read_file()
|
||||
# The old pattern: self.async_client = AsyncOpenAI(...) in _init_summarizer
|
||||
# should not exist — only self.async_client = None
|
||||
lines = src.split("\n")
|
||||
for i, line in enumerate(lines, 1):
|
||||
if "self.async_client = AsyncOpenAI(" in line and "_get_async_client" not in lines[max(0,i-3):i+1]:
|
||||
# Allow it inside _get_async_client method
|
||||
# Check if we're inside _get_async_client by looking at context
|
||||
context = "\n".join(lines[max(0,i-10):i+1])
|
||||
if "_get_async_client" not in context:
|
||||
pytest.fail(
|
||||
f"Line {i}: AsyncOpenAI created eagerly outside _get_async_client()"
|
||||
)
|
||||
|
||||
def test_get_async_client_method_exists(self):
|
||||
"""_get_async_client method should exist."""
|
||||
src = self._read_file()
|
||||
assert "def _get_async_client(self)" in src
|
||||
290
tests/tools/test_browser_camofox.py
Normal file
290
tests/tools/test_browser_camofox.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""Tests for the Camofox browser backend."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.browser_camofox import (
|
||||
camofox_back,
|
||||
camofox_click,
|
||||
camofox_close,
|
||||
camofox_console,
|
||||
camofox_get_images,
|
||||
camofox_navigate,
|
||||
camofox_press,
|
||||
camofox_scroll,
|
||||
camofox_snapshot,
|
||||
camofox_type,
|
||||
camofox_vision,
|
||||
check_camofox_available,
|
||||
cleanup_all_camofox_sessions,
|
||||
is_camofox_mode,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxMode:
|
||||
def test_disabled_by_default(self, monkeypatch):
|
||||
monkeypatch.delenv("CAMOFOX_URL", raising=False)
|
||||
assert is_camofox_mode() is False
|
||||
|
||||
def test_enabled_when_url_set(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
assert is_camofox_mode() is True
|
||||
|
||||
def test_health_check_unreachable(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:19999")
|
||||
assert check_camofox_available() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_response(status=200, json_data=None):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status
|
||||
resp.json.return_value = json_data or {}
|
||||
resp.content = b"\x89PNG\r\n\x1a\nfake"
|
||||
resp.raise_for_status = MagicMock()
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Navigate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxNavigate:
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_creates_tab_on_first_navigate(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab1", "url": "https://example.com"})
|
||||
|
||||
result = json.loads(camofox_navigate("https://example.com", task_id="t1"))
|
||||
assert result["success"] is True
|
||||
assert result["url"] == "https://example.com"
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_navigates_existing_tab(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
# First call creates tab
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab2", "url": "https://a.com"})
|
||||
camofox_navigate("https://a.com", task_id="t2")
|
||||
|
||||
# Second call navigates
|
||||
mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://b.com"})
|
||||
result = json.loads(camofox_navigate("https://b.com", task_id="t2"))
|
||||
assert result["success"] is True
|
||||
assert result["url"] == "https://b.com"
|
||||
|
||||
def test_connection_error_returns_helpful_message(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:19999")
|
||||
result = json.loads(camofox_navigate("https://example.com", task_id="t_err"))
|
||||
assert result["success"] is False
|
||||
assert "Cannot connect" in result["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxSnapshot:
|
||||
def test_no_session_returns_error(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
result = json.loads(camofox_snapshot(task_id="no_such_task"))
|
||||
assert result["success"] is False
|
||||
assert "browser_navigate" in result["error"]
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
@patch("tools.browser_camofox.requests.get")
|
||||
def test_returns_snapshot(self, mock_get, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
# Create session
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab3", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t3")
|
||||
|
||||
# Return snapshot
|
||||
mock_get.return_value = _mock_response(json_data={
|
||||
"snapshot": "- heading \"Test\" [e1]\n- button \"Submit\" [e2]",
|
||||
"refsCount": 2,
|
||||
})
|
||||
result = json.loads(camofox_snapshot(task_id="t3"))
|
||||
assert result["success"] is True
|
||||
assert "[e1]" in result["snapshot"]
|
||||
assert result["element_count"] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Click / Type / Scroll / Back / Press
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxInteractions:
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_click(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab4", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t4")
|
||||
|
||||
mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://x.com"})
|
||||
result = json.loads(camofox_click("@e5", task_id="t4"))
|
||||
assert result["success"] is True
|
||||
assert result["clicked"] == "e5"
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_type(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab5", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t5")
|
||||
|
||||
mock_post.return_value = _mock_response(json_data={"ok": True})
|
||||
result = json.loads(camofox_type("@e3", "hello world", task_id="t5"))
|
||||
assert result["success"] is True
|
||||
assert result["typed"] == "hello world"
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_scroll(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab6", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t6")
|
||||
|
||||
mock_post.return_value = _mock_response(json_data={"ok": True})
|
||||
result = json.loads(camofox_scroll("down", task_id="t6"))
|
||||
assert result["success"] is True
|
||||
assert result["scrolled"] == "down"
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_back(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab7", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t7")
|
||||
|
||||
mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://prev.com"})
|
||||
result = json.loads(camofox_back(task_id="t7"))
|
||||
assert result["success"] is True
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_press(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab8", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t8")
|
||||
|
||||
mock_post.return_value = _mock_response(json_data={"ok": True})
|
||||
result = json.loads(camofox_press("Enter", task_id="t8"))
|
||||
assert result["success"] is True
|
||||
assert result["pressed"] == "Enter"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Close
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxClose:
|
||||
@patch("tools.browser_camofox.requests.delete")
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_close_session(self, mock_post, mock_delete, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab9", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t9")
|
||||
|
||||
mock_delete.return_value = _mock_response(json_data={"ok": True})
|
||||
result = json.loads(camofox_close(task_id="t9"))
|
||||
assert result["success"] is True
|
||||
assert result["closed"] is True
|
||||
|
||||
def test_close_nonexistent_session(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
result = json.loads(camofox_close(task_id="nonexistent"))
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Console (limited support)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxConsole:
|
||||
def test_console_returns_empty_with_note(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
result = json.loads(camofox_console(task_id="t_console"))
|
||||
assert result["success"] is True
|
||||
assert result["total_messages"] == 0
|
||||
assert "not available" in result["note"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Images
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxGetImages:
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
@patch("tools.browser_camofox.requests.get")
|
||||
def test_get_images(self, mock_get, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab10", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t10")
|
||||
|
||||
mock_get.return_value = _mock_response(json_data={
|
||||
"images": [{"src": "https://x.com/img.png", "alt": "Logo"}],
|
||||
})
|
||||
result = json.loads(camofox_get_images(task_id="t10"))
|
||||
assert result["success"] is True
|
||||
assert result["count"] == 1
|
||||
assert result["images"][0]["src"] == "https://x.com/img.png"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routing integration — verify browser_tool routes to camofox
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBrowserToolRouting:
|
||||
"""Verify that browser_tool.py delegates to camofox when CAMOFOX_URL is set."""
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_browser_navigate_routes_to_camofox(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab_rt", "url": "https://example.com"})
|
||||
|
||||
from tools.browser_tool import browser_navigate
|
||||
# Bypass SSRF check for test URL
|
||||
with patch("tools.browser_tool._is_safe_url", return_value=True):
|
||||
result = json.loads(browser_navigate("https://example.com", task_id="t_route"))
|
||||
assert result["success"] is True
|
||||
|
||||
def test_check_requirements_passes_with_camofox(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
from tools.browser_tool import check_browser_requirements
|
||||
assert check_browser_requirements() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cleanup helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxCleanup:
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
@patch("tools.browser_camofox.requests.delete")
|
||||
def test_cleanup_all(self, mock_delete, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab_c", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t_cleanup")
|
||||
|
||||
mock_delete.return_value = _mock_response(json_data={"ok": True})
|
||||
cleanup_all_camofox_sessions()
|
||||
|
||||
# Session should be gone
|
||||
result = json.loads(camofox_snapshot(task_id="t_cleanup"))
|
||||
assert result["success"] is False
|
||||
@@ -1,13 +1,17 @@
|
||||
"""Tests for credential file passthrough registry (tools/credential_files.py)."""
|
||||
"""Tests for credential file passthrough and skills directory mounting."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.credential_files import (
|
||||
clear_credential_files,
|
||||
get_credential_file_mounts,
|
||||
get_skills_directory_mount,
|
||||
iter_skills_files,
|
||||
register_credential_file,
|
||||
register_credential_files,
|
||||
reset_config_cache,
|
||||
@@ -15,8 +19,8 @@ from tools.credential_files import (
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_registry():
|
||||
"""Reset registry between tests."""
|
||||
def _clean_state():
|
||||
"""Reset module state between tests."""
|
||||
clear_credential_files()
|
||||
reset_config_cache()
|
||||
yield
|
||||
@@ -24,135 +28,172 @@ def _clean_registry():
|
||||
reset_config_cache()
|
||||
|
||||
|
||||
class TestRegisterCredentialFile:
|
||||
def test_registers_existing_file(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "token.json").write_text('{"token": "abc"}')
|
||||
class TestRegisterCredentialFiles:
|
||||
def test_dict_with_path_key(self, tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "token.json").write_text("{}")
|
||||
|
||||
result = register_credential_file("token.json")
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
missing = register_credential_files([{"path": "token.json"}])
|
||||
|
||||
assert result is True
|
||||
assert missing == []
|
||||
mounts = get_credential_file_mounts()
|
||||
assert len(mounts) == 1
|
||||
assert mounts[0]["host_path"] == str(tmp_path / "token.json")
|
||||
assert mounts[0]["host_path"] == str(hermes_home / "token.json")
|
||||
assert mounts[0]["container_path"] == "/root/.hermes/token.json"
|
||||
|
||||
def test_skips_missing_file(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
def test_dict_with_name_key_fallback(self, tmp_path):
|
||||
"""Skills use 'name' instead of 'path' — both should work."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "google_token.json").write_text("{}")
|
||||
|
||||
result = register_credential_file("nonexistent.json")
|
||||
|
||||
assert result is False
|
||||
assert get_credential_file_mounts() == []
|
||||
|
||||
def test_custom_container_base(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "cred.json").write_text("{}")
|
||||
|
||||
register_credential_file("cred.json", container_base="/home/user/.hermes")
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
missing = register_credential_files([
|
||||
{"name": "google_token.json", "description": "OAuth token"},
|
||||
])
|
||||
|
||||
assert missing == []
|
||||
mounts = get_credential_file_mounts()
|
||||
assert mounts[0]["container_path"] == "/home/user/.hermes/cred.json"
|
||||
assert len(mounts) == 1
|
||||
assert "google_token.json" in mounts[0]["container_path"]
|
||||
|
||||
def test_deduplicates_by_container_path(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "token.json").write_text("{}")
|
||||
def test_string_entry(self, tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "secret.key").write_text("key")
|
||||
|
||||
register_credential_file("token.json")
|
||||
register_credential_file("token.json")
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
missing = register_credential_files(["secret.key"])
|
||||
|
||||
assert missing == []
|
||||
mounts = get_credential_file_mounts()
|
||||
assert len(mounts) == 1
|
||||
|
||||
def test_missing_file_reported(self, tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
class TestRegisterCredentialFiles:
|
||||
def test_string_entries(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "a.json").write_text("{}")
|
||||
(tmp_path / "b.json").write_text("{}")
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
missing = register_credential_files([
|
||||
{"name": "does_not_exist.json"},
|
||||
])
|
||||
|
||||
missing = register_credential_files(["a.json", "b.json"])
|
||||
|
||||
assert missing == []
|
||||
assert len(get_credential_file_mounts()) == 2
|
||||
|
||||
def test_dict_entries(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "token.json").write_text("{}")
|
||||
|
||||
missing = register_credential_files([
|
||||
{"path": "token.json", "description": "OAuth token"},
|
||||
])
|
||||
|
||||
assert missing == []
|
||||
assert len(get_credential_file_mounts()) == 1
|
||||
|
||||
def test_returns_missing_files(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "exists.json").write_text("{}")
|
||||
|
||||
missing = register_credential_files([
|
||||
"exists.json",
|
||||
"missing.json",
|
||||
{"path": "also_missing.json"},
|
||||
])
|
||||
|
||||
assert missing == ["missing.json", "also_missing.json"]
|
||||
assert len(get_credential_file_mounts()) == 1
|
||||
|
||||
def test_empty_list(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
assert register_credential_files([]) == []
|
||||
|
||||
|
||||
class TestConfigCredentialFiles:
|
||||
def test_loads_from_config(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "oauth.json").write_text("{}")
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"terminal:\n credential_files:\n - oauth.json\n"
|
||||
)
|
||||
|
||||
mounts = get_credential_file_mounts()
|
||||
|
||||
assert len(mounts) == 1
|
||||
assert mounts[0]["host_path"] == str(tmp_path / "oauth.json")
|
||||
|
||||
def test_config_skips_missing_files(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"terminal:\n credential_files:\n - nonexistent.json\n"
|
||||
)
|
||||
|
||||
mounts = get_credential_file_mounts()
|
||||
assert mounts == []
|
||||
|
||||
def test_combines_skill_and_config(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "skill_token.json").write_text("{}")
|
||||
(tmp_path / "config_token.json").write_text("{}")
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"terminal:\n credential_files:\n - config_token.json\n"
|
||||
)
|
||||
|
||||
register_credential_file("skill_token.json")
|
||||
mounts = get_credential_file_mounts()
|
||||
|
||||
assert len(mounts) == 2
|
||||
paths = {m["container_path"] for m in mounts}
|
||||
assert "/root/.hermes/skill_token.json" in paths
|
||||
assert "/root/.hermes/config_token.json" in paths
|
||||
|
||||
|
||||
class TestGetMountsRechecksExistence:
|
||||
def test_removed_file_excluded_from_mounts(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
token = tmp_path / "token.json"
|
||||
token.write_text("{}")
|
||||
|
||||
register_credential_file("token.json")
|
||||
assert len(get_credential_file_mounts()) == 1
|
||||
|
||||
# Delete the file after registration
|
||||
token.unlink()
|
||||
assert "does_not_exist.json" in missing
|
||||
assert get_credential_file_mounts() == []
|
||||
|
||||
def test_path_takes_precedence_over_name(self, tmp_path):
|
||||
"""When both path and name are present, path wins."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "real.json").write_text("{}")
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
missing = register_credential_files([
|
||||
{"path": "real.json", "name": "wrong.json"},
|
||||
])
|
||||
|
||||
assert missing == []
|
||||
mounts = get_credential_file_mounts()
|
||||
assert "real.json" in mounts[0]["container_path"]
|
||||
|
||||
|
||||
class TestSkillsDirectoryMount:
|
||||
def test_returns_mount_when_skills_dir_exists(self, tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
skills_dir = hermes_home / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
(skills_dir / "test-skill").mkdir()
|
||||
(skills_dir / "test-skill" / "SKILL.md").write_text("# test")
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
mount = get_skills_directory_mount()
|
||||
|
||||
assert mount is not None
|
||||
assert mount["host_path"] == str(skills_dir)
|
||||
assert mount["container_path"] == "/root/.hermes/skills"
|
||||
|
||||
def test_returns_none_when_no_skills_dir(self, tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
mount = get_skills_directory_mount()
|
||||
|
||||
assert mount is None
|
||||
|
||||
def test_custom_container_base(self, tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
(hermes_home / "skills").mkdir(parents=True)
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
mount = get_skills_directory_mount(container_base="/home/user/.hermes")
|
||||
|
||||
assert mount["container_path"] == "/home/user/.hermes/skills"
|
||||
|
||||
def test_symlinks_are_sanitized(self, tmp_path):
|
||||
"""Symlinks in skills dir should be excluded from the mount."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
skills_dir = hermes_home / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
(skills_dir / "legit.md").write_text("# real skill")
|
||||
# Create a symlink pointing outside the skills tree
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("TOP SECRET")
|
||||
(skills_dir / "evil_link").symlink_to(secret)
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
mount = get_skills_directory_mount()
|
||||
|
||||
assert mount is not None
|
||||
# The mount path should be a sanitized copy, not the original
|
||||
safe_path = Path(mount["host_path"])
|
||||
assert safe_path != skills_dir
|
||||
# Legitimate file should be present
|
||||
assert (safe_path / "legit.md").exists()
|
||||
assert (safe_path / "legit.md").read_text() == "# real skill"
|
||||
# Symlink should NOT be present
|
||||
assert not (safe_path / "evil_link").exists()
|
||||
|
||||
def test_no_symlinks_returns_original_dir(self, tmp_path):
|
||||
"""When no symlinks exist, the original dir is returned (no copy)."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
skills_dir = hermes_home / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
(skills_dir / "skill.md").write_text("ok")
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
mount = get_skills_directory_mount()
|
||||
|
||||
assert mount["host_path"] == str(skills_dir)
|
||||
|
||||
|
||||
class TestIterSkillsFiles:
|
||||
def test_returns_files_skipping_symlinks(self, tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
skills_dir = hermes_home / "skills"
|
||||
(skills_dir / "cat" / "myskill").mkdir(parents=True)
|
||||
(skills_dir / "cat" / "myskill" / "SKILL.md").write_text("# skill")
|
||||
(skills_dir / "cat" / "myskill" / "scripts").mkdir()
|
||||
(skills_dir / "cat" / "myskill" / "scripts" / "run.sh").write_text("#!/bin/bash")
|
||||
# Add a symlink that should be filtered
|
||||
secret = tmp_path / "secret"
|
||||
secret.write_text("nope")
|
||||
(skills_dir / "cat" / "myskill" / "evil").symlink_to(secret)
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
files = iter_skills_files()
|
||||
|
||||
paths = {f["container_path"] for f in files}
|
||||
assert "/root/.hermes/skills/cat/myskill/SKILL.md" in paths
|
||||
assert "/root/.hermes/skills/cat/myskill/scripts/run.sh" in paths
|
||||
# Symlink should be excluded
|
||||
assert not any("evil" in f["container_path"] for f in files)
|
||||
|
||||
def test_empty_when_no_skills_dir(self, tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
assert iter_skills_files() == []
|
||||
|
||||
@@ -61,6 +61,10 @@ def make_env(daytona_sdk, monkeypatch):
|
||||
"""Factory that creates a DaytonaEnvironment with a mocked SDK."""
|
||||
# Prevent is_interrupted from interfering
|
||||
monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False)
|
||||
# Prevent skills/credential sync from consuming mock exec calls
|
||||
monkeypatch.setattr("tools.credential_files.get_credential_file_mounts", lambda: [])
|
||||
monkeypatch.setattr("tools.credential_files.get_skills_directory_mount", lambda **kw: None)
|
||||
monkeypatch.setattr("tools.credential_files.iter_skills_files", lambda **kw: [])
|
||||
|
||||
def _factory(
|
||||
sandbox=None,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
@@ -28,6 +29,7 @@ def _reset_modules(prefixes: tuple[str, ...]):
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_tool_modules():
|
||||
original_hermes_home = os.environ.get("HERMES_HOME")
|
||||
original_modules = {
|
||||
name: module
|
||||
for name, module in sys.modules.items()
|
||||
@@ -41,6 +43,10 @@ def _restore_tool_modules():
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if original_hermes_home is None:
|
||||
os.environ.pop("HERMES_HOME", None)
|
||||
else:
|
||||
os.environ["HERMES_HOME"] = original_hermes_home
|
||||
_reset_modules(("tools", "hermes_cli", "modal"))
|
||||
sys.modules.update(original_modules)
|
||||
|
||||
@@ -57,6 +63,7 @@ def _install_modal_test_modules(
|
||||
hermes_cli.__path__ = [] # type: ignore[attr-defined]
|
||||
sys.modules["hermes_cli"] = hermes_cli
|
||||
hermes_home = tmp_path / "hermes-home"
|
||||
os.environ["HERMES_HOME"] = str(hermes_home)
|
||||
sys.modules["hermes_cli.config"] = types.SimpleNamespace(
|
||||
get_hermes_home=lambda: hermes_home,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tools.skills_hub import (
|
||||
GitHubAuth,
|
||||
@@ -648,6 +649,29 @@ class TestWellKnownSkillSource:
|
||||
assert bundle.files["SKILL.md"] == "# Code Review\n"
|
||||
assert bundle.files["references/checklist.md"] == "- [ ] security\n"
|
||||
|
||||
@patch("tools.skills_hub._write_index_cache")
|
||||
@patch("tools.skills_hub._read_index_cache", return_value=None)
|
||||
@patch("tools.skills_hub.httpx.get")
|
||||
def test_fetch_rejects_unsafe_file_paths_from_well_known_endpoint(self, mock_get, _mock_read_cache, _mock_write_cache):
|
||||
def fake_get(url, *args, **kwargs):
|
||||
if url.endswith("/index.json"):
|
||||
return MagicMock(status_code=200, json=lambda: {
|
||||
"skills": [{
|
||||
"name": "code-review",
|
||||
"description": "Review code",
|
||||
"files": ["SKILL.md", "../../../escape.txt"],
|
||||
}]
|
||||
})
|
||||
if url.endswith("/code-review/SKILL.md"):
|
||||
return MagicMock(status_code=200, text="# Code Review\n")
|
||||
raise AssertionError(url)
|
||||
|
||||
mock_get.side_effect = fake_get
|
||||
|
||||
bundle = self._source().fetch("well-known:https://example.com/.well-known/skills/code-review")
|
||||
|
||||
assert bundle is None
|
||||
|
||||
|
||||
class TestCheckForSkillUpdates:
|
||||
def test_bundle_content_hash_matches_installed_content_hash(self, tmp_path):
|
||||
@@ -1143,6 +1167,61 @@ class TestQuarantineBundleBinaryAssets:
|
||||
assert (q_path / "SKILL.md").read_text(encoding="utf-8").startswith("---")
|
||||
assert (q_path / "assets" / "neutts-cli" / "samples" / "jo.wav").read_bytes() == b"RIFF\x00\x01fakewav"
|
||||
|
||||
def test_quarantine_bundle_rejects_traversal_file_paths(self, tmp_path):
|
||||
import tools.skills_hub as hub
|
||||
|
||||
hub_dir = tmp_path / "skills" / ".hub"
|
||||
with patch.object(hub, "SKILLS_DIR", tmp_path / "skills"), \
|
||||
patch.object(hub, "HUB_DIR", hub_dir), \
|
||||
patch.object(hub, "LOCK_FILE", hub_dir / "lock.json"), \
|
||||
patch.object(hub, "QUARANTINE_DIR", hub_dir / "quarantine"), \
|
||||
patch.object(hub, "AUDIT_LOG", hub_dir / "audit.log"), \
|
||||
patch.object(hub, "TAPS_FILE", hub_dir / "taps.json"), \
|
||||
patch.object(hub, "INDEX_CACHE_DIR", hub_dir / "index-cache"):
|
||||
bundle = SkillBundle(
|
||||
name="demo",
|
||||
files={
|
||||
"SKILL.md": "---\nname: demo\n---\n",
|
||||
"../../../escape.txt": "owned",
|
||||
},
|
||||
source="well-known",
|
||||
identifier="well-known:https://example.com/.well-known/skills/demo",
|
||||
trust_level="community",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Unsafe bundle file path"):
|
||||
quarantine_bundle(bundle)
|
||||
|
||||
assert not (tmp_path / "skills" / "escape.txt").exists()
|
||||
|
||||
def test_quarantine_bundle_rejects_absolute_file_paths(self, tmp_path):
|
||||
import tools.skills_hub as hub
|
||||
|
||||
hub_dir = tmp_path / "skills" / ".hub"
|
||||
absolute_target = tmp_path / "outside.txt"
|
||||
with patch.object(hub, "SKILLS_DIR", tmp_path / "skills"), \
|
||||
patch.object(hub, "HUB_DIR", hub_dir), \
|
||||
patch.object(hub, "LOCK_FILE", hub_dir / "lock.json"), \
|
||||
patch.object(hub, "QUARANTINE_DIR", hub_dir / "quarantine"), \
|
||||
patch.object(hub, "AUDIT_LOG", hub_dir / "audit.log"), \
|
||||
patch.object(hub, "TAPS_FILE", hub_dir / "taps.json"), \
|
||||
patch.object(hub, "INDEX_CACHE_DIR", hub_dir / "index-cache"):
|
||||
bundle = SkillBundle(
|
||||
name="demo",
|
||||
files={
|
||||
"SKILL.md": "---\nname: demo\n---\n",
|
||||
str(absolute_target): "owned",
|
||||
},
|
||||
source="well-known",
|
||||
identifier="well-known:https://example.com/.well-known/skills/demo",
|
||||
trust_level="community",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Unsafe bundle file path"):
|
||||
quarantine_bundle(bundle)
|
||||
|
||||
assert not absolute_target.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHubSource._download_directory — tree API + fallback (#2940)
|
||||
|
||||
27
tests/tools/test_terminal_timeout_output.py
Normal file
27
tests/tools/test_terminal_timeout_output.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Verify that terminal command timeouts preserve partial output."""
|
||||
from tools.environments.local import LocalEnvironment
|
||||
|
||||
|
||||
class TestTimeoutPreservesPartialOutput:
|
||||
"""When a command times out, any output captured before the deadline
|
||||
should be included in the result — not discarded."""
|
||||
|
||||
def test_timeout_includes_partial_output(self):
|
||||
"""A command that prints then sleeps past the deadline should
|
||||
return both the printed text and the timeout notice."""
|
||||
env = LocalEnvironment()
|
||||
result = env.execute("echo 'hello from test' && sleep 30", timeout=2)
|
||||
|
||||
assert result["returncode"] == 124
|
||||
assert "hello from test" in result["output"]
|
||||
assert "timed out" in result["output"].lower()
|
||||
|
||||
def test_timeout_with_no_output(self):
|
||||
"""A command that produces nothing before timeout should still
|
||||
return a clean timeout message."""
|
||||
env = LocalEnvironment()
|
||||
result = env.execute("sleep 30", timeout=1)
|
||||
|
||||
assert result["returncode"] == 124
|
||||
assert "timed out" in result["output"].lower()
|
||||
assert not result["output"].startswith("\n")
|
||||
@@ -259,6 +259,12 @@ def test_check_website_access_uses_dynamic_hermes_home(monkeypatch, tmp_path):
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
# Invalidate the module-level cache so the new HERMES_HOME is picked up.
|
||||
# A prior test may have cached a default policy (enabled=False) under the
|
||||
# old HERMES_HOME set by the autouse _isolate_hermes_home fixture.
|
||||
from tools.website_policy import invalidate_cache
|
||||
invalidate_cache()
|
||||
|
||||
blocked = check_website_access("https://dynamic.example/path")
|
||||
|
||||
assert blocked is not None
|
||||
|
||||
Reference in New Issue
Block a user