"""Tests for gateway/platforms/base.py — MessageEvent, media extraction, message truncation."""
import os
import time
from unittest.mock import patch
import pytest
from gateway.platforms.base import (
BasePlatformAdapter,
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE,
MessageEvent,
SendResult,
cache_audio_from_bytes,
cache_image_from_bytes,
cache_video_from_bytes,
safe_url_for_log,
utf16_len,
validate_inbound_media_size,
_log_safe_path,
_prefix_within_utf16_limit,
cache_audio_from_bytes,
)
def test_media_delivery_denies_encrypted_bitwarden_cache(tmp_path, monkeypatch):
"""Encrypted Bitwarden cache is covered by the media credential guard."""
import gateway.platforms.base as base
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setattr(base, "_HERMES_HOME", hermes_home)
monkeypatch.setattr(base, "_HERMES_ROOT", hermes_home)
path = hermes_home / "cache" / "bws_cache.enc.json"
path.parent.mkdir()
path.write_text("encrypted-secret-cache")
assert path in base._media_delivery_denied_paths()
assert base.validate_media_delivery_path(str(path)) is None
class TestInboundMediaSizeCap:
"""gateway.max_inbound_media_bytes caps inbound media buffered into RAM (#13145)."""
_PNG = b"\x89PNG\r\n\x1a\n" + b"x" * 64
def test_default_cap_is_128_mib(self, monkeypatch):
# No config override -> default. Patch loader to return empty config.
import gateway.platforms.base as base
monkeypatch.setattr(base, "get_inbound_media_max_bytes", lambda: base.DEFAULT_INBOUND_MEDIA_MAX_BYTES)
assert base.DEFAULT_INBOUND_MEDIA_MAX_BYTES == 128 * 1024 * 1024
def test_image_bytes_rejected_when_oversized(self, monkeypatch):
import gateway.platforms.base as base
monkeypatch.setattr(base, "get_inbound_media_max_bytes", lambda: 16)
with pytest.raises(ValueError, match="Inbound image payload is too large"):
cache_image_from_bytes(self._PNG, ext=".png")
class TestSecretCaptureGuidance:
def test_gateway_secret_capture_message_points_to_local_setup(self):
message = GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE
assert "local cli" in message.lower()
assert "~/.hermes/.env" in message
class TestSafeUrlForLog:
def test_strips_query_fragment_and_userinfo(self):
url = (
"https://user:pass@example.com/private/path/image.png"
"?X-Amz-Signature=supersecret&token=abc#frag"
)
result = safe_url_for_log(url)
assert result == "https://example.com/.../image.png"
assert "supersecret" not in result
assert "token=abc" not in result
assert "user:pass@" not in result
class TestCacheAudioFromBytes:
def test_sniffs_mp4_quicktime_audio_even_when_ext_is_ogg(self, tmp_path):
payload = b"\x00\x00\x00\x14ftypqt " + b"\x00" * 32
with patch("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path):
result = cache_audio_from_bytes(payload, ext=".ogg")
saved = tmp_path / os.path.basename(result)
assert saved.suffix == ".m4a"
assert saved.read_bytes() == payload
# ---------------------------------------------------------------------------
# MessageEvent — command parsing
# ---------------------------------------------------------------------------
class TestMessageEventIsCommand:
def test_slash_command(self):
event = MessageEvent(text="/new")
assert event.is_command() is True
class TestMessageEventGetCommand:
def test_simple_command(self):
event = MessageEvent(text="/new")
assert event.get_command() == "new"
def test_command_with_args(self):
event = MessageEvent(text="/reset session")
assert event.get_command() == "reset"
def test_not_a_command(self):
event = MessageEvent(text="hello")
assert event.get_command() is None
class TestMessageEventGetCommandArgs:
def test_command_with_args(self):
event = MessageEvent(text="/new session id 123")
assert event.get_command_args() == "session id 123"
# ---------------------------------------------------------------------------
# extract_images
# ---------------------------------------------------------------------------
class TestExtractImages:
def test_no_images(self):
images, cleaned = BasePlatformAdapter.extract_images("Just regular text.")
assert images == []
assert cleaned == "Just regular text."
def test_markdown_image_with_image_ext(self):
content = "Here is a photo: "
images, cleaned = BasePlatformAdapter.extract_images(content)
assert len(images) == 1
assert images[0][0] == "https://example.com/cat.png"
assert images[0][1] == "cat"
assert "![cat]" not in cleaned
def test_fal_media_cdn(self):
content = ""
images, _ = BasePlatformAdapter.extract_images(content)
assert len(images) == 1
assert images[0][0] == "https://fal.media/files/abc123/output.png"
assert images[0][1] == "gen"
def test_replicate_delivery(self):
content = ""
images, _ = BasePlatformAdapter.extract_images(content)
assert len(images) == 1
assert images[0][0] == "https://replicate.delivery/pbxt/abc/output"
assert images[0][1] == ""
def test_html_img_tag(self):
content = 'Check this:
'
images, cleaned = BasePlatformAdapter.extract_images(content)
assert len(images) == 1
assert images[0][0] == "https://example.com/photo.png"
assert images[0][1] == "" # HTML images have no alt text
assert "
/root/.hermes) emits
profile-scoped paths (``/profiles//cache/images/x.png``)
that resolve under ``/root``. ``$HOME`` is NOT that prefix, so the
root-home exception doesn't fire, and the top-level cache allowlist
doesn't cover the profile subdir — the file was silently dropped.
Per-profile cache roots must be allowlisted so it delivers.
"""
self._patch_roots(monkeypatch) # strict on, zero top-level cache roots
# Stand-in for the literal /root deny prefix in the deployment.
denied_root = tmp_path / "root"
hermes_root = denied_root / ".hermes"
prof_cache = hermes_root / "profiles" / "myprof" / "cache" / "images"
prof_cache.mkdir(parents=True)
image = prof_cache / "gen.png"
image.write_bytes(b"\x89PNG\r\n\x1a\n")
# $HOME is NOT the denied prefix (mirrors HOME=/opt/data/home).
fake_home = tmp_path / "opt" / "data" / "home"
fake_home.mkdir(parents=True)
monkeypatch.setenv("HOME", str(fake_home))
monkeypatch.setattr(
"gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES",
(str(denied_root),),
)
monkeypatch.setattr(
"gateway.platforms.base._HERMES_ROOT", hermes_root
)
assert (
BasePlatformAdapter.validate_media_delivery_path(str(image))
== str(image.resolve())
)
def test_root_home_workdir_symlink_to_credential_blocked(self, tmp_path, monkeypatch):
"""A symlink in the workdir pointing at a credential is rejected on its
resolved target, even under the $HOME exception.
"""
self._patch_roots(monkeypatch)
fake_home = tmp_path / "root"
ssh_dir = fake_home / ".ssh"
ssh_dir.mkdir(parents=True)
key = ssh_dir / "id_rsa"
key.write_bytes(b"-----BEGIN OPENSSH PRIVATE KEY-----")
workdir = fake_home / "work"
workdir.mkdir()
link = workdir / "innocent.pdf"
link.symlink_to(key)
monkeypatch.setenv("HOME", str(fake_home))
monkeypatch.setattr(
"gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES",
(str(fake_home),),
)
assert BasePlatformAdapter.validate_media_delivery_path(str(link)) is None
class TestDockerContainerMediaPathTranslation:
"""MEDIA:/workspace (and configured mounts) must resolve to host paths."""
def test_configured_workspace_mount_translates(self, tmp_path, monkeypatch):
import json
host_ws = tmp_path / "host-ws"
host_ws.mkdir()
media = host_ws / "shot.png"
media.write_bytes(b"\x89PNG\r\n\x1a\n")
monkeypatch.setenv(
"TERMINAL_DOCKER_VOLUMES",
json.dumps([f"{host_ws}:/workspace"]),
)
monkeypatch.delenv("TERMINAL_ENV", raising=False)
assert BasePlatformAdapter.validate_media_delivery_path(
"/workspace/shot.png"
) == str(media.resolve())
def test_configured_output_mount_translates(self, tmp_path, monkeypatch):
import json
host_out = tmp_path / "documents"
host_out.mkdir()
media = host_out / "report.pdf"
media.write_bytes(b"%PDF-1.4")
monkeypatch.setenv(
"TERMINAL_DOCKER_VOLUMES",
json.dumps([f"{host_out}:/output"]),
)
assert BasePlatformAdapter.validate_media_delivery_path(
"/output/report.pdf"
) == str(media.resolve())
def test_longest_prefix_wins(self, tmp_path, monkeypatch):
import json
host_a = tmp_path / "a"
host_b = tmp_path / "b"
host_a.mkdir()
host_b.mkdir()
nested = host_b / "file.png"
nested.write_bytes(b"png")
monkeypatch.setenv(
"TERMINAL_DOCKER_VOLUMES",
json.dumps([
f"{host_a}:/data",
f"{host_b}:/data/nested",
]),
)
assert BasePlatformAdapter.validate_media_delivery_path(
"/data/nested/file.png"
) == str(nested.resolve())
def test_default_persistent_workspace_fallback(self, tmp_path, monkeypatch):
sandbox = tmp_path / "sandboxes"
ws = sandbox / "docker" / "default" / "workspace"
ws.mkdir(parents=True)
media = ws / "out.png"
media.write_bytes(b"png")
monkeypatch.setenv("TERMINAL_ENV", "docker")
monkeypatch.setenv("TERMINAL_CONTAINER_PERSISTENT", "true")
monkeypatch.setenv("TERMINAL_SANDBOX_DIR", str(sandbox))
monkeypatch.delenv("TERMINAL_DOCKER_VOLUMES", raising=False)
monkeypatch.delenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", raising=False)
assert BasePlatformAdapter.validate_media_delivery_path(
"/workspace/out.png"
) == str(media.resolve())
def test_unmapped_container_path_fails(self, monkeypatch):
monkeypatch.delenv("TERMINAL_DOCKER_VOLUMES", raising=False)
monkeypatch.delenv("TERMINAL_ENV", raising=False)
assert BasePlatformAdapter.validate_media_delivery_path("/workspace/nope.png") is None
def test_persistent_home_root_write_translates(self, tmp_path, monkeypatch):
"""An agent writing /root/out.png in a persistent container produced a
real host file under /docker/default/home — deliver it."""
sandbox = tmp_path / "sandboxes"
home = sandbox / "docker" / "default" / "home"
home.mkdir(parents=True)
media = home / "out.png"
media.write_bytes(b"\x89PNG\r\n\x1a\n")
monkeypatch.setenv("TERMINAL_ENV", "docker")
monkeypatch.setenv("TERMINAL_CONTAINER_PERSISTENT", "true")
monkeypatch.setenv("TERMINAL_SANDBOX_DIR", str(sandbox))
monkeypatch.delenv("TERMINAL_DOCKER_VOLUMES", raising=False)
assert BasePlatformAdapter.validate_media_delivery_path(
"/root/out.png"
) == str(media.resolve())
def test_cache_dir_container_path_translates_to_host_cache(self, tmp_path, monkeypatch):
"""MEDIA:/root/.hermes/cache/images/... (the agent_visible_image path
under docker) must translate to the HOST cache file, not the sandbox
home copy."""
hermes_home = tmp_path / ".hermes"
cache = hermes_home / "cache" / "images"
cache.mkdir(parents=True)
media = cache / "generated.png"
media.write_bytes(b"\x89PNG\r\n\x1a\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("TERMINAL_ENV", "docker")
monkeypatch.delenv("TERMINAL_DOCKER_VOLUMES", raising=False)
assert BasePlatformAdapter.validate_media_delivery_path(
"/root/.hermes/cache/images/generated.png"
) == str(media.resolve())
def test_container_credential_path_never_translates_through_home(self, tmp_path, monkeypatch):
"""/root/.hermes/* outside a cache mount (the sandbox's credential
surface: .env, auth.json) must NOT resolve through the persistent
home mount — those host-side copies sit outside the credential
denylist prefixes and would otherwise deliver."""
sandbox = tmp_path / "sandboxes"
home = sandbox / "docker" / "default" / "home"
secret = home / ".hermes"
secret.mkdir(parents=True)
(secret / "auth.json").write_text('{"token": "SECRET"}')
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("TERMINAL_ENV", "docker")
monkeypatch.setenv("TERMINAL_CONTAINER_PERSISTENT", "true")
monkeypatch.setenv("TERMINAL_SANDBOX_DIR", str(sandbox))
monkeypatch.delenv("TERMINAL_DOCKER_VOLUMES", raising=False)
assert BasePlatformAdapter.validate_media_delivery_path(
"/root/.hermes/auth.json"
) is None
# ---------------------------------------------------------------------------
# should_send_media_as_audio
# ---------------------------------------------------------------------------
class TestShouldSendMediaAsAudio:
"""Audio-routing policy shared by gateway + scheduler + send_message."""
def test_unknown_extension_returns_false(self):
from gateway.platforms.base import should_send_media_as_audio
assert should_send_media_as_audio(None, ".png") is False
assert should_send_media_as_audio("telegram", ".pdf") is False
def test_telegram_ogg_opus_only_when_voice_flagged(self):
from gateway.platforms.base import should_send_media_as_audio
assert should_send_media_as_audio("telegram", ".ogg", is_voice=True) is True
assert should_send_media_as_audio("telegram", ".opus", is_voice=True) is True
assert should_send_media_as_audio("telegram", ".ogg") is False
assert should_send_media_as_audio("telegram", ".opus") is False
# ---------------------------------------------------------------------------
# truncate_message
# ---------------------------------------------------------------------------
class TestIsSenderAuthorized:
"""``_is_sender_authorized`` is a tri-state: True / False / unknown.
Callers gate credentialed side effects on an explicit ``is True``, so a
truthy non-boolean must resolve to unknown rather than being coerced
into an authorization by ``bool()``.
"""
def _adapter(self):
class StubAdapter(BasePlatformAdapter):
async def connect(self, *, is_reconnect: bool = False):
return True
async def disconnect(self):
pass
async def send(self, *a, **kw):
pass
async def get_chat_info(self, *a):
return {}
from gateway.config import Platform, PlatformConfig
return StubAdapter(config=PlatformConfig(enabled=True, token="test"),
platform=Platform.TELEGRAM)
def test_no_check_registered_is_unknown(self):
assert self._adapter()._is_sender_authorized("user") is None
def test_empty_user_id_is_unknown(self):
adapter = self._adapter()
adapter.set_authorization_check(lambda *_a: True)
assert adapter._is_sender_authorized("") is None
def test_true_and_false_propagate(self):
adapter = self._adapter()
adapter.set_authorization_check(lambda *_a: True)
assert adapter._is_sender_authorized("user") is True
adapter.set_authorization_check(lambda *_a: False)
assert adapter._is_sender_authorized("user") is False
@pytest.mark.parametrize("result", ["allowed", 1, object(), [1]])
def test_truthy_non_boolean_is_unknown(self, result):
adapter = self._adapter()
adapter.set_authorization_check(lambda *_a: result)
assert adapter._is_sender_authorized("user") is None
def test_raising_check_is_unknown(self):
def boom(*_a):
raise RuntimeError("auth backend down")
adapter = self._adapter()
adapter.set_authorization_check(boom)
assert adapter._is_sender_authorized("user") is None
def test_check_receives_chat_context(self):
seen = []
adapter = self._adapter()
adapter.set_authorization_check(
lambda user_id, chat_type, chat_id: seen.append((user_id, chat_type, chat_id)) or True
)
adapter._is_sender_authorized("user", "group", "chan")
assert seen == [("user", "group", "chan")]
class TestTruncateMessage:
def _adapter(self):
"""Create a minimal adapter instance for testing static/instance methods."""
class StubAdapter(BasePlatformAdapter):
async def connect(self, *, is_reconnect: bool = False):
return True
async def disconnect(self):
pass
async def send(self, *a, **kw):
pass
async def get_chat_info(self, *a):
return {}
from gateway.config import Platform, PlatformConfig
config = PlatformConfig(enabled=True, token="test")
return StubAdapter(config=config, platform=Platform.TELEGRAM)
def test_short_message_single_chunk(self):
adapter = self._adapter()
chunks = adapter.truncate_message("Hello world", max_length=100)
assert chunks == ["Hello world"]
def test_exact_length_single_chunk(self):
adapter = self._adapter()
msg = "x" * 100
chunks = adapter.truncate_message(msg, max_length=100)
assert chunks == [msg]
@staticmethod
def _truncate_with_timeout(content, max_length, *, len_fn=None, timeout=3.0):
"""Run truncate_message on a worker thread; fail if it doesn't return.
Guards against the regression where a pathologically small max_length
made the split loop never consume any input and spin forever.
"""
import threading
box: dict = {}
def _run():
box["result"] = BasePlatformAdapter.truncate_message(
content, max_length, len_fn=len_fn
)
t = threading.Thread(target=_run, daemon=True)
t.start()
t.join(timeout=timeout)
assert not t.is_alive(), (
f"truncate_message hung (infinite loop) for max_length={max_length}"
)
return box["result"]
def test_pathological_small_max_length_terminates(self):
# max_length 0 and 1 previously drove the split loop into an unbounded
# hang (headroom -> 0, split_at -> 0, remaining never shrinks). It must
# terminate and preserve every character across the chunks.
import re
for max_length in (0, 1, 2):
chunks = self._truncate_with_timeout("abcdefghij", max_length)
assert chunks, f"no chunks for max_length={max_length}"
reassembled = "".join(
re.sub(r"\s*\(\d+/\d+\)$", "", c) for c in chunks
)
for ch in "abcdefghij":
assert ch in reassembled, f"char {ch!r} lost at max_length={max_length}"
def test_code_block_language_tag_carried(self):
adapter = self._adapter()
msg = "Start\n```javascript\n" + "console.log('x');\n" * 80 + "```\nEnd"
chunks = adapter.truncate_message(msg, max_length=300)
if len(chunks) > 1:
# At least one continuation chunk should reopen with ```javascript
reopened_with_lang = any("```javascript" in chunk for chunk in chunks[1:])
assert reopened_with_lang, (
"No continuation chunk reopened with language tag"
)
# ---------------------------------------------------------------------------
# _get_human_delay
# ---------------------------------------------------------------------------
class TestGetHumanDelay:
def test_natural_mode_ignores_malformed_custom_env_vars(self):
env = {
"HERMES_HUMAN_DELAY_MODE": "natural",
"HERMES_HUMAN_DELAY_MIN_MS": "oops",
"HERMES_HUMAN_DELAY_MAX_MS": "still-bad",
}
with patch.dict(os.environ, env):
delay = BasePlatformAdapter._get_human_delay()
assert 0.8 <= delay <= 2.5
def test_custom_mode_tolerates_malformed_env_vars(self):
env = {
"HERMES_HUMAN_DELAY_MODE": "custom",
"HERMES_HUMAN_DELAY_MIN_MS": "oops",
"HERMES_HUMAN_DELAY_MAX_MS": "still-bad",
}
with patch.dict(os.environ, env):
# falls back to the custom-mode defaults instead of crashing
delay = BasePlatformAdapter._get_human_delay()
assert 0.8 <= delay <= 2.5
# ---------------------------------------------------------------------------
# utf16_len / _prefix_within_utf16_limit / truncate_message with len_fn
# ---------------------------------------------------------------------------
# Ported from nearai/ironclaw#2304 — Telegram counts message length in UTF-16
# code units, not Unicode code-points. Astral-plane characters (emoji, CJK
# Extension B) are surrogate pairs: 1 Python char but 2 UTF-16 units.
class TestUtf16Len:
"""Verify the UTF-16 length helper."""
def test_ascii(self):
assert utf16_len("hello") == 5
def test_bmp_cjk(self):
# CJK ideographs in the BMP are 1 code unit each
assert utf16_len("你好") == 2
class TestPrefixWithinUtf16Limit:
"""Verify UTF-16-aware prefix truncation."""
def test_fits_entirely(self):
assert _prefix_within_utf16_limit("hello", 10) == "hello"
def test_all_emoji(self):
msg = "😀" * 10 # 20 UTF-16 units
result = _prefix_within_utf16_limit(msg, 6)
assert result == "😀😀😀"
assert utf16_len(result) == 6
class TestTruncateMessageUtf16:
"""Verify truncate_message respects UTF-16 lengths when len_fn=utf16_len."""
def test_short_emoji_message_no_split(self):
"""A short message under the UTF-16 limit should not be split."""
msg = "Hello 😀 world"
chunks = BasePlatformAdapter.truncate_message(msg, 4096, len_fn=utf16_len)
assert len(chunks) == 1
assert chunks[0] == msg
def test_emoji_near_limit_triggers_split(self):
"""A message at 4096 codepoints but >4096 UTF-16 units must split."""
# 2049 emoji = 2049 codepoints but 4098 UTF-16 units → exceeds 4096
msg = "😀" * 2049
assert len(msg) == 2049 # Python len sees 2049 chars
assert utf16_len(msg) == 4098 # but it's 4098 UTF-16 units
# Without UTF-16 awareness, this would NOT split (2049 < 4096)
chunks_naive = BasePlatformAdapter.truncate_message(msg, 4096)
assert len(chunks_naive) == 1, "Without len_fn, no split expected"
# With UTF-16 awareness, it MUST split
chunks = BasePlatformAdapter.truncate_message(msg, 4096, len_fn=utf16_len)
assert len(chunks) > 1, "With utf16_len, message should be split"
# Each chunk must fit within the UTF-16 limit
for i, chunk in enumerate(chunks):
assert utf16_len(chunk) <= 4096, (
f"Chunk {i} exceeds 4096 UTF-16 units: {utf16_len(chunk)}"
)
class TestProxyKwargsForAiohttp:
"""Verify proxy_kwargs_for_aiohttp routes all schemes through ProxyConnector."""
def test_http_proxy_uses_connector_when_aiohttp_socks_available(self):
pytest.importorskip("aiohttp_socks")
from unittest.mock import MagicMock
from gateway.platforms.base import proxy_kwargs_for_aiohttp
sentinel = MagicMock(name="ProxyConnector")
with patch("aiohttp_socks.ProxyConnector.from_url", return_value=sentinel):
sess_kw, req_kw = proxy_kwargs_for_aiohttp("http://proxy:8080")
assert sess_kw.get("connector") is sentinel, (
"HTTP proxy must use ProxyConnector so libraries that don't "
"forward per-request proxy= kwargs still route through the proxy"
)
assert req_kw == {}
class TestMediaDeliveryDiagnosability:
"""Diagnosable rejection logging + crafted-path robustness (#33251)."""
def test_rejected_path_appears_in_log(self, tmp_path, caplog):
outside = tmp_path / "outside.ogg"
outside.write_bytes(b"OggS")
with patch.dict(os.environ, {"HERMES_MEDIA_DELIVERY_STRICT": "1",
"HERMES_MEDIA_TRUST_RECENT_FILES": "0"}), \
patch("gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS", ()):
with caplog.at_level("WARNING"):
out = BasePlatformAdapter.filter_media_delivery_paths([(str(outside), False)])
assert out == []
# The dropped path must be in the log so operators can diagnose it.
assert str(outside) in caplog.text
def test_crafted_null_path_does_not_abort_batch(self, tmp_path, monkeypatch):
"""One crafted ~\\x00 path must not drop every other attachment."""
good = tmp_path / "good.png"
good.write_bytes(b"\x89PNG")
monkeypatch.setenv("HERMES_MEDIA_DELIVERY_STRICT", "0")
out = BasePlatformAdapter.filter_media_delivery_paths([
("~\x00evil.png", False),
(str(good), False),
])
assert out == [(str(good.resolve()), False)]
# ---------------------------------------------------------------------------
# Media-send fallback must not leak host filesystem paths into chat
# ---------------------------------------------------------------------------
class _CapturingAdapter(BasePlatformAdapter):
"""Minimal concrete BasePlatformAdapter that records what send() sees.
The four media-send fallbacks (send_voice, send_video, send_document,
send_image_file) historically forwarded their *_path argument into the
chat text. That argument is a host filesystem path inside the Hermes
cache, so any subclass that fell back to super() — like the Telegram
adapter on a rejected video — would leak the host's directory layout
into the user's chat.
"""
def __init__(self):
from gateway.config import Platform, PlatformConfig
super().__init__(PlatformConfig(enabled=True), Platform.TELEGRAM)
self.sent: list[dict] = []
async def connect(self) -> bool: # pragma: no cover - not exercised
return True
async def disconnect(self) -> None: # pragma: no cover - not exercised
return None
async def get_chat_info(self, chat_id): # pragma: no cover - not exercised
return {"name": chat_id, "type": "dm"}
async def send(self, chat_id, content, reply_to=None, metadata=None):
from gateway.platforms.base import SendResult
self.sent.append({
"chat_id": chat_id,
"content": content,
"reply_to": reply_to,
"metadata": metadata,
})
return SendResult(success=True, message_id="m1")
class TestMediaFallbackDoesNotLeakHostPath:
"""Regression: the four base-class media fallbacks must not echo *_path.
Telegram, Discord, and Slack adapters all fall back to these base
implementations on native-send failure. When they did, the user saw
a chat message like ``🎬 Video: /home/.../hermes/cache/video/abc.mp4``
— a host filesystem path with no actionable information.
"""
SENSITIVE_PATH = "/home/jayne/.hermes/cache/media/sensitive_host_path_abc123.bin"
@pytest.mark.asyncio
async def test_send_document_fallback_includes_explicit_filename_only(self):
"""A caller-supplied file_name is user-facing and may be shown — but
the host file_path argument must still be suppressed."""
adapter = _CapturingAdapter()
result = await adapter.send_document(
chat_id="123",
file_path=self.SENSITIVE_PATH,
file_name="report.pdf",
)
assert result.success
sent_text = adapter.sent[0]["content"]
assert self.SENSITIVE_PATH not in sent_text
assert "/home/" not in sent_text
assert "report.pdf" in sent_text
@pytest.mark.asyncio
async def test_caption_is_preserved_in_fallback(self):
"""The user-supplied caption is still shown — only the path is suppressed."""
adapter = _CapturingAdapter()
await adapter.send_video(
chat_id="123",
video_path=self.SENSITIVE_PATH,
caption="Here's the daily summary.",
)
sent_text = adapter.sent[0]["content"]
assert "Here's the daily summary." in sent_text
assert self.SENSITIVE_PATH not in sent_text
class TestDockerProfileSandboxMediaTranslation:
"""MEDIA from persistent Docker sandboxes must resolve to the host
directory the profile's container actually bind-mounts (#93950).
Contract: persistent Docker is PROFILE-scoped — the default profile (and
CLI) uses the literal ``default`` sandbox, other profiles use
``sanitize_task_id_for_path("profile:")``. Legacy per-session
sandboxes (``session:``) created during the a270c4ade bug window
remain resolvable as a fallback so their files still deliver.
"""
SESSION_KEY = "agent:main:telegram:dm:123456"
@staticmethod
def _sandbox_dir(task_id: str = "default"):
from tools.environments.base import get_sandbox_dir, sanitize_task_id_for_path
name = task_id if task_id == "default" else sanitize_task_id_for_path(task_id)
return get_sandbox_dir() / "docker" / name
def _enable_docker(self, monkeypatch):
monkeypatch.setenv("TERMINAL_ENV", "docker")
monkeypatch.setenv("TERMINAL_CONTAINER_PERSISTENT", "true")
def test_default_profile_workspace_media_translates(self, monkeypatch):
"""A MEDIA tag pointing at the container's /workspace resolves to the
default profile's shared host sandbox — with or without a session
key, since every session of the profile shares one container."""
self._enable_docker(monkeypatch)
workspace = self._sandbox_dir() / "workspace"
workspace.mkdir(parents=True, exist_ok=True)
produced = workspace / "chart.png"
produced.write_bytes(b"png")
with_key = BasePlatformAdapter.validate_media_delivery_path(
"/workspace/chart.png", session_key=self.SESSION_KEY
)
without_key = BasePlatformAdapter.validate_media_delivery_path(
"/workspace/chart.png"
)
assert with_key == without_key == str(produced.resolve())
def test_legacy_session_sandbox_still_resolves(self, monkeypatch):
"""Self-heal for the a270c4ade bug window: files produced in a legacy
per-session sandbox still deliver via the fallback candidate."""
self._enable_docker(monkeypatch)
workspace = self._sandbox_dir(f"session:{self.SESSION_KEY}") / "workspace"
workspace.mkdir(parents=True, exist_ok=True)
produced = workspace / "out.png"
produced.write_bytes(b"png")
assert BasePlatformAdapter.validate_media_delivery_path(
"/workspace/out.png", session_key=self.SESSION_KEY
) == str(produced.resolve())
def test_profile_and_legacy_sandboxes_both_searched(self, monkeypatch):
"""When the profile sandbox exists but the file was produced in a
legacy per-session container, translation still finds it — the dir
existing must not mask the fallback (#93950 follow-up)."""
self._enable_docker(monkeypatch)
(self._sandbox_dir() / "workspace").mkdir(parents=True, exist_ok=True)
legacy_ws = self._sandbox_dir(f"session:{self.SESSION_KEY}") / "workspace"
legacy_ws.mkdir(parents=True, exist_ok=True)
produced = legacy_ws / "old.png"
produced.write_bytes(b"png")
assert BasePlatformAdapter.validate_media_delivery_path(
"/workspace/old.png", session_key=self.SESSION_KEY
) == str(produced.resolve())
def test_home_mount_translates_stray_root_writes(self, monkeypatch):
"""/root/ lands in the profile sandbox's home mount."""
self._enable_docker(monkeypatch)
home = self._sandbox_dir() / "home"
home.mkdir(parents=True, exist_ok=True)
produced = home / "note.txt"
produced.write_text("hi")
assert BasePlatformAdapter.validate_media_delivery_path(
"/root/note.txt", session_key=self.SESSION_KEY
) == str(produced.resolve())
def test_home_credential_surface_still_refused(self, monkeypatch):
"""The /root/.hermes exclusion survives profile scoping: translating
the home mount must never expose the container's secret surface —
in the profile layout AND the legacy session layout."""
self._enable_docker(monkeypatch)
for task in ("default", f"session:{self.SESSION_KEY}"):
secrets = self._sandbox_dir(task) / "home" / ".hermes"
secrets.mkdir(parents=True, exist_ok=True)
(secrets / "auth.json").write_text("{}")
assert (
BasePlatformAdapter.validate_media_delivery_path(
"/root/.hermes/auth.json", session_key=self.SESSION_KEY
)
is None
)
def test_filter_passes_session_key_through(self, monkeypatch):
"""The adapter filter used by _process_message_background forwards the
key, so legacy-sandbox MEDIA tags survive filtering in one hop."""
self._enable_docker(monkeypatch)
workspace = self._sandbox_dir(f"session:{self.SESSION_KEY}") / "workspace"
workspace.mkdir(parents=True, exist_ok=True)
produced = workspace / "clip.mp4"
produced.write_bytes(b"mp4")
kept = BasePlatformAdapter.filter_media_delivery_paths(
[("/workspace/clip.mp4", False)], session_key=self.SESSION_KEY
)
assert kept == [(str(produced.resolve()), False)]
def test_unresolved_docker_media_names_the_cause(self, monkeypatch, caplog):
"""A container path that resolves in no candidate sandbox must log
WHY it was dropped (sandbox mismatch), not just the generic unsafe-
path line — the silent-drop mode reported in #93950."""
import logging
self._enable_docker(monkeypatch)
with caplog.at_level(logging.WARNING, logger="gateway.platforms.base"):
resolved = BasePlatformAdapter.validate_media_delivery_path(
"/workspace/ghost.png", session_key=self.SESSION_KEY
)
assert resolved is None
assert any(
"did not resolve" in r.message and f"session_key={self.SESSION_KEY}" in r.message
for r in caplog.records
)
class _LockGovernanceProbeAdapter(BasePlatformAdapter):
"""Minimal concrete adapter for platform-lock takeover governance tests."""
async def connect(self, *, is_reconnect: bool = False) -> bool:
return True
async def disconnect(self) -> None:
pass
async def send(self, *args, **kwargs) -> SendResult:
return SendResult(success=True)
async def get_chat_info(self, chat_id: str) -> dict:
return {"name": "probe", "type": "dm"}
class TestPlatformLockTakeoverGovernance:
"""A supervised (non-``--replace``) gateway must never evict a live holder.
Regression for #79048: launchd services with ``KeepAlive=true`` used to be
generated with ``--replace``, re-arming takeover authority on every
respawn. Two profiles sharing one platform token (e.g. the same Discord
bot) would then terminate each other in an endless mutual-eviction loop.
The runtime guard is the adapter's ``_platform_lock_takeover_allowed``
bit — only an explicit ``gateway run --replace`` startup arms it. Without
that authority a live cross-home holder must be left alone and reported
as a retryable failure, never terminated.
"""
def _make_adapter(self, *, takeover_allowed: bool):
from gateway.config import Platform, PlatformConfig
adapter = _LockGovernanceProbeAdapter(
config=PlatformConfig(), platform=Platform.DISCORD
)
adapter._platform_lock_takeover_allowed = takeover_allowed
return adapter
def test_no_takeover_without_replace_authority(self, tmp_path, monkeypatch):
from gateway import status
existing = {
"pid": 4242,
"start_time": 123,
"home": str(tmp_path / "other-profile-home"),
}
monkeypatch.setattr(
status,
"acquire_scoped_lock",
lambda scope, identity, metadata=None: (False, existing),
)
takeover_calls = []
monkeypatch.setattr(
status,
"take_over_scoped_lock_holder",
lambda existing: takeover_calls.append(existing) or 4242,
)
monkeypatch.setattr(status, "write_runtime_status", lambda **kw: None)
adapter = self._make_adapter(takeover_allowed=False)
# Ordinary (supervised) start: the live cross-home holder must be
# left untouched — the gateway fails retryably instead of killing it.
assert adapter._acquire_platform_lock("discord-token", "tok", "Discord") is False
assert takeover_calls == []
assert adapter.fatal_error_retryable is True
assert adapter._fatal_error_code == "discord-token_lock"
def test_takeover_authority_consumed_once(self, tmp_path, monkeypatch):
from gateway import status
existing = {
"pid": 4242,
"start_time": 123,
"home": str(tmp_path / "other-profile-home"),
}
monkeypatch.setattr(
status,
"acquire_scoped_lock",
lambda scope, identity, metadata=None: (False, existing),
)
takeover_calls = []
monkeypatch.setattr(
status,
"take_over_scoped_lock_holder",
lambda existing: takeover_calls.append(existing) or 4242,
)
monkeypatch.setattr(status, "write_runtime_status", lambda **kw: None)
adapter = self._make_adapter(takeover_allowed=True)
# An explicit --replace start may attempt exactly one takeover...
assert adapter._acquire_platform_lock("discord-token", "tok", "Discord") is False
assert len(takeover_calls) == 1
# ...but the authority is consumed, so a reconnect can never evict a
# healthy holder (this is what stops the supervised respawn loop).
assert adapter._acquire_platform_lock("discord-token", "tok", "Discord") is False
assert len(takeover_calls) == 1
assert adapter._platform_lock_takeover_attempted is True