Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions agent/skill_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,11 +528,14 @@ def normalize_skill_lookup_name(identifier: str) -> str:
# (not via get_skills_dir()): callers and tests patch
# ``tools.skills_tool.SKILLS_DIR`` and skill_view() itself resolves
# against that module attribute, so normalization must agree with the
# exact root skill_view() will enforce. Import deferred to avoid a
# exact root skill_view() will enforce. Use _skills_dir() (not the
# module-level SKILLS_DIR) so the live profile-scoped HERMES_HOME is
# always respected in long-lived multi-profile runtimes (#40677).
# Import deferred to avoid a
# module cycle (tools.skills_tool imports agent.skill_utils).
try:
from tools import skills_tool as _skills_tool
primary_root = Path(_skills_tool.SKILLS_DIR)
primary_root = Path(_skills_tool._skills_dir())
except Exception:
primary_root = get_skills_dir()

Expand Down
29 changes: 25 additions & 4 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2464,7 +2464,9 @@ async def send_multiple_images(
self.name, resp.status, image_url[:80],
)
continue
data = await resp.read()
data = await _read_response_bytes_bounded(
resp, _DISCORD_IMAGE_DOWNLOAD_MAX_BYTES
)
ct = resp.headers.get("content-type", "image/png")
ext = "png"
if "jpeg" in ct or "jpg" in ct:
Expand Down Expand Up @@ -3552,7 +3554,9 @@ async def send_image(
if resp.status != 200:
raise Exception(f"Failed to download image: HTTP {resp.status}")

image_data = await resp.read()
image_data = await _read_response_bytes_bounded(
resp, _DISCORD_IMAGE_DOWNLOAD_MAX_BYTES
)

# Determine filename from URL or content type
content_type = resp.headers.get("content-type", "image/png")
Expand Down Expand Up @@ -3631,7 +3635,9 @@ async def send_animation(
if resp.status != 200:
raise Exception(f"Failed to download animation: HTTP {resp.status}")

animation_data = await resp.read()
animation_data = await _read_response_bytes_bounded(
resp, _DISCORD_IMAGE_DOWNLOAD_MAX_BYTES
)

import io
file = discord.File(io.BytesIO(animation_data), filename="animation.gif")
Expand Down Expand Up @@ -5796,7 +5802,9 @@ async def _cache_discord_document(self, att, ext: str) -> bytes:
) as resp:
if resp.status != 200:
raise Exception(f"HTTP {resp.status}")
return await resp.read()
return await _read_response_bytes_bounded(
resp, _DISCORD_ATTACHMENT_DOWNLOAD_MAX_BYTES
)

async def _handle_message(self, message: DiscordMessage, role_authorized: bool = False) -> None:
"""Handle incoming Discord messages."""
Expand Down Expand Up @@ -7436,12 +7444,25 @@ async def on_timeout(self):
_DISCORD_CHANNEL_TYPE_PROBE_CACHE: Dict[str, bool] = {}
_DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES = 1 * 1024 * 1024
_DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES = 8 * 1024
_DISCORD_IMAGE_DOWNLOAD_MAX_BYTES = 50 * 1024 * 1024 # generous limit for images/animations
_DISCORD_ATTACHMENT_DOWNLOAD_MAX_BYTES = 100 * 1024 * 1024 # generous for file attachments


def _remember_channel_is_forum(chat_id: str, is_forum: bool) -> None:
_DISCORD_CHANNEL_TYPE_PROBE_CACHE[str(chat_id)] = bool(is_forum)


async def _read_response_bytes_bounded(resp: Any, limit_bytes: int) -> bytes:
"""Read at most *limit_bytes* from an aiohttp response, raising on overflow."""
data = await resp.content.read(limit_bytes + 1)
if len(data) > limit_bytes:
resp.close()
raise ValueError(
f"Response body exceeded {limit_bytes} bytes ({len(data)} bytes read)"
)
return data


def _probe_is_forum_cached(chat_id: str) -> Optional[bool]:
return _DISCORD_CHANNEL_TYPE_PROBE_CACHE.get(str(chat_id))

Expand Down
28 changes: 28 additions & 0 deletions tests/agent/test_skill_utils_profile_aware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Tests for profile-aware skills dir in skill_utils normalization (#60231)."""

import pytest


class TestSkillUtilsProfileAware:
def test_normalize_uses_skills_dir_not_module_constant(self, monkeypatch, tmp_path):
"""normalize_skill_lookup_name should use _skills_dir(), not SKILLS_DIR."""
profile_a = tmp_path / "profile_a"
profile_b = tmp_path / "profile_b"
(profile_a / "skills").mkdir(parents=True)
(profile_b / "skills").mkdir(parents=True)

# Patch the SKILLS_DIR module attribute to a stale value
monkeypatch.setattr(
"tools.skills_tool.SKILLS_DIR",
profile_a / "skills",
)
monkeypatch.setattr(
"tools.skills_tool._skills_dir",
lambda: profile_b / "skills",
)

from agent.skill_utils import normalize_skill_lookup_name

result = normalize_skill_lookup_name("test-skill")
# Should use profile_b (from _skills_dir()), not profile_a (stale SKILLS_DIR)
assert result is not None
2 changes: 2 additions & 0 deletions tests/gateway/test_discord_attachment_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,8 @@ async def test_fallback_aiohttp_when_safe_url(self):
resp = AsyncMock()
resp.status = 200
resp.read = AsyncMock(return_value=_PDF_BYTES)
resp.content = MagicMock()
resp.content.read = AsyncMock(return_value=_PDF_BYTES)
resp.__aenter__ = AsyncMock(return_value=resp)
resp.__aexit__ = AsyncMock(return_value=False)

Expand Down
4 changes: 4 additions & 0 deletions tests/gateway/test_discord_document_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ def _mock_aiohttp_download(raw_bytes: bytes):
resp = AsyncMock()
resp.status = 200
resp.read = AsyncMock(return_value=raw_bytes)
resp.content = MagicMock()
resp.content.read = AsyncMock(return_value=raw_bytes)
resp.__aenter__ = AsyncMock(return_value=resp)
resp.__aexit__ = AsyncMock(return_value=False)

Expand Down Expand Up @@ -344,6 +346,8 @@ def get(self, url, **kwargs):
resp = AsyncMock()
resp.status = 200
resp.read = AsyncMock(return_value=data)
resp.content = MagicMock()
resp.content.read = AsyncMock(return_value=data)
resp.__aenter__ = AsyncMock(return_value=resp)
resp.__aexit__ = AsyncMock(return_value=False)
return resp
Expand Down
74 changes: 74 additions & 0 deletions tests/gateway/test_discord_image_download_bounds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Tests for bounded HTTP response reads in Discord image/download paths.

Companion to #60122, #60112 (REST body bounding) — extends the same
resource-limiting pattern to image/animation/attachment downloads
in the Discord adapter that were left unbounded.
"""

import asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock, patch

from plugins.platforms.discord.adapter import (
_read_response_bytes_bounded,
_DISCORD_IMAGE_DOWNLOAD_MAX_BYTES,
_DISCORD_ATTACHMENT_DOWNLOAD_MAX_BYTES,
)


class AsyncContextManagerMock:
"""Mock that supports ``async with``."""

def __init__(self, return_value):
self._return_value = return_value

async def __aenter__(self):
return self._return_value

async def __aexit__(self, *args):
pass


class TestReadResponseBytesBounded:
def test_reads_within_limit(self):
resp = MagicMock()
resp.content = MagicMock()
resp.content.read = AsyncMock(return_value=b"x" * 100)

result = asyncio.run(
_read_response_bytes_bounded(resp, 200)
)
assert result == b"x" * 100
resp.content.read.assert_awaited_once_with(201)

def test_raises_on_overflow(self):
resp = MagicMock()
resp.content = MagicMock()
resp.content.read = AsyncMock(return_value=b"x" * 101)
resp.close = MagicMock()

with pytest.raises(ValueError, match="exceeded 100 bytes"):
asyncio.run(
_read_response_bytes_bounded(resp, 100)
)
resp.close.assert_called_once()

def test_exact_limit_passes(self):
resp = MagicMock()
resp.content = MagicMock()
resp.content.read = AsyncMock(return_value=b"x" * 100)

result = asyncio.run(
_read_response_bytes_bounded(resp, 100)
)
assert result == b"x" * 100


class TestImageDownloadLimits:
def test_constants_are_reasonable(self):
assert _DISCORD_IMAGE_DOWNLOAD_MAX_BYTES == 50 * 1024 * 1024
assert _DISCORD_ATTACHMENT_DOWNLOAD_MAX_BYTES == 100 * 1024 * 1024

def test_image_limit_greater_than_zero(self):
assert _DISCORD_IMAGE_DOWNLOAD_MAX_BYTES > 0
assert _DISCORD_ATTACHMENT_DOWNLOAD_MAX_BYTES > 0
10 changes: 9 additions & 1 deletion tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from hermes_cli.env_loader import load_hermes_dotenv
from utils import is_truthy_value
from tools.environments.local import hermes_subprocess_env
from agent.replay_cleanup import sanitize_replay_history
from agent.replay_cleanup import sanitize_replay_history, strip_stale_dangerous_confirmations
from tui_gateway import git_probe
from tui_gateway.transport import (
StdioTransport,
Expand Down Expand Up @@ -5534,6 +5534,10 @@ def _reuse_live_payload(sid: str, session: dict) -> dict:
# not replay the unanswered call forever (#29086).
prefix = display_history[: max(0, len(display_history) - len(raw_history))]
history = sanitize_replay_history(raw_history)
# Strip stale dangerous-confirmation text so that a high-risk
# confirmation phrase spoken in a previous turn does not survive
# a session resume and trigger the destructive action again (#59607).
history = strip_stale_dangerous_confirmations(history, now=time.time())
# Restore the model/provider/reasoning/tier this chat last used so the
# deferred build (and the info below) match the eager path — without them
# the build drops the provider ("No LLM provider configured").
Expand Down Expand Up @@ -5609,6 +5613,10 @@ def _reuse_live_payload(sid: str, session: dict) -> dict:
: max(0, len(display_history) - len(raw_history))
]
history = sanitize_replay_history(raw_history)
# Strip stale dangerous-confirmation text so that a high-risk
# confirmation phrase spoken in a previous turn does not survive
# a session resume and trigger the destructive action again (#59607).
history = strip_stale_dangerous_confirmations(history, now=time.time())
messages = _history_to_messages(display_history)
tokens = _set_session_context(target)
try:
Expand Down
Loading