hermes-agent/tests/gateway/test_raft_adapter.py
srojk34 3817ff180d security(raft): enforce body-size limit on chunked requests
_handle_wake() and _handle_activity() enforced max_body_bytes only via
the Content-Length header. A Transfer-Encoding: chunked request
(content_length=None) or a spoofed small Content-Length bypassed the
cap entirely, letting the actual read be bounded only by aiohttp's
implicit 1 MiB client_max_size default (64x the 16 KB default) — the
same pattern ec29590a0 just fixed for gateway/platforms/webhook.py.

Fix: web.Application(client_max_size=self._max_body_bytes) so aiohttp
enforces the cap on every read path including chunked bodies, catch
HTTPRequestEntityTooLarge -> 413 on both endpoints (was swallowed into
a generic 400), and re-check the actual bytes read as defense in depth.
Exposure here is narrower than the webhook adapter (binds to 127.0.0.1
by default and requires the bridge token), but the bypass is otherwise
identical.
2026-07-05 13:57:37 -07:00

550 lines
19 KiB
Python

"""Tests for the Raft channel adapter."""
import asyncio
import json
import os
from unittest.mock import AsyncMock, patch
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.config import Platform, PlatformConfig
from plugins.platforms.raft.adapter import (
ACTIVITY_DRAIN_SCHEMA,
ACTIVITY_EVENT_SCHEMA,
ActivityQueue,
BRIDGE_TOKEN_HEADER,
DEFAULT_PATH,
RaftAdapter,
_ACTIVE_ADAPTERS,
_ACTIVE_ADAPTERS_LOCK,
_RAFT_CONTEXT_LOCK,
_RAFT_PROMPT_TURN_IDS,
_RAFT_SESSION_IDS,
_RAFT_TURN_IDS,
_has_content_field,
_env_enablement,
_is_connected,
_on_session_start,
_on_pre_llm_call,
_on_pre_tool_call,
_on_post_llm_call,
_on_post_tool_call,
_on_session_end,
_on_session_finalize,
check_raft_requirements,
interactive_setup,
register,
)
from gateway.session import build_session_key
RAFT_CHANNEL_SCHEMA = "raft-channel-wake.v1"
FUTURE_RAFT_CHANNEL_SCHEMA = "raft-channel-wake.v2"
def _make_config(**extra):
data = {
"bridge_token": "bridge-secret",
"runtime_session": "default",
"port": 0,
}
data.update(extra)
return PlatformConfig(enabled=True, extra=data)
def _make_adapter(**extra):
return RaftAdapter(_make_config(**extra))
def _create_app(adapter: RaftAdapter) -> web.Application:
# Mirror connect(): client_max_size enforces the cap on chunked bodies.
app = web.Application(client_max_size=adapter._max_body_bytes)
app.router.add_get("/health", adapter._handle_health)
app.router.add_post(adapter._path, adapter._handle_wake)
app.router.add_post("/activity", adapter._handle_activity)
app.router.add_get("/activity/drain", adapter._handle_activity_drain)
return app
def _activity_event(event_id: str, **overrides):
event = {
"schema": ACTIVITY_EVENT_SCHEMA,
"eventId": event_id,
"sessionId": "session-1",
"hookEventName": "PreToolUse",
"status": "ok",
"occurredAt": "2026-06-16T06:00:00Z",
"toolName": "execute_code",
}
event.update(overrides)
return event
class TestRaftWakePayload:
def test_detects_content_fields(self):
assert _has_content_field({"text": "hello"}) is True
assert _has_content_field({"nested": {"messages": []}}) is True
assert _has_content_field({"eventId": "evt-1", "messageId": "msg-1"}) is False
class TestRaftWakeHttp:
@pytest.mark.asyncio
async def test_send_is_noop_success(self):
adapter = _make_adapter()
result = await adapter.send("default", "hello")
assert result.success is True
assert result.message_id is None
@pytest.mark.asyncio
async def test_rejects_missing_bridge_token(self):
adapter = _make_adapter()
adapter.handle_message = AsyncMock()
app = _create_app(adapter)
async with TestClient(TestServer(app)) as client:
resp = await client.post(DEFAULT_PATH, json={"eventId": "wake-1"})
assert resp.status == 401
body = await resp.json()
assert body["ok"] is False
adapter.handle_message.assert_not_called()
@pytest.mark.asyncio
async def test_rejects_content_bearing_payload(self):
adapter = _make_adapter()
adapter.set_message_handler(AsyncMock())
adapter.handle_message = AsyncMock()
app = _create_app(adapter)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
DEFAULT_PATH,
json={"eventId": "wake-1", "text": "do work"},
headers={BRIDGE_TOKEN_HEADER: "bridge-secret"},
)
assert resp.status == 400
body = await resp.json()
assert body == {"ok": False, "error": "content_not_allowed"}
adapter.handle_message.assert_not_called()
@pytest.mark.asyncio
async def test_returns_not_ready_without_gateway_handler(self):
adapter = _make_adapter()
app = _create_app(adapter)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
DEFAULT_PATH,
json={"eventId": "wake-1"},
headers={BRIDGE_TOKEN_HEADER: "bridge-secret"},
)
assert resp.status == 503
body = await resp.json()
assert body["ok"] is False
assert body["runtimeSession"] == "default"
@pytest.mark.asyncio
@pytest.mark.parametrize("schema", [RAFT_CHANNEL_SCHEMA, FUTURE_RAFT_CHANNEL_SCHEMA])
async def test_accepts_content_free_wake_as_internal_event(self, schema):
adapter = _make_adapter()
adapter.set_message_handler(AsyncMock())
adapter.handle_message = AsyncMock()
app = _create_app(adapter)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
DEFAULT_PATH,
json={
"schema": schema,
"attemptId": "attempt-1",
"eventId": "wake-1",
"messageId": "msg-1",
"agentId": "agent-1",
"profile": "dev",
"coreSessionId": "default",
"adapterInstance": "hermes",
"occurredAt": "2026-06-11T08:00:00Z",
},
headers={BRIDGE_TOKEN_HEADER: "bridge-secret"},
)
assert resp.status == 202
body = await resp.json()
assert body == {"ok": True, "runtimeSession": "default"}
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.internal is True
assert event.message_id == "wake-1"
assert event.raw_message["schema"] == schema
assert event.raw_message["eventId"] == "wake-1"
assert event.raw_message["attemptId"] == "attempt-1"
assert event.raw_message["messageId"] == "msg-1"
assert event.source.platform == Platform("raft")
assert event.source.chat_id == "default"
assert "raft manual get" in event.text
@pytest.mark.asyncio
async def test_busy_session_queues_without_interrupt(self):
handler = AsyncMock()
adapter = _make_adapter()
adapter.set_message_handler(handler)
source = adapter.build_source(
chat_id="default",
chat_name="Raft channel",
chat_type="dm",
user_id="raft-bridge",
user_name="Raft Bridge",
)
session_key = build_session_key(source)
adapter._active_sessions[session_key] = __import__("asyncio").Event()
accepted = await adapter._accept_wake({"eventId": "wake-busy"})
assert accepted is True
handler.assert_not_called()
assert session_key in adapter._pending_messages
pending = adapter._pending_messages[session_key]
assert pending.message_id == "wake-busy"
assert "raft manual get" in pending.text
class TestRaftActivityHttp:
@pytest.mark.asyncio
async def test_activity_endpoint_auth_validation_and_drain(self):
adapter = _make_adapter()
adapter._activity_queue = ActivityQueue(cap=2)
app = _create_app(adapter)
async with TestClient(TestServer(app)) as client:
unauthorized = await client.post("/activity", json=_activity_event("evt-1"))
assert unauthorized.status == 401
unknown = await client.post(
"/activity",
json={**_activity_event("evt-1"), "transcript_path": "/tmp/session.jsonl"},
headers={BRIDGE_TOKEN_HEADER: "bridge-secret"},
)
assert unknown.status == 400
for event_id in ["evt-1", "evt-2", "evt-3"]:
resp = await client.post(
"/activity",
json=_activity_event(event_id),
headers={BRIDGE_TOKEN_HEADER: "bridge-secret"},
)
assert resp.status == 202
drain = await client.get(
"/activity/drain?max=10",
headers={BRIDGE_TOKEN_HEADER: "bridge-secret"},
)
assert drain.status == 200
body = await drain.json()
assert body["schema"] == ACTIVITY_DRAIN_SCHEMA
assert body["dropped"] == 1
assert [event["eventId"] for event in body["events"]] == ["evt-2", "evt-3"]
def test_hook_mapping_reports_only_raft_context(self):
adapter = _make_adapter()
with _RAFT_CONTEXT_LOCK:
_RAFT_PROMPT_TURN_IDS.clear()
_RAFT_SESSION_IDS.clear()
_RAFT_TURN_IDS.clear()
with _ACTIVE_ADAPTERS_LOCK:
_ACTIVE_ADAPTERS.add(adapter)
try:
_on_pre_tool_call(
session_id="session-1",
turn_id="turn-1",
tool_name="execute_code",
args={"cmd": "echo nope"},
)
assert adapter._activity_queue.drain(10)["events"] == []
_on_pre_llm_call(
platform="raft",
session_id="session-1",
turn_id="turn-1",
user_message="run a probe",
)
_on_pre_llm_call(
platform="raft",
session_id="session-1",
turn_id="turn-1",
user_message="run a follow-up LLM call in the same turn",
)
_on_pre_tool_call(
session_id="session-1",
turn_id="turn-1",
tool_name="execute_code",
args={"cmd": "echo ok"},
)
_on_post_tool_call(
session_id="session-1",
turn_id="turn-1",
tool_name="execute_code",
args={"cmd": "echo ok"},
result="ok",
status="ok",
duration_ms=321,
)
_on_post_llm_call(
platform="raft",
session_id="session-1",
turn_id="turn-1",
assistant_response="done",
)
_on_session_end(
platform="raft",
session_id="session-1",
turn_id="turn-1",
completed=True,
interrupted=False,
)
_on_session_finalize(
platform="raft",
session_id="session-1",
reason="shutdown",
)
drain = adapter._activity_queue.drain(10)
finally:
with _ACTIVE_ADAPTERS_LOCK:
_ACTIVE_ADAPTERS.discard(adapter)
with _RAFT_CONTEXT_LOCK:
_RAFT_PROMPT_TURN_IDS.clear()
_RAFT_SESSION_IDS.clear()
_RAFT_TURN_IDS.clear()
assert [event["hookEventName"] for event in drain["events"]] == [
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"Stop",
"SessionEnd",
]
tool_start = drain["events"][1]
assert tool_start["toolName"] == "execute_code"
assert '"cmd": "echo ok"' in tool_start["toolInput"]
tool_result = drain["events"][2]
assert tool_result["durationMs"] == 321
def test_session_start_registers_raft_profile_env_passthrough(self):
import tools.env_passthrough as env_passthrough_mod
from tools.code_execution_tool import _scrub_child_env
from tools.environments.local import _make_run_env
from tools.env_passthrough import clear_env_passthrough, is_env_passthrough
previous_config_passthrough = env_passthrough_mod._config_passthrough
clear_env_passthrough()
env_passthrough_mod._config_passthrough = frozenset()
with _RAFT_CONTEXT_LOCK:
_RAFT_PROMPT_TURN_IDS.clear()
_RAFT_SESSION_IDS.clear()
_RAFT_TURN_IDS.clear()
try:
assert "RAFT_PROFILE" not in _scrub_child_env(
{"RAFT_PROFILE": "dev"},
is_windows=False,
)
_on_session_start(session_id="session-1", turn_id="turn-1")
assert not is_env_passthrough("RAFT_PROFILE")
_on_session_start(platform="raft", session_id="session-1", turn_id="turn-1")
assert is_env_passthrough("RAFT_PROFILE")
assert _scrub_child_env({"RAFT_PROFILE": "dev"}, is_windows=False)["RAFT_PROFILE"] == "dev"
with patch.dict(os.environ, {"PATH": "/usr/bin", "RAFT_PROFILE": "dev"}, clear=True):
assert _make_run_env({})["RAFT_PROFILE"] == "dev"
finally:
clear_env_passthrough()
env_passthrough_mod._config_passthrough = previous_config_passthrough
with _RAFT_CONTEXT_LOCK:
_RAFT_PROMPT_TURN_IDS.clear()
_RAFT_SESSION_IDS.clear()
_RAFT_TURN_IDS.clear()
def test_interrupted_turn_reports_error_stop(self):
adapter = _make_adapter()
with _RAFT_CONTEXT_LOCK:
_RAFT_PROMPT_TURN_IDS.clear()
_RAFT_SESSION_IDS.clear()
_RAFT_TURN_IDS.clear()
with _ACTIVE_ADAPTERS_LOCK:
_ACTIVE_ADAPTERS.add(adapter)
try:
_on_pre_llm_call(
platform="raft",
session_id="session-1",
turn_id="turn-1",
)
_on_session_end(
platform="raft",
session_id="session-1",
turn_id="turn-1",
completed=False,
interrupted=True,
)
drain = adapter._activity_queue.drain(10)
finally:
with _ACTIVE_ADAPTERS_LOCK:
_ACTIVE_ADAPTERS.discard(adapter)
with _RAFT_CONTEXT_LOCK:
_RAFT_PROMPT_TURN_IDS.clear()
_RAFT_SESSION_IDS.clear()
_RAFT_TURN_IDS.clear()
assert [event["hookEventName"] for event in drain["events"]] == [
"UserPromptSubmit",
"Stop",
]
assert drain["events"][1]["status"] == "error"
assert drain["events"][1]["errorClass"] == "interrupted"
class TestBodySize:
"""The wake/activity endpoints enforced max_body_bytes only via the
Content-Length header; a Transfer-Encoding: chunked request
(content_length=None) bypassed the cap entirely and read the full body,
bounded only by aiohttp's implicit 1 MiB default. Mirrors
gateway/platforms/webhook.py's TestBodySize."""
@pytest.mark.asyncio
async def test_wake_chunked_oversized_payload_rejected(self):
adapter = _make_adapter(max_body_bytes=100)
adapter.set_message_handler(AsyncMock())
adapter.handle_message = AsyncMock()
async def _chunked_body():
payload = json.dumps({"eventId": "x" * 500}).encode("utf-8")
for i in range(0, len(payload), 64):
yield payload[i : i + 64]
await asyncio.sleep(0)
app = _create_app(adapter)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
DEFAULT_PATH,
data=_chunked_body(),
headers={
BRIDGE_TOKEN_HEADER: "bridge-secret",
"Content-Type": "application/json",
},
)
assert resp.status == 413
body = await resp.json()
assert body == {"ok": False, "error": "payload_too_large"}
adapter.handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_activity_chunked_oversized_payload_rejected(self):
adapter = _make_adapter(max_body_bytes=100)
async def _chunked_body():
payload = json.dumps(_activity_event("x" * 500)).encode("utf-8")
for i in range(0, len(payload), 64):
yield payload[i : i + 64]
await asyncio.sleep(0)
app = _create_app(adapter)
async with TestClient(TestServer(app)) as client:
resp = await client.post(
"/activity",
data=_chunked_body(),
headers={
BRIDGE_TOKEN_HEADER: "bridge-secret",
"Content-Type": "application/json",
},
)
assert resp.status == 413
body = await resp.json()
assert body == {"ok": False, "error": "payload_too_large"}
assert adapter._activity_queue.drain()["events"] == []
class TestRaftConfig:
def test_env_enablement_auto_enables_with_raft_profile(self, monkeypatch):
monkeypatch.setenv("RAFT_PROFILE", "my-agent")
extra = _env_enablement()
assert extra is not None
assert extra["enabled"] is True
def test_env_enablement_returns_none_without_profile(self, monkeypatch):
monkeypatch.delenv("RAFT_PROFILE", raising=False)
assert _env_enablement() is None
def test_is_connected_checks_bridge_token_or_enabled(self):
assert _is_connected(PlatformConfig(enabled=True, extra={"bridge_token": "tok"})) is True
assert _is_connected(PlatformConfig(enabled=True, extra={"enabled": True})) is True
assert _is_connected(PlatformConfig(enabled=True, extra={})) is False
def test_interactive_setup_saves_raft_profile(self, monkeypatch, tmp_path, capsys):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("RAFT_PROFILE", raising=False)
monkeypatch.setattr("builtins.input", lambda _prompt: "dev-profile")
interactive_setup()
assert (tmp_path / ".env").read_text(encoding="utf-8") == "RAFT_PROFILE=dev-profile\n"
assert os.environ["RAFT_PROFILE"] == "dev-profile"
out = capsys.readouterr().out
assert "Raft configuration saved" in out
assert "hermes gateway restart" in out
def test_interactive_setup_keeps_existing_profile_when_not_reconfigured(
self, monkeypatch, tmp_path, capsys
):
env_path = tmp_path / ".env"
env_path.write_text("RAFT_PROFILE=existing\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("RAFT_PROFILE", "existing")
monkeypatch.setattr("builtins.input", lambda _prompt: "n")
interactive_setup()
assert env_path.read_text(encoding="utf-8") == "RAFT_PROFILE=existing\n"
assert os.environ["RAFT_PROFILE"] == "existing"
assert "Keeping RAFT_PROFILE=existing" in capsys.readouterr().out
def test_register_calls_register_platform(self):
registered = {}
hooks = {}
class FakeCtx:
def register_platform(self, **kwargs):
registered.update(kwargs)
def register_hook(self, name, handler):
hooks[name] = handler
register(FakeCtx())
assert registered["name"] == "raft"
assert registered["label"] == "Raft"
assert registered["emoji"] == "🔔"
assert registered["setup_fn"] is interactive_setup
assert "profile show" in registered["platform_hint"]
assert "manual get" in registered["platform_hint"]
assert "--profile" in registered["platform_hint"]
assert hooks == {
"on_session_start": _on_session_start,
"pre_llm_call": _on_pre_llm_call,
"pre_tool_call": _on_pre_tool_call,
"post_tool_call": _on_post_tool_call,
"post_llm_call": _on_post_llm_call,
"on_session_end": _on_session_end,
"on_session_finalize": _on_session_finalize,
}