diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 88fe3d7cd5..228a17d1e3 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -2081,12 +2081,13 @@ class APIServerAdapter(BasePlatformAdapter): """Resolve + validate the /p// URL prefix on an API request. Returns: - - ``None`` when no profile prefix is present, or multiplexing is off - (the prefix is ignored; request handled as the default profile). + - ``None`` when no profile prefix is present, or when multiplexing + is off and the prefix names this process's own profile (the + request is already scoped to the only agent served here). - the profile name (str) when present, multiplexing is on, and the profile is one this gateway serves. - - ``_PROFILE_REJECTED`` when a prefix is present but the profile is - unknown/unconfigured (handler/middleware returns 404). + - ``_PROFILE_REJECTED`` when a prefix is present but names a profile + this process cannot serve (handler/middleware returns 404). """ profile = (request.match_info.get("profile") or "").strip() if not profile: @@ -2094,9 +2095,25 @@ class APIServerAdapter(BasePlatformAdapter): runner = getattr(self, "gateway_runner", None) cfg = getattr(runner, "config", None) if not getattr(cfg, "multiplex_profiles", False): - # Prefix supplied but multiplexing is off — ignore it, behave as - # the single-profile gateway (don't 404 a would-be valid route). - return None + # A prefix names a specific agent. With multiplexing off this + # process serves exactly one, so honor the prefix only when it + # names that one (peers address single-profile daemons this way + # without knowing the host's topology). Anything else must fail + # closed: answering as the local profile would deliver the + # request to a DIFFERENT agent than the one addressed — silent + # misdelivery, strictly worse than a 404. Observed live (Aug + # 2026): `hermes peer dm mini/researcher` answered by the mini's + # default agent, with no error anywhere. + try: + from hermes_cli.profiles import get_active_profile_name + + own = get_active_profile_name() + except Exception: + return _PROFILE_REJECTED + if profile == own: + # Scoped to self: same handling as no prefix at all. + return None + return _PROFILE_REJECTED try: from hermes_cli.profiles import profiles_to_serve diff --git a/tests/gateway/test_api_server_profile_prefix_misdelivery.py b/tests/gateway/test_api_server_profile_prefix_misdelivery.py new file mode 100644 index 0000000000..bc37872db9 --- /dev/null +++ b/tests/gateway/test_api_server_profile_prefix_misdelivery.py @@ -0,0 +1,115 @@ +"""A ``/p//`` prefix on a non-multiplexed gateway must not misdeliver. + +The prefix is an address: the caller is naming WHICH agent the request is +for. The old behavior ignored it whenever ``gateway.multiplex_profiles`` was +off and answered as the process's own (single) profile — so a request +explicitly addressed to one agent was silently answered by a different one. +Observed live (Aug 2026): ``hermes peer dm mini/researcher`` was answered by +the mini's *default* agent, with no error on either side, because the mini +runs one LaunchDaemon per profile and only the default daemon hosted an +api_server. + +The contract now: with multiplexing off, a prefix naming this process's own +profile is honored (peers address single-profile daemons this way without +knowing the host's topology); any other name fails closed with the existing +404, because a wrong-agent answer is strictly worse than an error. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from gateway.platforms.api_server import _PROFILE_REJECTED, APIServerAdapter +from gateway.config import PlatformConfig + + +@pytest.fixture() +def adapter(): + # No gateway_runner configured -> multiplex_profiles is falsy, the exact + # shape of a standalone single-profile daemon. + return APIServerAdapter(PlatformConfig(enabled=True)) + + +def _request(profile: str | None): + return SimpleNamespace(match_info={} if profile is None else {"profile": profile}) + + +class TestResolverWithMultiplexOff: + def test_no_prefix_is_untouched(self, adapter): + assert adapter._resolve_request_profile(_request(None)) is None + + def test_prefix_naming_own_profile_is_honored(self, adapter, monkeypatch): + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "researcher" + ) + assert adapter._resolve_request_profile(_request("researcher")) is None + + def test_prefix_naming_default_on_default_home_is_honored(self, adapter, monkeypatch): + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "default" + ) + assert adapter._resolve_request_profile(_request("default")) is None + + def test_prefix_naming_another_agent_fails_closed(self, adapter, monkeypatch): + """The misdelivery case: addressed to researcher, running as default.""" + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "default" + ) + assert adapter._resolve_request_profile(_request("researcher")) is _PROFILE_REJECTED + + def test_unresolvable_own_identity_fails_closed(self, adapter, monkeypatch): + """If the process cannot prove who it is, it must not answer as anyone.""" + + def boom(): + raise RuntimeError("no home") + + monkeypatch.setattr("hermes_cli.profiles.get_active_profile_name", boom) + assert adapter._resolve_request_profile(_request("researcher")) is _PROFILE_REJECTED + + +class TestMultiplexOnUnchanged: + def test_served_profile_resolves(self, adapter, monkeypatch): + adapter.gateway_runner = SimpleNamespace( + config=SimpleNamespace( + multiplex_profiles=True, multiplex_profile_allowlist=None + ) + ) + monkeypatch.setattr( + "hermes_cli.profiles.profiles_to_serve", + lambda multiplex, profile_allowlist: [("worker", object())], + ) + assert adapter._resolve_request_profile(_request("worker")) == "worker" + assert adapter._resolve_request_profile(_request("ghost")) is _PROFILE_REJECTED + + +@pytest.mark.asyncio +async def test_http_request_addressed_to_another_agent_is_404_not_answered( + adapter, monkeypatch +): + """End to end through the real middleware: the wrong-agent request must + 404 instead of being served — a body would mean the misdelivery is back.""" + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "default" + ) + + async def handler(request): + return web.json_response({"served_by": "default"}) + + app = web.Application(middlewares=[adapter._make_profile_prefix_middleware()]) + app.router.add_get("/p/{profile}/v1/test", handler) + app.router.add_get("/v1/test", handler) + + async with TestClient(TestServer(app)) as cli: + # Addressed to a different agent: refused. + resp = await cli.get("/p/researcher/v1/test") + assert resp.status == 404 + body = await resp.json() + assert "profile" in str(body.get("error", "")).lower() + + # Addressed to this agent, and unaddressed: both served. + assert (await cli.get("/p/default/v1/test")).status == 200 + assert (await cli.get("/v1/test")).status == 200