mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-31 06:51:29 +00:00
Fix unsafe gateway media path delivery
This commit is contained in:
parent
4a91e36495
commit
41d2c758c3
10 changed files with 371 additions and 60 deletions
|
|
@ -529,7 +529,9 @@ def _send_media_via_adapter(
|
||||||
"""
|
"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from gateway.platforms.base import should_send_media_as_audio
|
from gateway.platforms.base import BasePlatformAdapter, should_send_media_as_audio
|
||||||
|
|
||||||
|
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
|
||||||
|
|
||||||
for media_path, _is_voice in media_files:
|
for media_path, _is_voice in media_files:
|
||||||
try:
|
try:
|
||||||
|
|
@ -614,6 +616,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||||
# Extract MEDIA: tags so attachments are forwarded as files, not raw text
|
# Extract MEDIA: tags so attachments are forwarded as files, not raw text
|
||||||
from gateway.platforms.base import BasePlatformAdapter
|
from gateway.platforms.base import BasePlatformAdapter
|
||||||
media_files, cleaned_delivery_content = BasePlatformAdapter.extract_media(delivery_content)
|
media_files, cleaned_delivery_content = BasePlatformAdapter.extract_media(delivery_content)
|
||||||
|
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = load_gateway_config()
|
config = load_gateway_config()
|
||||||
|
|
|
||||||
|
|
@ -472,7 +472,7 @@ sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
|
||||||
|
|
||||||
from gateway.config import Platform, PlatformConfig
|
from gateway.config import Platform, PlatformConfig
|
||||||
from gateway.session import SessionSource, build_session_key
|
from gateway.session import SessionSource, build_session_key
|
||||||
from hermes_constants import get_hermes_dir
|
from hermes_constants import get_hermes_dir, get_hermes_home
|
||||||
|
|
||||||
|
|
||||||
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
|
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
|
||||||
|
|
@ -813,6 +813,86 @@ def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
DOCUMENT_CACHE_DIR = get_hermes_dir("cache/documents", "document_cache")
|
DOCUMENT_CACHE_DIR = get_hermes_dir("cache/documents", "document_cache")
|
||||||
|
SCREENSHOT_CACHE_DIR = get_hermes_dir("cache/screenshots", "browser_screenshots")
|
||||||
|
_HERMES_HOME = get_hermes_home()
|
||||||
|
MEDIA_DELIVERY_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS"
|
||||||
|
MEDIA_DELIVERY_SAFE_ROOTS = (
|
||||||
|
IMAGE_CACHE_DIR,
|
||||||
|
AUDIO_CACHE_DIR,
|
||||||
|
VIDEO_CACHE_DIR,
|
||||||
|
DOCUMENT_CACHE_DIR,
|
||||||
|
SCREENSHOT_CACHE_DIR,
|
||||||
|
_HERMES_HOME / "image_cache",
|
||||||
|
_HERMES_HOME / "audio_cache",
|
||||||
|
_HERMES_HOME / "video_cache",
|
||||||
|
_HERMES_HOME / "document_cache",
|
||||||
|
_HERMES_HOME / "browser_screenshots",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _media_delivery_allowed_roots() -> List[Path]:
|
||||||
|
"""Return roots from which model-emitted local media may be delivered."""
|
||||||
|
roots = [Path(root) for root in MEDIA_DELIVERY_SAFE_ROOTS]
|
||||||
|
extra_roots = os.environ.get(MEDIA_DELIVERY_ALLOW_DIRS_ENV, "")
|
||||||
|
for chunk in extra_roots.split(os.pathsep):
|
||||||
|
for raw_root in chunk.split(","):
|
||||||
|
raw_root = raw_root.strip()
|
||||||
|
if not raw_root:
|
||||||
|
continue
|
||||||
|
root = Path(os.path.expanduser(raw_root))
|
||||||
|
if root.is_absolute():
|
||||||
|
roots.append(root)
|
||||||
|
return roots
|
||||||
|
|
||||||
|
|
||||||
|
def _path_is_within(path: Path, root: Path) -> bool:
|
||||||
|
try:
|
||||||
|
path.relative_to(root)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_media_delivery_path(path: str) -> Optional[str]:
|
||||||
|
"""Return a safe absolute file path for native media delivery, else None.
|
||||||
|
|
||||||
|
MEDIA tags and bare local paths in model output are untrusted text. Only
|
||||||
|
existing regular files under Hermes-managed media caches, or roots the
|
||||||
|
operator explicitly allowlists, may be uploaded as native attachments.
|
||||||
|
Symlinks are resolved before the containment check.
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidate = str(path).strip()
|
||||||
|
if len(candidate) >= 2 and candidate[0] == candidate[-1] and candidate[0] in "`\"'":
|
||||||
|
candidate = candidate[1:-1].strip()
|
||||||
|
candidate = candidate.lstrip("`\"'").rstrip("`\"',.;:)}]")
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
|
||||||
|
expanded = Path(os.path.expanduser(candidate))
|
||||||
|
if not expanded.is_absolute():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved = expanded.resolve(strict=True)
|
||||||
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not resolved.is_file():
|
||||||
|
return None
|
||||||
|
|
||||||
|
for root in _media_delivery_allowed_roots():
|
||||||
|
try:
|
||||||
|
resolved_root = root.expanduser().resolve(strict=False)
|
||||||
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
continue
|
||||||
|
if _path_is_within(resolved, resolved_root):
|
||||||
|
return str(resolved)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_DOCUMENT_TYPES = {
|
SUPPORTED_DOCUMENT_TYPES = {
|
||||||
".pdf": "application/pdf",
|
".pdf": "application/pdf",
|
||||||
|
|
@ -2119,6 +2199,35 @@ class BasePlatformAdapter(ABC):
|
||||||
text = f"{caption}\n{text}"
|
text = f"{caption}\n{text}"
|
||||||
return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)
|
return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_media_delivery_path(path: str) -> Optional[str]:
|
||||||
|
"""Return a resolved path if it is safe for native attachment upload."""
|
||||||
|
return validate_media_delivery_path(path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def filter_media_delivery_paths(media_files) -> List[Tuple[str, bool]]:
|
||||||
|
"""Drop unsafe MEDIA paths and normalize accepted paths."""
|
||||||
|
safe_media: List[Tuple[str, bool]] = []
|
||||||
|
for media_path, is_voice in media_files or []:
|
||||||
|
safe_path = validate_media_delivery_path(str(media_path))
|
||||||
|
if safe_path:
|
||||||
|
safe_media.append((safe_path, bool(is_voice)))
|
||||||
|
else:
|
||||||
|
logger.warning("Skipping unsafe MEDIA directive path outside allowed roots")
|
||||||
|
return safe_media
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def filter_local_delivery_paths(file_paths) -> List[str]:
|
||||||
|
"""Drop unsafe bare local file paths and normalize accepted paths."""
|
||||||
|
safe_paths: List[str] = []
|
||||||
|
for file_path in file_paths or []:
|
||||||
|
safe_path = validate_media_delivery_path(str(file_path))
|
||||||
|
if safe_path:
|
||||||
|
safe_paths.append(safe_path)
|
||||||
|
else:
|
||||||
|
logger.warning("Skipping unsafe local file path outside allowed roots")
|
||||||
|
return safe_paths
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:
|
def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -3166,6 +3275,7 @@ class BasePlatformAdapter(ABC):
|
||||||
|
|
||||||
# Extract MEDIA:<path> tags (from TTS tool) before other processing
|
# Extract MEDIA:<path> tags (from TTS tool) before other processing
|
||||||
media_files, response = self.extract_media(response)
|
media_files, response = self.extract_media(response)
|
||||||
|
media_files = self.filter_media_delivery_paths(media_files)
|
||||||
|
|
||||||
# Extract image URLs and send them as native platform attachments
|
# Extract image URLs and send them as native platform attachments
|
||||||
images, text_content = self.extract_images(response)
|
images, text_content = self.extract_images(response)
|
||||||
|
|
@ -3179,6 +3289,7 @@ class BasePlatformAdapter(ABC):
|
||||||
# Auto-detect bare local file paths for native media delivery
|
# Auto-detect bare local file paths for native media delivery
|
||||||
# (helps small models that don't use MEDIA: syntax)
|
# (helps small models that don't use MEDIA: syntax)
|
||||||
local_files, text_content = self.extract_local_files(text_content)
|
local_files, text_content = self.extract_local_files(text_content)
|
||||||
|
local_files = self.filter_local_delivery_paths(local_files)
|
||||||
if local_files:
|
if local_files:
|
||||||
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
|
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1679,8 +1679,10 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||||
|
|
||||||
# Extract MEDIA: tags and bare local file paths before text delivery.
|
# Extract MEDIA: tags and bare local file paths before text delivery.
|
||||||
media_files, cleaned_content = self.extract_media(content)
|
media_files, cleaned_content = self.extract_media(content)
|
||||||
|
media_files = self.filter_media_delivery_paths(media_files)
|
||||||
_, image_cleaned = self.extract_images(cleaned_content)
|
_, image_cleaned = self.extract_images(cleaned_content)
|
||||||
local_files, final_content = self.extract_local_files(image_cleaned)
|
local_files, final_content = self.extract_local_files(image_cleaned)
|
||||||
|
local_files = self.filter_local_delivery_paths(local_files)
|
||||||
|
|
||||||
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
|
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
|
||||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
|
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
|
||||||
|
|
|
||||||
|
|
@ -5058,6 +5058,11 @@ class GatewayRunner:
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
from gateway.platforms.base import BasePlatformAdapter
|
||||||
|
candidates = BasePlatformAdapter.filter_local_delivery_paths(candidates)
|
||||||
|
if not candidates:
|
||||||
|
return
|
||||||
|
|
||||||
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
|
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
|
||||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
|
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
|
||||||
|
|
||||||
|
|
@ -11264,14 +11269,16 @@ class GatewayRunner:
|
||||||
# send_multiple_images (Telegram sendPhoto recompresses to ~1280px).
|
# send_multiple_images (Telegram sendPhoto recompresses to ~1280px).
|
||||||
force_document_attachments = "[[as_document]]" in response
|
force_document_attachments = "[[as_document]]" in response
|
||||||
|
|
||||||
|
from gateway.platforms.base import BasePlatformAdapter, should_send_media_as_audio
|
||||||
|
|
||||||
media_files, _ = adapter.extract_media(response)
|
media_files, _ = adapter.extract_media(response)
|
||||||
|
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
|
||||||
_, cleaned = adapter.extract_images(response)
|
_, cleaned = adapter.extract_images(response)
|
||||||
local_files, _ = adapter.extract_local_files(cleaned)
|
local_files, _ = adapter.extract_local_files(cleaned)
|
||||||
|
local_files = BasePlatformAdapter.filter_local_delivery_paths(local_files)
|
||||||
|
|
||||||
_thread_meta = self._thread_metadata_for_source(event.source, self._reply_anchor_for_event(event))
|
_thread_meta = self._thread_metadata_for_source(event.source, self._reply_anchor_for_event(event))
|
||||||
|
|
||||||
from gateway.platforms.base import should_send_media_as_audio
|
|
||||||
|
|
||||||
_VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'}
|
_VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'}
|
||||||
_IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
|
_IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
|
||||||
|
|
||||||
|
|
@ -11563,6 +11570,8 @@ class GatewayRunner:
|
||||||
# Extract media files from the response
|
# Extract media files from the response
|
||||||
if response:
|
if response:
|
||||||
media_files, response = adapter.extract_media(response)
|
media_files, response = adapter.extract_media(response)
|
||||||
|
from gateway.platforms.base import BasePlatformAdapter
|
||||||
|
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
|
||||||
images, text_content = adapter.extract_images(response)
|
images, text_content = adapter.extract_images(response)
|
||||||
|
|
||||||
preview = prompt[:60] + ("..." if len(prompt) > 60 else "")
|
preview = prompt[:60] + ("..." if len(prompt) > 60 else "")
|
||||||
|
|
|
||||||
|
|
@ -490,6 +490,17 @@ class TestRoutingIntents:
|
||||||
class TestDeliverResultWrapping:
|
class TestDeliverResultWrapping:
|
||||||
"""Verify that cron deliveries are wrapped with header/footer and no longer mirrored."""
|
"""Verify that cron deliveries are wrapped with header/footer and no longer mirrored."""
|
||||||
|
|
||||||
|
def _safe_media_path(self, tmp_path, monkeypatch, name, data=b"media"):
|
||||||
|
root = tmp_path / "media-cache"
|
||||||
|
media_file = root / name
|
||||||
|
media_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
media_file.write_bytes(data)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS",
|
||||||
|
(root,),
|
||||||
|
)
|
||||||
|
return media_file.resolve()
|
||||||
|
|
||||||
def test_delivery_wraps_content_with_header_and_footer(self):
|
def test_delivery_wraps_content_with_header_and_footer(self):
|
||||||
"""Delivered content should include task name header and agent-invisible note."""
|
"""Delivered content should include task name header and agent-invisible note."""
|
||||||
from gateway.config import Platform
|
from gateway.config import Platform
|
||||||
|
|
@ -564,9 +575,10 @@ class TestDeliverResultWrapping:
|
||||||
assert "Cronjob Response" not in sent_content
|
assert "Cronjob Response" not in sent_content
|
||||||
assert "The agent cannot see" not in sent_content
|
assert "The agent cannot see" not in sent_content
|
||||||
|
|
||||||
def test_delivery_extracts_media_tags_before_send(self):
|
def test_delivery_extracts_media_tags_before_send(self, tmp_path, monkeypatch):
|
||||||
"""Cron delivery should pass MEDIA attachments separately to the send helper."""
|
"""Cron delivery should pass MEDIA attachments separately to the send helper."""
|
||||||
from gateway.config import Platform
|
from gateway.config import Platform
|
||||||
|
media_path = self._safe_media_path(tmp_path, monkeypatch, "test-voice.ogg")
|
||||||
|
|
||||||
pconfig = MagicMock()
|
pconfig = MagicMock()
|
||||||
pconfig.enabled = True
|
pconfig.enabled = True
|
||||||
|
|
@ -581,7 +593,7 @@ class TestDeliverResultWrapping:
|
||||||
"deliver": "origin",
|
"deliver": "origin",
|
||||||
"origin": {"platform": "telegram", "chat_id": "123"},
|
"origin": {"platform": "telegram", "chat_id": "123"},
|
||||||
}
|
}
|
||||||
_deliver_result(job, "Title\nMEDIA:/tmp/test-voice.ogg")
|
_deliver_result(job, f"Title\nMEDIA:{media_path}")
|
||||||
|
|
||||||
send_mock.assert_called_once()
|
send_mock.assert_called_once()
|
||||||
args, kwargs = send_mock.call_args
|
args, kwargs = send_mock.call_args
|
||||||
|
|
@ -589,14 +601,15 @@ class TestDeliverResultWrapping:
|
||||||
assert "MEDIA:" not in args[3]
|
assert "MEDIA:" not in args[3]
|
||||||
assert "Title" in args[3]
|
assert "Title" in args[3]
|
||||||
# Media files should be forwarded separately
|
# Media files should be forwarded separately
|
||||||
assert kwargs["media_files"] == [("/tmp/test-voice.ogg", False)]
|
assert kwargs["media_files"] == [(str(media_path), False)]
|
||||||
|
|
||||||
def test_live_adapter_sends_media_as_attachments(self):
|
def test_live_adapter_sends_media_as_attachments(self, tmp_path, monkeypatch):
|
||||||
"""When a live adapter is available, MEDIA files should be sent as native
|
"""When a live adapter is available, MEDIA files should be sent as native
|
||||||
platform attachments (e.g., Discord voice, Telegram audio) rather than
|
platform attachments (e.g., Discord voice, Telegram audio) rather than
|
||||||
as literal 'MEDIA:/path' text."""
|
as literal 'MEDIA:/path' text."""
|
||||||
from gateway.config import Platform
|
from gateway.config import Platform
|
||||||
from concurrent.futures import Future
|
from concurrent.futures import Future
|
||||||
|
media_path = self._safe_media_path(tmp_path, monkeypatch, "cron-voice.mp3")
|
||||||
|
|
||||||
adapter = AsyncMock()
|
adapter = AsyncMock()
|
||||||
adapter.send.return_value = MagicMock(success=True)
|
adapter.send.return_value = MagicMock(success=True)
|
||||||
|
|
@ -628,7 +641,7 @@ class TestDeliverResultWrapping:
|
||||||
patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro):
|
patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro):
|
||||||
_deliver_result(
|
_deliver_result(
|
||||||
job,
|
job,
|
||||||
"Here is TTS\nMEDIA:/tmp/cron-voice.mp3",
|
f"Here is TTS\nMEDIA:{media_path}",
|
||||||
adapters={Platform.DISCORD: adapter},
|
adapters={Platform.DISCORD: adapter},
|
||||||
loop=loop,
|
loop=loop,
|
||||||
)
|
)
|
||||||
|
|
@ -642,12 +655,13 @@ class TestDeliverResultWrapping:
|
||||||
# Audio file should be sent as a voice attachment
|
# Audio file should be sent as a voice attachment
|
||||||
adapter.send_voice.assert_called_once()
|
adapter.send_voice.assert_called_once()
|
||||||
voice_call = adapter.send_voice.call_args
|
voice_call = adapter.send_voice.call_args
|
||||||
assert voice_call[1]["audio_path"] == "/tmp/cron-voice.mp3"
|
assert voice_call[1]["audio_path"] == str(media_path)
|
||||||
|
|
||||||
def test_live_adapter_routes_image_to_send_image_file(self):
|
def test_live_adapter_routes_image_to_send_image_file(self, tmp_path, monkeypatch):
|
||||||
"""Image MEDIA files should be routed to send_image_file, not send_voice."""
|
"""Image MEDIA files should be routed to send_image_file, not send_voice."""
|
||||||
from gateway.config import Platform
|
from gateway.config import Platform
|
||||||
from concurrent.futures import Future
|
from concurrent.futures import Future
|
||||||
|
media_path = self._safe_media_path(tmp_path, monkeypatch, "chart.png")
|
||||||
|
|
||||||
adapter = AsyncMock()
|
adapter = AsyncMock()
|
||||||
adapter.send.return_value = MagicMock(success=True)
|
adapter.send.return_value = MagicMock(success=True)
|
||||||
|
|
@ -678,19 +692,20 @@ class TestDeliverResultWrapping:
|
||||||
patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro):
|
patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro):
|
||||||
_deliver_result(
|
_deliver_result(
|
||||||
job,
|
job,
|
||||||
"Chart attached\nMEDIA:/tmp/chart.png",
|
f"Chart attached\nMEDIA:{media_path}",
|
||||||
adapters={Platform.DISCORD: adapter},
|
adapters={Platform.DISCORD: adapter},
|
||||||
loop=loop,
|
loop=loop,
|
||||||
)
|
)
|
||||||
|
|
||||||
adapter.send_image_file.assert_called_once()
|
adapter.send_image_file.assert_called_once()
|
||||||
assert adapter.send_image_file.call_args[1]["image_path"] == "/tmp/chart.png"
|
assert adapter.send_image_file.call_args[1]["image_path"] == str(media_path)
|
||||||
adapter.send_voice.assert_not_called()
|
adapter.send_voice.assert_not_called()
|
||||||
|
|
||||||
def test_live_adapter_media_only_no_text(self):
|
def test_live_adapter_media_only_no_text(self, tmp_path, monkeypatch):
|
||||||
"""When content is ONLY a MEDIA tag with no text, media should still be sent."""
|
"""When content is ONLY a MEDIA tag with no text, media should still be sent."""
|
||||||
from gateway.config import Platform
|
from gateway.config import Platform
|
||||||
from concurrent.futures import Future
|
from concurrent.futures import Future
|
||||||
|
media_path = self._safe_media_path(tmp_path, monkeypatch, "voice.ogg")
|
||||||
|
|
||||||
adapter = AsyncMock()
|
adapter = AsyncMock()
|
||||||
adapter.send_voice.return_value = MagicMock(success=True)
|
adapter.send_voice.return_value = MagicMock(success=True)
|
||||||
|
|
@ -720,7 +735,7 @@ class TestDeliverResultWrapping:
|
||||||
patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro):
|
patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro):
|
||||||
_deliver_result(
|
_deliver_result(
|
||||||
job,
|
job,
|
||||||
"[[audio_as_voice]]\nMEDIA:/tmp/voice.ogg",
|
f"[[audio_as_voice]]\nMEDIA:{media_path}",
|
||||||
adapters={Platform.TELEGRAM: adapter},
|
adapters={Platform.TELEGRAM: adapter},
|
||||||
loop=loop,
|
loop=loop,
|
||||||
)
|
)
|
||||||
|
|
@ -2164,43 +2179,56 @@ class TestBuildJobPromptBumpUse:
|
||||||
class TestSendMediaViaAdapter:
|
class TestSendMediaViaAdapter:
|
||||||
"""Unit tests for _send_media_via_adapter — routes files to typed adapter methods."""
|
"""Unit tests for _send_media_via_adapter — routes files to typed adapter methods."""
|
||||||
|
|
||||||
|
def _safe_media_path(self, tmp_path, monkeypatch, name, data=b"media"):
|
||||||
|
root = tmp_path / "media-cache"
|
||||||
|
media_file = root / name
|
||||||
|
media_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
media_file.write_bytes(data)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS",
|
||||||
|
(root,),
|
||||||
|
)
|
||||||
|
return media_file.resolve()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _run_with_loop(adapter, chat_id, media_files, metadata, job):
|
def _run_with_loop(adapter, chat_id, media_files, metadata, job):
|
||||||
"""Helper: run _send_media_via_adapter with a real running event loop."""
|
"""Helper: run _send_media_via_adapter with immediate scheduling."""
|
||||||
import asyncio
|
from concurrent.futures import Future
|
||||||
import threading
|
|
||||||
|
|
||||||
loop = asyncio.new_event_loop()
|
def fake_run_coro(coro, _loop):
|
||||||
t = threading.Thread(target=loop.run_forever, daemon=True)
|
coro.close()
|
||||||
t.start()
|
completed = Future()
|
||||||
try:
|
completed.set_result(MagicMock(success=True))
|
||||||
_send_media_via_adapter(adapter, chat_id, media_files, metadata, loop, job)
|
return completed
|
||||||
finally:
|
|
||||||
loop.call_soon_threadsafe(loop.stop)
|
|
||||||
t.join(timeout=5)
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
def test_video_dispatched_to_send_video(self):
|
with patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro):
|
||||||
|
_send_media_via_adapter(adapter, chat_id, media_files, metadata, MagicMock(), job)
|
||||||
|
|
||||||
|
def test_video_dispatched_to_send_video(self, tmp_path, monkeypatch):
|
||||||
adapter = MagicMock()
|
adapter = MagicMock()
|
||||||
adapter.send_video = AsyncMock()
|
adapter.send_video = AsyncMock()
|
||||||
media_files = [("/tmp/clip.mp4", False)]
|
media_path = self._safe_media_path(tmp_path, monkeypatch, "clip.mp4")
|
||||||
|
media_files = [(str(media_path), False)]
|
||||||
self._run_with_loop(adapter, "123", media_files, None, {"id": "j1"})
|
self._run_with_loop(adapter, "123", media_files, None, {"id": "j1"})
|
||||||
adapter.send_video.assert_called_once()
|
adapter.send_video.assert_called_once()
|
||||||
assert adapter.send_video.call_args[1]["video_path"] == "/tmp/clip.mp4"
|
assert adapter.send_video.call_args[1]["video_path"] == str(media_path)
|
||||||
|
|
||||||
def test_unknown_ext_dispatched_to_send_document(self):
|
def test_unknown_ext_dispatched_to_send_document(self, tmp_path, monkeypatch):
|
||||||
adapter = MagicMock()
|
adapter = MagicMock()
|
||||||
adapter.send_document = AsyncMock()
|
adapter.send_document = AsyncMock()
|
||||||
media_files = [("/tmp/report.pdf", False)]
|
media_path = self._safe_media_path(tmp_path, monkeypatch, "report.pdf")
|
||||||
|
media_files = [(str(media_path), False)]
|
||||||
self._run_with_loop(adapter, "123", media_files, None, {"id": "j2"})
|
self._run_with_loop(adapter, "123", media_files, None, {"id": "j2"})
|
||||||
adapter.send_document.assert_called_once()
|
adapter.send_document.assert_called_once()
|
||||||
assert adapter.send_document.call_args[1]["file_path"] == "/tmp/report.pdf"
|
assert adapter.send_document.call_args[1]["file_path"] == str(media_path)
|
||||||
|
|
||||||
def test_multiple_media_files_all_delivered(self):
|
def test_multiple_media_files_all_delivered(self, tmp_path, monkeypatch):
|
||||||
adapter = MagicMock()
|
adapter = MagicMock()
|
||||||
adapter.send_voice = AsyncMock()
|
adapter.send_voice = AsyncMock()
|
||||||
adapter.send_image_file = AsyncMock()
|
adapter.send_image_file = AsyncMock()
|
||||||
media_files = [("/tmp/voice.mp3", False), ("/tmp/photo.jpg", False)]
|
voice_path = self._safe_media_path(tmp_path, monkeypatch, "voice.mp3")
|
||||||
|
photo_path = self._safe_media_path(tmp_path, monkeypatch, "photo.jpg")
|
||||||
|
media_files = [(str(voice_path), False), (str(photo_path), False)]
|
||||||
self._run_with_loop(adapter, "123", media_files, None, {"id": "j3"})
|
self._run_with_loop(adapter, "123", media_files, None, {"id": "j3"})
|
||||||
adapter.send_voice.assert_called_once()
|
adapter.send_voice.assert_called_once()
|
||||||
adapter.send_image_file.assert_called_once()
|
adapter.send_image_file.assert_called_once()
|
||||||
|
|
@ -2462,7 +2490,7 @@ class TestSendMediaTimeoutCancelsFuture:
|
||||||
in-flight coroutine must be cancelled before the next file is tried.
|
in-flight coroutine must be cancelled before the next file is tried.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def test_media_send_timeout_cancels_future_and_continues(self):
|
def test_media_send_timeout_cancels_future_and_continues(self, tmp_path, monkeypatch):
|
||||||
"""End-to-end: _send_media_via_adapter with a future whose .result()
|
"""End-to-end: _send_media_via_adapter with a future whose .result()
|
||||||
raises TimeoutError. Assert cancel() fires and the loop proceeds
|
raises TimeoutError. Assert cancel() fires and the loop proceeds
|
||||||
to the next file rather than hanging or crashing."""
|
to the next file rather than hanging or crashing."""
|
||||||
|
|
@ -2493,9 +2521,19 @@ class TestSendMediaTimeoutCancelsFuture:
|
||||||
coro.close()
|
coro.close()
|
||||||
return next(futures_iter)
|
return next(futures_iter)
|
||||||
|
|
||||||
|
root = tmp_path / "media-cache"
|
||||||
|
slow = root / "slow.png"
|
||||||
|
fast = root / "fast.mp4"
|
||||||
|
slow.parent.mkdir(parents=True)
|
||||||
|
slow.write_bytes(b"slow")
|
||||||
|
fast.write_bytes(b"fast")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS",
|
||||||
|
(root,),
|
||||||
|
)
|
||||||
media_files = [
|
media_files = [
|
||||||
("/tmp/slow.png", False), # times out
|
(str(slow), False), # times out
|
||||||
("/tmp/fast.mp4", False), # succeeds
|
(str(fast), False), # succeeds
|
||||||
]
|
]
|
||||||
|
|
||||||
loop = MagicMock()
|
loop = MagicMock()
|
||||||
|
|
@ -2509,4 +2547,4 @@ class TestSendMediaTimeoutCancelsFuture:
|
||||||
assert timeout_cancel_calls == [True], "future.cancel() must fire on TimeoutError"
|
assert timeout_cancel_calls == [True], "future.cancel() must fire on TimeoutError"
|
||||||
# 2. Second file still got dispatched — one timeout doesn't abort the batch
|
# 2. Second file still got dispatched — one timeout doesn't abort the batch
|
||||||
adapter.send_video.assert_called_once()
|
adapter.send_video.assert_called_once()
|
||||||
assert adapter.send_video.call_args[1]["video_path"] == "/tmp/fast.mp4"
|
assert adapter.send_video.call_args[1]["video_path"] == str(fast.resolve())
|
||||||
|
|
|
||||||
|
|
@ -361,6 +361,72 @@ class TestExtractMedia:
|
||||||
assert "[[as_document]]" not in cleaned
|
assert "[[as_document]]" not in cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class TestMediaDeliveryPathValidation:
|
||||||
|
def _patch_roots(self, monkeypatch, *roots):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS",
|
||||||
|
tuple(roots),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_allows_existing_file_inside_safe_root(self, tmp_path, monkeypatch):
|
||||||
|
root = tmp_path / "media-cache"
|
||||||
|
media_file = root / "voice.ogg"
|
||||||
|
media_file.parent.mkdir(parents=True)
|
||||||
|
media_file.write_bytes(b"OggS")
|
||||||
|
self._patch_roots(monkeypatch, root)
|
||||||
|
|
||||||
|
assert BasePlatformAdapter.validate_media_delivery_path(str(media_file)) == str(media_file.resolve())
|
||||||
|
|
||||||
|
def test_rejects_existing_file_outside_safe_root(self, tmp_path, monkeypatch):
|
||||||
|
root = tmp_path / "media-cache"
|
||||||
|
root.mkdir()
|
||||||
|
secret = tmp_path / "secrets.txt"
|
||||||
|
secret.write_text("not for upload")
|
||||||
|
self._patch_roots(monkeypatch, root)
|
||||||
|
|
||||||
|
assert BasePlatformAdapter.validate_media_delivery_path(str(secret)) is None
|
||||||
|
|
||||||
|
def test_rejects_symlink_escape_from_safe_root(self, tmp_path, monkeypatch):
|
||||||
|
root = tmp_path / "media-cache"
|
||||||
|
root.mkdir()
|
||||||
|
secret = tmp_path / "outside.png"
|
||||||
|
secret.write_bytes(b"secret")
|
||||||
|
link = root / "safe-looking.png"
|
||||||
|
try:
|
||||||
|
link.symlink_to(secret)
|
||||||
|
except OSError:
|
||||||
|
pytest.skip("symlink creation is unavailable")
|
||||||
|
self._patch_roots(monkeypatch, root)
|
||||||
|
|
||||||
|
assert BasePlatformAdapter.validate_media_delivery_path(str(link)) is None
|
||||||
|
|
||||||
|
def test_filter_keeps_safe_media_and_drops_unsafe(self, tmp_path, monkeypatch):
|
||||||
|
root = tmp_path / "media-cache"
|
||||||
|
safe = root / "speech.ogg"
|
||||||
|
unsafe = tmp_path / "outside.ogg"
|
||||||
|
safe.parent.mkdir(parents=True)
|
||||||
|
safe.write_bytes(b"OggS")
|
||||||
|
unsafe.write_bytes(b"OggS")
|
||||||
|
self._patch_roots(monkeypatch, root)
|
||||||
|
|
||||||
|
filtered = BasePlatformAdapter.filter_media_delivery_paths([
|
||||||
|
(str(unsafe), False),
|
||||||
|
(str(safe), True),
|
||||||
|
])
|
||||||
|
|
||||||
|
assert filtered == [(str(safe.resolve()), True)]
|
||||||
|
|
||||||
|
def test_allows_operator_configured_extra_root(self, tmp_path, monkeypatch):
|
||||||
|
extra_root = tmp_path / "operator-media"
|
||||||
|
media_file = extra_root / "report.pdf"
|
||||||
|
media_file.parent.mkdir(parents=True)
|
||||||
|
media_file.write_bytes(b"%PDF-1.4")
|
||||||
|
self._patch_roots(monkeypatch)
|
||||||
|
monkeypatch.setenv("HERMES_MEDIA_ALLOW_DIRS", str(extra_root))
|
||||||
|
|
||||||
|
assert BasePlatformAdapter.validate_media_delivery_path(str(media_file)) == str(media_file.resolve())
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# should_send_media_as_audio
|
# should_send_media_as_audio
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -728,4 +794,3 @@ class TestProxyKwargsForAiohttp:
|
||||||
sess_kw, req_kw = proxy_kwargs_for_aiohttp("http://proxy:8080")
|
sess_kw, req_kw = proxy_kwargs_for_aiohttp("http://proxy:8080")
|
||||||
assert sess_kw == {}
|
assert sess_kw == {}
|
||||||
assert req_kw == {"proxy": "http://proxy:8080"}
|
assert req_kw == {"proxy": "http://proxy:8080"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,24 @@ def _event(thread_id=None):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_media_path(tmp_path, monkeypatch, name):
|
||||||
|
root = tmp_path / "media-cache"
|
||||||
|
media_file = root / name
|
||||||
|
media_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
media_file.write_bytes(b"media")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS",
|
||||||
|
(root,),
|
||||||
|
)
|
||||||
|
return media_file.resolve()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_base_adapter_routes_telegram_flac_media_tag_to_document_sender():
|
async def test_base_adapter_routes_telegram_flac_media_tag_to_document_sender(tmp_path, monkeypatch):
|
||||||
adapter = _MediaRoutingAdapter()
|
adapter = _MediaRoutingAdapter()
|
||||||
event = _event()
|
event = _event()
|
||||||
adapter._message_handler = AsyncMock(return_value="MEDIA:/tmp/speech.flac")
|
media_file = _allowed_media_path(tmp_path, monkeypatch, "speech.flac")
|
||||||
|
adapter._message_handler = AsyncMock(return_value=f"MEDIA:{media_file}")
|
||||||
adapter.send_voice = AsyncMock(return_value=SendResult(success=True, message_id="voice"))
|
adapter.send_voice = AsyncMock(return_value=SendResult(success=True, message_id="voice"))
|
||||||
adapter.send_document = AsyncMock(return_value=SendResult(success=True, message_id="doc"))
|
adapter.send_document = AsyncMock(return_value=SendResult(success=True, message_id="doc"))
|
||||||
|
|
||||||
|
|
@ -62,17 +75,18 @@ async def test_base_adapter_routes_telegram_flac_media_tag_to_document_sender():
|
||||||
|
|
||||||
adapter.send_document.assert_awaited_once_with(
|
adapter.send_document.assert_awaited_once_with(
|
||||||
chat_id="chat-1",
|
chat_id="chat-1",
|
||||||
file_path="/tmp/speech.flac",
|
file_path=str(media_file),
|
||||||
metadata=None,
|
metadata=None,
|
||||||
)
|
)
|
||||||
adapter.send_voice.assert_not_awaited()
|
adapter.send_voice.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_base_adapter_routes_non_voice_telegram_ogg_media_tag_to_document_sender():
|
async def test_base_adapter_routes_non_voice_telegram_ogg_media_tag_to_document_sender(tmp_path, monkeypatch):
|
||||||
adapter = _MediaRoutingAdapter()
|
adapter = _MediaRoutingAdapter()
|
||||||
event = _event()
|
event = _event()
|
||||||
adapter._message_handler = AsyncMock(return_value="MEDIA:/tmp/speech.ogg")
|
media_file = _allowed_media_path(tmp_path, monkeypatch, "speech.ogg")
|
||||||
|
adapter._message_handler = AsyncMock(return_value=f"MEDIA:{media_file}")
|
||||||
adapter.send_voice = AsyncMock(return_value=SendResult(success=True, message_id="voice"))
|
adapter.send_voice = AsyncMock(return_value=SendResult(success=True, message_id="voice"))
|
||||||
adapter.send_document = AsyncMock(return_value=SendResult(success=True, message_id="doc"))
|
adapter.send_document = AsyncMock(return_value=SendResult(success=True, message_id="doc"))
|
||||||
|
|
||||||
|
|
@ -80,18 +94,19 @@ async def test_base_adapter_routes_non_voice_telegram_ogg_media_tag_to_document_
|
||||||
|
|
||||||
adapter.send_document.assert_awaited_once_with(
|
adapter.send_document.assert_awaited_once_with(
|
||||||
chat_id="chat-1",
|
chat_id="chat-1",
|
||||||
file_path="/tmp/speech.ogg",
|
file_path=str(media_file),
|
||||||
metadata=None,
|
metadata=None,
|
||||||
)
|
)
|
||||||
adapter.send_voice.assert_not_awaited()
|
adapter.send_voice.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_base_adapter_routes_voice_tagged_telegram_ogg_media_tag_to_voice_sender():
|
async def test_base_adapter_routes_voice_tagged_telegram_ogg_media_tag_to_voice_sender(tmp_path, monkeypatch):
|
||||||
adapter = _MediaRoutingAdapter()
|
adapter = _MediaRoutingAdapter()
|
||||||
event = _event()
|
event = _event()
|
||||||
|
media_file = _allowed_media_path(tmp_path, monkeypatch, "speech.ogg")
|
||||||
adapter._message_handler = AsyncMock(
|
adapter._message_handler = AsyncMock(
|
||||||
return_value="[[audio_as_voice]]\nMEDIA:/tmp/speech.ogg"
|
return_value=f"[[audio_as_voice]]\nMEDIA:{media_file}"
|
||||||
)
|
)
|
||||||
adapter.send_voice = AsyncMock(return_value=SendResult(success=True, message_id="voice"))
|
adapter.send_voice = AsyncMock(return_value=SendResult(success=True, message_id="voice"))
|
||||||
adapter.send_document = AsyncMock(return_value=SendResult(success=True, message_id="doc"))
|
adapter.send_document = AsyncMock(return_value=SendResult(success=True, message_id="doc"))
|
||||||
|
|
@ -100,7 +115,7 @@ async def test_base_adapter_routes_voice_tagged_telegram_ogg_media_tag_to_voice_
|
||||||
|
|
||||||
adapter.send_voice.assert_awaited_once_with(
|
adapter.send_voice.assert_awaited_once_with(
|
||||||
chat_id="chat-1",
|
chat_id="chat-1",
|
||||||
audio_path="/tmp/speech.ogg",
|
audio_path=str(media_file),
|
||||||
metadata=None,
|
metadata=None,
|
||||||
)
|
)
|
||||||
adapter.send_document.assert_not_awaited()
|
adapter.send_document.assert_not_awaited()
|
||||||
|
|
@ -117,8 +132,9 @@ def _fake_runner(thread_meta):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sender():
|
async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sender(tmp_path, monkeypatch):
|
||||||
event = _event(thread_id="topic-1")
|
event = _event(thread_id="topic-1")
|
||||||
|
media_file = _allowed_media_path(tmp_path, monkeypatch, "speech.flac")
|
||||||
adapter = SimpleNamespace(
|
adapter = SimpleNamespace(
|
||||||
name="test",
|
name="test",
|
||||||
extract_media=BasePlatformAdapter.extract_media,
|
extract_media=BasePlatformAdapter.extract_media,
|
||||||
|
|
@ -132,22 +148,23 @@ async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sen
|
||||||
|
|
||||||
await GatewayRunner._deliver_media_from_response(
|
await GatewayRunner._deliver_media_from_response(
|
||||||
_fake_runner({"thread_id": "topic-1"}),
|
_fake_runner({"thread_id": "topic-1"}),
|
||||||
"MEDIA:/tmp/speech.flac",
|
f"MEDIA:{media_file}",
|
||||||
event,
|
event,
|
||||||
adapter,
|
adapter,
|
||||||
)
|
)
|
||||||
|
|
||||||
adapter.send_document.assert_awaited_once_with(
|
adapter.send_document.assert_awaited_once_with(
|
||||||
chat_id="chat-1",
|
chat_id="chat-1",
|
||||||
file_path="/tmp/speech.flac",
|
file_path=str(media_file),
|
||||||
metadata={"thread_id": "topic-1"},
|
metadata={"thread_id": "topic-1"},
|
||||||
)
|
)
|
||||||
adapter.send_voice.assert_not_awaited()
|
adapter.send_voice.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_streaming_delivery_routes_non_voice_telegram_ogg_media_tag_to_document_sender():
|
async def test_streaming_delivery_routes_non_voice_telegram_ogg_media_tag_to_document_sender(tmp_path, monkeypatch):
|
||||||
event = _event(thread_id="topic-1")
|
event = _event(thread_id="topic-1")
|
||||||
|
media_file = _allowed_media_path(tmp_path, monkeypatch, "speech.ogg")
|
||||||
adapter = SimpleNamespace(
|
adapter = SimpleNamespace(
|
||||||
name="test",
|
name="test",
|
||||||
extract_media=BasePlatformAdapter.extract_media,
|
extract_media=BasePlatformAdapter.extract_media,
|
||||||
|
|
@ -161,24 +178,25 @@ async def test_streaming_delivery_routes_non_voice_telegram_ogg_media_tag_to_doc
|
||||||
|
|
||||||
await GatewayRunner._deliver_media_from_response(
|
await GatewayRunner._deliver_media_from_response(
|
||||||
_fake_runner({"thread_id": "topic-1"}),
|
_fake_runner({"thread_id": "topic-1"}),
|
||||||
"MEDIA:/tmp/speech.ogg",
|
f"MEDIA:{media_file}",
|
||||||
event,
|
event,
|
||||||
adapter,
|
adapter,
|
||||||
)
|
)
|
||||||
|
|
||||||
adapter.send_document.assert_awaited_once_with(
|
adapter.send_document.assert_awaited_once_with(
|
||||||
chat_id="chat-1",
|
chat_id="chat-1",
|
||||||
file_path="/tmp/speech.ogg",
|
file_path=str(media_file),
|
||||||
metadata={"thread_id": "topic-1"},
|
metadata={"thread_id": "topic-1"},
|
||||||
)
|
)
|
||||||
adapter.send_voice.assert_not_awaited()
|
adapter.send_voice.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_streaming_delivery_routes_telegram_mp3_media_tag_to_voice_sender():
|
async def test_streaming_delivery_routes_telegram_mp3_media_tag_to_voice_sender(tmp_path, monkeypatch):
|
||||||
"""MP3 audio on Telegram must go through send_voice (which routes to
|
"""MP3 audio on Telegram must go through send_voice (which routes to
|
||||||
sendAudio internally); Telegram accepts MP3 for the audio player."""
|
sendAudio internally); Telegram accepts MP3 for the audio player."""
|
||||||
event = _event(thread_id="topic-1")
|
event = _event(thread_id="topic-1")
|
||||||
|
media_file = _allowed_media_path(tmp_path, monkeypatch, "speech.mp3")
|
||||||
adapter = SimpleNamespace(
|
adapter = SimpleNamespace(
|
||||||
name="test",
|
name="test",
|
||||||
extract_media=BasePlatformAdapter.extract_media,
|
extract_media=BasePlatformAdapter.extract_media,
|
||||||
|
|
@ -192,14 +210,47 @@ async def test_streaming_delivery_routes_telegram_mp3_media_tag_to_voice_sender(
|
||||||
|
|
||||||
await GatewayRunner._deliver_media_from_response(
|
await GatewayRunner._deliver_media_from_response(
|
||||||
_fake_runner({"thread_id": "topic-1"}),
|
_fake_runner({"thread_id": "topic-1"}),
|
||||||
"MEDIA:/tmp/speech.mp3",
|
f"MEDIA:{media_file}",
|
||||||
event,
|
event,
|
||||||
adapter,
|
adapter,
|
||||||
)
|
)
|
||||||
|
|
||||||
adapter.send_voice.assert_awaited_once_with(
|
adapter.send_voice.assert_awaited_once_with(
|
||||||
chat_id="chat-1",
|
chat_id="chat-1",
|
||||||
audio_path="/tmp/speech.mp3",
|
audio_path=str(media_file),
|
||||||
metadata={"thread_id": "topic-1"},
|
metadata={"thread_id": "topic-1"},
|
||||||
)
|
)
|
||||||
adapter.send_document.assert_not_awaited()
|
adapter.send_document.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_streaming_delivery_blocks_media_path_outside_allowed_roots(tmp_path, monkeypatch):
|
||||||
|
event = _event(thread_id="topic-1")
|
||||||
|
allowed_root = tmp_path / "media-cache"
|
||||||
|
allowed_root.mkdir()
|
||||||
|
secret = tmp_path / "outside.pdf"
|
||||||
|
secret.write_bytes(b"%PDF secret")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS",
|
||||||
|
(allowed_root,),
|
||||||
|
)
|
||||||
|
adapter = SimpleNamespace(
|
||||||
|
name="test",
|
||||||
|
extract_media=BasePlatformAdapter.extract_media,
|
||||||
|
extract_images=BasePlatformAdapter.extract_images,
|
||||||
|
extract_local_files=BasePlatformAdapter.extract_local_files,
|
||||||
|
send_voice=AsyncMock(return_value=SendResult(success=True, message_id="voice")),
|
||||||
|
send_document=AsyncMock(return_value=SendResult(success=True, message_id="doc")),
|
||||||
|
send_image_file=AsyncMock(return_value=SendResult(success=True, message_id="image")),
|
||||||
|
send_video=AsyncMock(return_value=SendResult(success=True, message_id="video")),
|
||||||
|
)
|
||||||
|
|
||||||
|
await GatewayRunner._deliver_media_from_response(
|
||||||
|
_fake_runner({"thread_id": "topic-1"}),
|
||||||
|
f"MEDIA:{secret}",
|
||||||
|
event,
|
||||||
|
adapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter.send_document.assert_not_awaited()
|
||||||
|
adapter.send_voice.assert_not_awaited()
|
||||||
|
|
|
||||||
|
|
@ -377,6 +377,37 @@ class TestSendMessageTool:
|
||||||
user_id="user-123",
|
user_id="user-123",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_media_tag_outside_allowed_roots_is_not_sent(self, tmp_path):
|
||||||
|
config, telegram_cfg = _make_config()
|
||||||
|
secret = tmp_path / "secret.pdf"
|
||||||
|
secret.write_bytes(b"%PDF secret")
|
||||||
|
|
||||||
|
with patch("gateway.config.load_gateway_config", return_value=config), \
|
||||||
|
patch("tools.interrupt.is_interrupted", return_value=False), \
|
||||||
|
patch("model_tools._run_async", side_effect=_run_async_immediately), \
|
||||||
|
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \
|
||||||
|
patch("gateway.mirror.mirror_to_session", return_value=True):
|
||||||
|
result = json.loads(
|
||||||
|
send_message_tool(
|
||||||
|
{
|
||||||
|
"action": "send",
|
||||||
|
"target": "telegram:12345",
|
||||||
|
"message": f"hello\nMEDIA:{secret}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
send_mock.assert_awaited_once_with(
|
||||||
|
Platform.TELEGRAM,
|
||||||
|
telegram_cfg,
|
||||||
|
"12345",
|
||||||
|
"hello",
|
||||||
|
thread_id=None,
|
||||||
|
media_files=[],
|
||||||
|
force_document=False,
|
||||||
|
)
|
||||||
|
|
||||||
def test_top_level_send_failure_redacts_query_token(self):
|
def test_top_level_send_failure_redacts_query_token(self):
|
||||||
config, _telegram_cfg = _make_config()
|
config, _telegram_cfg = _make_config()
|
||||||
leaked = "very-secret-query-token-123456"
|
leaked = "very-secret-query-token-123456"
|
||||||
|
|
@ -2652,4 +2683,3 @@ class TestSendTelegramThreadNotFoundRetry:
|
||||||
finally:
|
finally:
|
||||||
if media_path and os.path.exists(media_path):
|
if media_path and os.path.exists(media_path):
|
||||||
os.unlink(media_path)
|
os.unlink(media_path)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ SEND_MESSAGE_SCHEMA = {
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The message text to send. To send an image or file, include MEDIA:<local_path> (e.g. 'MEDIA:/tmp/hermes/cache/img_xxx.jpg') in the message — the platform will deliver it as a native media attachment."
|
"description": "The message text to send. To send an image or file, include MEDIA:<local_path> for a file under a Hermes media cache or HERMES_MEDIA_ALLOW_DIRS — the platform will deliver it as a native media attachment."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": []
|
"required": []
|
||||||
|
|
@ -251,6 +251,7 @@ def _handle_send(args):
|
||||||
force_document_attachments = "[[as_document]]" in message
|
force_document_attachments = "[[as_document]]" in message
|
||||||
|
|
||||||
media_files, cleaned_message = BasePlatformAdapter.extract_media(message)
|
media_files, cleaned_message = BasePlatformAdapter.extract_media(message)
|
||||||
|
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
|
||||||
mirror_text = cleaned_message.strip() or _describe_media_for_mirror(media_files)
|
mirror_text = cleaned_message.strip() or _describe_media_for_mirror(media_files)
|
||||||
|
|
||||||
used_home_channel = False
|
used_home_channel = False
|
||||||
|
|
|
||||||
|
|
@ -472,6 +472,7 @@ async def _handle_yb_send_dm(args, **kw):
|
||||||
embedded_media, message = BasePlatformAdapter.extract_media(message)
|
embedded_media, message = BasePlatformAdapter.extract_media(message)
|
||||||
if embedded_media:
|
if embedded_media:
|
||||||
media_files.extend(embedded_media)
|
media_files.extend(embedded_media)
|
||||||
|
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
|
||||||
|
|
||||||
return tool_result(await send_dm(
|
return tool_result(await send_dm(
|
||||||
group_code=group_code, name=args.get("name", ""),
|
group_code=group_code, name=args.get("name", ""),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue