fix(api-server): a /p/<profile> prefix on a non-multiplexed gateway fails closed instead of misdelivering
The prefix is an address: the caller is naming WHICH agent the request is for. With gateway.multiplex_profiles off, _resolve_request_profile ignored the prefix entirely — "don't 404 a would-be valid route" — 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 that host runs one LaunchDaemon per profile and only the default daemon hosted an api_server. A wrong-agent answer is strictly worse than an error: the sender believes the addressee got the message. With multiplexing off the process serves exactly one profile, so the prefix is honored when it names that profile (peers address single-profile daemons this way without knowing the host's topology — get_active_profile_name() is the same identity the file already uses for model resolution) and rejected otherwise through the existing _PROFILE_REJECTED path (404). A process that cannot resolve its own identity rejects too: if it cannot prove who it is, it must not answer as anyone. Unprefixed requests are untouched, and multiplexed hosts are untouched — the change is confined to the prefix-present, multiplexing-off branch that previously discarded the caller's addressing.
This commit is contained in:
committed by
Teknium
parent
764dba6953
commit
265bdcac82
@@ -2081,12 +2081,13 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
"""Resolve + validate the /p/<profile>/ 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
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""A ``/p/<profile>/`` 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
|
||||
Reference in New Issue
Block a user