feat(gateway): allow selective multiplex profile serving

This commit is contained in:
Trevin Chow
2026-08-10 12:12:21 -07:00
committed by Teknium
parent a31be48030
commit c8f235a106
21 changed files with 627 additions and 42 deletions
+73 -1
View File
@@ -37,6 +37,51 @@ def _coerce_bool(value: Any, default: bool = True) -> bool:
return is_truthy_value(value, default=default)
def _normalize_multiplex_profile_allowlist(value: Any) -> Optional[List[str]]:
"""Normalize the optional named-profile allowlist.
``None`` preserves the historical serve-all behavior. A malformed outer
value fails safe to an empty list (default profile only); malformed list
entries are skipped with a warning.
"""
if value is None:
return None
if not isinstance(value, list):
logger.warning(
"Invalid gateway.multiplex_profile_allowlist (expected a list, got %s); "
"serving only the default profile",
type(value).__name__,
)
return []
from hermes_cli.profiles import normalize_profile_name, validate_profile_name
normalized: List[str] = []
seen = set()
for entry in value:
if not isinstance(entry, str):
logger.warning(
"Skipping invalid gateway.multiplex_profile_allowlist entry %r "
"(expected a profile name)",
entry,
)
continue
try:
name = normalize_profile_name(entry)
validate_profile_name(name)
except ValueError:
logger.warning(
"Skipping invalid gateway.multiplex_profile_allowlist entry %r",
entry,
)
continue
if name == "default" or name in seen:
continue
seen.add(name)
normalized.append(name)
return normalized
# Recognized truthy / falsy tokens for the GATEWAY_MULTIPLEX_PROFILES operator
# override. Anything not in either set — and a blank/whitespace value — is
# treated as "unset" so it falls through to config.yaml rather than silently
@@ -925,6 +970,9 @@ class GatewayConfig:
# phases) per-profile adapters/credentials are resolved. When False, the
# gateway behaves exactly as before — single HERMES_HOME, no profile stamping.
multiplex_profiles: bool = False
# Optional named-profile allowlist for multiplex mode. None preserves the
# historical serve-all behavior; [] serves only the default profile.
multiplex_profile_allowlist: Optional[List[str]] = None
# Opt-in systemd event-loop watchdog. Zero preserves Type=simple and
# disables sd_notify at runtime.
@@ -956,6 +1004,9 @@ class GatewayConfig:
profile_routes: list = field(default_factory=list)
def __post_init__(self) -> None:
self.multiplex_profile_allowlist = _normalize_multiplex_profile_allowlist(
self.multiplex_profile_allowlist
)
self.systemd_watchdog_seconds = coerce_systemd_watchdog_seconds(
self.systemd_watchdog_seconds
)
@@ -1070,6 +1121,7 @@ class GatewayConfig:
"thread_sessions_per_user": self.thread_sessions_per_user,
"max_concurrent_sessions": self.max_concurrent_sessions,
"multiplex_profiles": self.multiplex_profiles,
"multiplex_profile_allowlist": self.multiplex_profile_allowlist,
"systemd_watchdog_seconds": self.systemd_watchdog_seconds,
"loop_watchdog": self.loop_watchdog,
"unauthorized_dm_behavior": self.unauthorized_dm_behavior,
@@ -1133,7 +1185,14 @@ class GatewayConfig:
group_sessions_per_user = data.get("group_sessions_per_user")
thread_sessions_per_user = data.get("thread_sessions_per_user")
multiplex_profiles = data.get("multiplex_profiles")
nested_gateway = data.get("gateway") if isinstance(data.get("gateway"), dict) else {}
raw_gateway = data.get("gateway")
nested_gateway = raw_gateway if isinstance(raw_gateway, dict) else {}
if "multiplex_profile_allowlist" in data:
multiplex_profile_allowlist = data.get("multiplex_profile_allowlist")
else:
multiplex_profile_allowlist = nested_gateway.get(
"multiplex_profile_allowlist"
)
if "systemd_watchdog_seconds" in data:
systemd_watchdog_raw = data.get("systemd_watchdog_seconds")
systemd_watchdog_key = "systemd_watchdog_seconds"
@@ -1207,6 +1266,7 @@ class GatewayConfig:
group_sessions_per_user=_coerce_bool(group_sessions_per_user, True),
thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False),
multiplex_profiles=_coerce_bool(multiplex_profiles, False),
multiplex_profile_allowlist=multiplex_profile_allowlist,
systemd_watchdog_seconds=systemd_watchdog_seconds,
loop_watchdog=loop_watchdog,
max_concurrent_sessions=max_concurrent_sessions,
@@ -1350,6 +1410,18 @@ def load_gateway_config() -> GatewayConfig:
if "multiplex_profiles" in yaml_cfg:
gw_data["multiplex_profiles"] = yaml_cfg["multiplex_profiles"]
if "multiplex_profile_allowlist" in yaml_cfg:
gw_data["multiplex_profile_allowlist"] = yaml_cfg[
"multiplex_profile_allowlist"
]
elif (
isinstance(gateway_section, dict)
and "multiplex_profile_allowlist" in gateway_section
):
gw_data["multiplex_profile_allowlist"] = gateway_section[
"multiplex_profile_allowlist"
]
# Profile-based routing rules: accept either top-level
# ``profile_routes`` or the nested ``gateway.profile_routes`` form
# (matching the multiplex_profiles parity above).
+9 -1
View File
@@ -1982,7 +1982,15 @@ class APIServerAdapter(BasePlatformAdapter):
try:
from hermes_cli.profiles import profiles_to_serve
served = {name for name, _ in profiles_to_serve(multiplex=True)}
served = {
name
for name, _ in profiles_to_serve(
multiplex=True,
profile_allowlist=getattr(
cfg, "multiplex_profile_allowlist", None
),
)
}
except Exception:
return _PROFILE_REJECTED
if profile not in served:
+10
View File
@@ -7007,8 +7007,11 @@ class BasePlatformAdapter(ABC):
# Resolve profile from configured routes (None when no match / no routes)
profile = None
profile_route_rejected = False
runner = getattr(self, "gateway_runner", None)
if runner is not None:
from gateway.profile_routing import ProfileRouteRejected
try:
profile = runner._profile_name_for_source(
SessionSource(
@@ -7029,6 +7032,8 @@ class BasePlatformAdapter(ABC):
message_id=str(message_id) if message_id else None,
)
)
except ProfileRouteRejected:
profile_route_rejected = True
except Exception:
logger.warning(
"Profile resolution failed for %s/%s, defaulting to active profile",
@@ -7060,6 +7065,11 @@ class BasePlatformAdapter(ABC):
# SessionSource.to_dict(). The live receiving adapter is authoritative
# for this turn even when profile_routes selects a different runtime.
source._transport_adapter_ref = weakref.ref(self)
# Keep this transport-only fail-closed signal out of SessionSource
# serialization/session identity. The shared gateway handler consumes it
# before auth, hooks, or session setup, so every adapter drops matched
# routes to unserved profiles consistently without surfacing HTTP 500s.
source.profile_route_rejected = profile_route_rejected
return source
@abstractmethod
+9 -1
View File
@@ -552,7 +552,15 @@ class WebhookAdapter(BasePlatformAdapter):
return None
try:
from hermes_cli.profiles import profiles_to_serve
served = {name for name, _ in profiles_to_serve(multiplex=True)}
served = {
name
for name, _ in profiles_to_serve(
multiplex=True,
profile_allowlist=getattr(
cfg, "multiplex_profile_allowlist", None
),
)
}
except Exception:
return _PROFILE_REJECTED
if profile not in served:
+4
View File
@@ -47,6 +47,10 @@ import logging
logger = logging.getLogger(__name__)
class ProfileRouteRejected(RuntimeError):
"""An explicit route matched a profile this gateway does not serve."""
@dataclass(frozen=True)
class ProfileRoute:
"""A single routing rule that maps a platform scope to a profile."""
+67 -8
View File
@@ -1990,6 +1990,18 @@ class SecondaryPortBindingConfigError(MultiplexConfigError):
"""A secondary profile conflicts with the multiplexer's shared listener."""
def _multiplex_profile_homes(config: object) -> list[tuple[str, "Path"]]:
"""Return the authoritative profile set for one multiplex gateway config."""
from hermes_cli.profiles import profiles_to_serve
return list(
profiles_to_serve(
multiplex=True,
profile_allowlist=getattr(config, "multiplex_profile_allowlist", None),
)
)
@_contextmanager
def _profile_runtime_scope(profile_home: "Path"):
"""Scope config/skills/memory AND credentials to a profile for one turn.
@@ -13580,7 +13592,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return 0
try:
from hermes_cli.profiles import profiles_to_serve, get_active_profile_name
from hermes_cli.profiles import get_active_profile_name
except Exception:
return 0
@@ -13606,7 +13618,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if isinstance(retry_claim, tuple):
claimed[retry_claim] = active
for profile_name, profile_home in profiles_to_serve(multiplex=True):
profile_homes = _multiplex_profile_homes(self.config)
for profile_name, profile_home in profile_homes:
if profile_name == active:
continue # handled by the primary startup loop
try:
@@ -13627,11 +13640,16 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
profile_name, e, exc_info=True,
)
# Record served profiles in runtime status for `hermes status`.
# Record the authoritative served set in runtime status for `hermes status`.
# "Served" means eligible for shared routing, HTTP prefixes, cron, and
# profile runtime scope; it is intentionally broader than profiles with a
# successfully connected secondary adapter (or any adapter configured).
try:
from gateway.status import write_runtime_status
from gateway.pairing import PairingStore
served = [active] + sorted(self._profile_adapters.keys())
served = [active] + sorted(
name for name, _home in profile_homes if name != active
)
# Per-profile PairingStores so authz_mixin can route pairing
# checks to the right whitelist. The active profile gets a store
# at its HERMES_HOME; additional served profiles resolve from
@@ -14816,6 +14834,29 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception:
logger.debug("reset_session_vars failed at handler entry", exc_info=True)
# Most adapters resolve profile routes in build_source(), before they
# hand us the event. A few internal/voice paths construct SessionSource
# directly, so resolve those here as the shared fail-closed ingress gate
# before authorization, hooks, or session side effects.
if (
getattr(getattr(self, "config", None), "multiplex_profiles", False)
and not getattr(source, "profile", None)
and not getattr(source, "profile_route_rejected", False)
):
from gateway.profile_routing import ProfileRouteRejected
try:
source.profile = self._profile_name_for_source(source)
except ProfileRouteRejected:
source.profile_route_rejected = True
if getattr(source, "profile_route_rejected", False):
logger.warning(
"Dropping inbound message because its explicit profile route "
"targets an unserved profile"
)
return None
# Internal events (e.g. background-process completion notifications)
# are system-generated and must skip user authorization.
is_internal = bool(getattr(event, "internal", False))
@@ -25179,7 +25220,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
routes = getattr(config, "profile_routes", None)
if not routes:
return None
from gateway.profile_routing import match_profile_route
from gateway.profile_routing import ProfileRouteRejected, match_profile_route
try:
matched = match_profile_route(
routes,
@@ -25196,6 +25237,23 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
return None
if matched:
try:
served = {name for name, _home in _multiplex_profile_homes(config)}
except Exception as exc:
logger.warning(
"Rejecting profile route %r because the served-profile set "
"could not be resolved",
matched.name,
exc_info=True,
)
raise ProfileRouteRejected(matched.name) from exc
if matched.profile not in served:
logger.warning(
"Rejecting profile route %r: target profile %r is not served",
matched.name,
matched.profile,
)
raise ProfileRouteRejected(matched.name)
return matched.profile
logger.debug(
"No profile route matched: platform=%s chat_id=%s thread_id=%s parent_chat_id=%s",
@@ -25214,6 +25272,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
fallback for sources that bypass ``build_source``.
3. The active profile (the multiplexer's own home).
"""
from gateway.profile_routing import ProfileRouteRejected
from hermes_cli.profiles import (
get_active_profile_name,
get_profile_dir,
@@ -25247,6 +25306,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
return get_hermes_home()
return profile_dir
except ProfileRouteRejected:
raise
except Exception:
# Catch normalization errors, path errors, etc.
logger.warning(
@@ -27895,9 +27956,7 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
and getattr(runner.config, "multiplex_profiles", False)
):
try:
from hermes_cli.profiles import profiles_to_serve
profile_homes = list(profiles_to_serve(multiplex=True))
profile_homes = _multiplex_profile_homes(runner.config)
if profile_homes:
cron_start_kwargs["profile_homes"] = profile_homes
logger.info(
+3
View File
@@ -182,6 +182,9 @@ class SessionSource:
# None => the gateway's active/default profile. Drives both session-key
# namespacing and the per-turn config/credential scope.
profile: Optional[str] = None
# Transport-local fail-closed signal for an explicit profile route whose
# target is not served. Excluded from repr/equality and wire serialization.
profile_route_rejected: bool = field(default=False, repr=False, compare=False)
# Discord auto-thread metadata. Newly auto-created Discord threads start
# with a fast placeholder title from the raw message, then the gateway can
+4
View File
@@ -2546,6 +2546,10 @@ DEFAULT_CONFIG = {
# Gateway settings — control how messaging platforms (Telegram, Discord,
# Slack, etc.) deliver agent-produced files as native attachments.
"gateway": {
# Optional named-profile allowlist for multiplex mode. None preserves
# the historical serve-all behavior; [] serves only the default.
"multiplex_profile_allowlist": None,
# Durable delivery-obligation ledger: final agent responses are
# recorded in state.db around the platform send, and a gateway that
# died between finalize and platform ACK redelivers the stored
+25 -6
View File
@@ -4873,26 +4873,45 @@ def _guard_named_profile_under_multiplexer(force: bool = False) -> None:
# to config.yaml.
from gateway.config import _env_multiplex_profiles_override
cfg_path = default_root / "config.yaml"
cfg = {}
if cfg_path.exists():
# Raw read of the DEFAULT root's config (not the active profile
# home, so load_config() is the wrong owner here); whole probe is
# fail-open via the enclosing except.
from hermes_cli.config import read_user_config_raw
cfg = read_user_config_raw(cfg_path)
env_multiplex = _env_multiplex_profiles_override()
if env_multiplex is False:
return # explicitly forced OFF by the operator env override
if env_multiplex is True:
multiplex = True
else:
cfg_path = default_root / "config.yaml"
if not cfg_path.exists():
return
# Raw read of the DEFAULT root's config (not the active profile
# home, so load_config() is the wrong owner here); whole probe is
# fail-open via the enclosing except.
from hermes_cli.config import read_user_config_raw
cfg = read_user_config_raw(cfg_path)
multiplex = bool(
cfg.get("multiplex_profiles")
or (cfg.get("gateway", {}) or {}).get("multiplex_profiles")
)
if not multiplex:
return
gateway_cfg = cfg.get("gateway", {}) or {}
if "multiplex_profile_allowlist" in cfg:
raw_allowlist = cfg.get("multiplex_profile_allowlist")
else:
raw_allowlist = gateway_cfg.get("multiplex_profile_allowlist")
from gateway.config import _normalize_multiplex_profile_allowlist
from hermes_cli.profiles import normalize_profile_name
profile_allowlist = _normalize_multiplex_profile_allowlist(raw_allowlist)
if (
profile_allowlist is not None
and normalize_profile_name(suffix) not in profile_allowlist
):
return
except Exception:
logger.debug("Multiplexer-conflict probe failed", exc_info=True)
return
+35 -2
View File
@@ -20,6 +20,7 @@ Usage::
"""
import json
import logging
import os
import re
import shlex
@@ -34,7 +35,10 @@ from typing import Dict, List, Optional, Tuple
from agent.skill_utils import is_excluded_skill_path
logger = logging.getLogger(__name__)
_PROFILE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
_WARNED_MISSING_ALLOWLIST_ENTRIES: set[tuple[str, ...]] = set()
# Directories bootstrapped inside every new profile
_PROFILE_DIRS = [
@@ -954,7 +958,10 @@ def list_profiles() -> List[ProfileInfo]:
return profiles
def profiles_to_serve(multiplex: bool) -> List[Tuple[str, Path]]:
def profiles_to_serve(
multiplex: bool,
profile_allowlist: Optional[List[str]] = None,
) -> List[Tuple[str, Path]]:
"""Return the ``(profile_name, hermes_home)`` pairs a gateway should serve.
This is the single chokepoint for "which profiles does the inbound gateway
@@ -965,7 +972,9 @@ def profiles_to_serve(multiplex: bool) -> List[Tuple[str, Path]]:
always had. The name is ``"default"`` for the default profile or the
active named profile's id.
- ``multiplex=True``: returns the default profile plus every valid named
profile under ``profiles/``, each paired with its own HERMES_HOME.
profile under ``profiles/``, each paired with its own HERMES_HOME. When
``profile_allowlist`` is provided, only selected named profiles are
included; the default profile is always served.
Intentionally lightweight (a directory scan + name validation only): no
per-profile config reads, gateway-running probes, or skill counts like
@@ -979,6 +988,19 @@ def profiles_to_serve(multiplex: bool) -> List[Tuple[str, Path]]:
return [(active, get_profile_dir(active))]
serve: List[Tuple[str, Path]] = [("default", _get_default_hermes_home())]
allowed: Optional[set[str]] = None
if profile_allowlist is not None:
allowed = set()
for entry in profile_allowlist:
if not isinstance(entry, str):
continue
try:
name = normalize_profile_name(entry)
validate_profile_name(name)
except ValueError:
continue
if name != "default":
allowed.add(name)
profiles_root = _get_profiles_root()
if profiles_root.is_dir():
@@ -990,8 +1012,19 @@ def profiles_to_serve(multiplex: bool) -> List[Tuple[str, Path]]:
continue # default is the built-in entry already added above
if not _PROFILE_ID_RE.match(name):
continue
if allowed is not None and name not in allowed:
continue
serve.append((name, entry))
if allowed is not None:
missing = tuple(sorted(allowed - {name for name, _ in serve}))
if missing and missing not in _WARNED_MISSING_ALLOWLIST_ENTRIES:
_WARNED_MISSING_ALLOWLIST_ENTRIES.add(missing)
logger.warning(
"Skipping missing gateway.multiplex_profile_allowlist profile(s): %s",
", ".join(missing),
)
return serve
@@ -110,7 +110,9 @@ async def test_profile_middleware_binds_auth_before_handler(
)()
monkeypatch.setattr(
"hermes_cli.profiles.profiles_to_serve",
lambda multiplex: [("default", tmp_path), ("worker", worker_home)],
lambda multiplex, profile_allowlist=None: [
("default", tmp_path), ("worker", worker_home)
],
)
monkeypatch.setattr(
"hermes_cli.profiles.get_profile_dir",
+19
View File
@@ -397,6 +397,25 @@ class TestLoadGatewayConfig:
assert config.multiplex_profiles is True
def test_multiplex_allowlist_from_nested_gateway_section(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"gateway:\n"
" multiplex_profiles: true\n"
" multiplex_profile_allowlist:\n"
" - Worker\n"
" - worker\n"
" - guest\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.multiplex_profiles is True
assert config.multiplex_profile_allowlist == ["worker", "guest"]
def test_discord_websocket_health_settings_seed_platform_extra(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
@@ -3,7 +3,7 @@ import logging
import asyncio
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -280,9 +280,18 @@ class TestSecondaryProfileConfigHandling:
from gateway.config import GatewayConfig
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(multiplex_profiles=True)
runner.config = GatewayConfig(
multiplex_profiles=True,
multiplex_profile_allowlist=["bad", "good"],
)
runner.adapters = {}
runner._profile_adapters = {}
runner.pairing_stores = {
"default": MagicMock(),
"bad": MagicMock(),
"good": MagicMock(),
}
runner.pairing_store = runner.pairing_stores["default"]
async def fake_start_one(profile_name, profile_home, claimed):
if profile_name == "bad":
@@ -291,28 +300,35 @@ class TestSecondaryProfileConfigHandling:
runner._profile_adapters[profile_name] = {}
return 2
monkeypatch.setattr(
"hermes_cli.profiles.profiles_to_serve",
lambda multiplex: [
def fake_profiles_to_serve(multiplex, profile_allowlist=None):
assert multiplex is True
assert profile_allowlist == ["bad", "good"]
return [
("default", Path("/tmp/default")),
("bad", Path("/tmp/bad")),
("good", Path("/tmp/good")),
],
]
monkeypatch.setattr(
"hermes_cli.profiles.profiles_to_serve",
fake_profiles_to_serve,
)
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name",
lambda: "default",
)
monkeypatch.setattr(runner, "_start_one_profile_adapters", fake_start_one)
status = {}
monkeypatch.setattr(
"gateway.status.write_runtime_status",
lambda **kwargs: None,
lambda **kwargs: status.update(kwargs),
)
caplog.set_level(logging.WARNING, logger="gateway.run")
connected = await runner._start_secondary_profile_adapters()
assert connected == 2
assert status["served_profiles"] == ["default", "bad", "good"]
assert "good" in runner._profile_adapters
assert "bad" not in runner._profile_adapters
assert "Skipping secondary profile 'bad'" in caplog.text
@@ -335,7 +351,7 @@ class TestSecondaryProfileConfigHandling:
monkeypatch.setattr(
"hermes_cli.profiles.profiles_to_serve",
lambda multiplex: [
lambda multiplex, profile_allowlist=None: [
("default", Path("/tmp/default")),
("unsafe", Path("/tmp/unsafe")),
],
@@ -6,6 +6,8 @@ owns the port, and secondary profiles are reached via a URL prefix when
"""
from __future__ import annotations
from typing import Any, cast
from gateway.config import GatewayConfig, PlatformConfig
from gateway.platforms.api_server import (
APIServerAdapter,
@@ -14,12 +16,17 @@ from gateway.platforms.api_server import (
)
def _make_adapter(multiplex: bool = True) -> APIServerAdapter:
def _make_adapter(
multiplex: bool = True, allowlist: list[str] | None = None
) -> APIServerAdapter:
cfg = PlatformConfig(enabled=True, extra={"host": "127.0.0.1", "port": 8642, "key": "test-key"})
adapter = APIServerAdapter(cfg)
class _Runner:
config = GatewayConfig(multiplex_profiles=multiplex)
config = GatewayConfig(
multiplex_profiles=multiplex,
multiplex_profile_allowlist=allowlist,
)
adapter.gateway_runner = _Runner()
return adapter
@@ -35,6 +42,25 @@ class TestApiServerProfileResolution:
adapter = _make_adapter(multiplex=True)
assert adapter._resolve_request_profile(_FakeReq(None)) is None
def test_unserved_prefix_is_rejected(self, monkeypatch):
adapter = _make_adapter(multiplex=True, allowlist=["worker"])
monkeypatch.setattr(
"hermes_cli.profiles.profiles_to_serve",
lambda multiplex, profile_allowlist=None: [
("default", "/profiles/default"),
("worker", "/profiles/worker"),
],
)
assert (
adapter._resolve_request_profile(cast(Any, _FakeReq("worker")))
== "worker"
)
assert (
adapter._resolve_request_profile(cast(Any, _FakeReq("restricted")))
is _PROFILE_REJECTED
)
class TestApiServerRouteTable:
def test_route_table_includes_models_options_and_chat(self):
+26 -2
View File
@@ -20,14 +20,22 @@ class TestSessionSourceProfileField:
class TestWebhookProfileResolution:
"""_resolve_request_profile validates the /p/<profile>/ prefix."""
def _adapter(self, multiplex: bool, served=("default", "coder")):
def _adapter(
self,
multiplex: bool,
served=("default", "coder"),
allowlist=None,
):
from gateway.platforms.webhook import WebhookAdapter, _PROFILE_REJECTED
class _FakeReq:
def __init__(self, profile):
self.match_info = {"profile": profile} if profile is not None else {}
cfg = GatewayConfig(multiplex_profiles=multiplex)
cfg = GatewayConfig(
multiplex_profiles=multiplex,
multiplex_profile_allowlist=allowlist,
)
class _Runner:
config = cfg
@@ -41,4 +49,20 @@ class TestWebhookProfileResolution:
adapter, Req, _REJ, _ = self._adapter(multiplex=True)
assert adapter._resolve_request_profile(Req(None)) is None
def test_unserved_prefix_is_rejected(self, monkeypatch):
adapter, Req, rejected, served = self._adapter(
multiplex=True,
served=("default", "worker"),
allowlist=["worker"],
)
monkeypatch.setattr(
"hermes_cli.profiles.profiles_to_serve",
lambda multiplex, profile_allowlist=None: [
(name, f"/profiles/{name}") for name in served
],
)
assert adapter._resolve_request_profile(Req("worker")) == "worker"
assert adapter._resolve_request_profile(Req("restricted")) is rejected
+68
View File
@@ -1,6 +1,8 @@
"""Phase 4: lifecycle guard + per-profile observability."""
import pytest
from gateway.config import GatewayConfig
class TestServedProfilesStatus:
def test_write_and_read_served_profiles(self, tmp_path, monkeypatch):
@@ -18,6 +20,26 @@ class TestServedProfilesStatus:
importlib.reload(status)
def test_cron_profile_homes_follow_allowlist(tmp_path, monkeypatch):
"""The helper wired into in-process cron returns only selected profiles."""
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
default_home = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(default_home))
for name in ("worker", "guest"):
(default_home / "profiles" / name).mkdir(parents=True)
import gateway.run as gateway_run
homes = gateway_run._multiplex_profile_homes(
GatewayConfig(
multiplex_profiles=True,
multiplex_profile_allowlist=["worker"],
)
)
assert [name for name, _home in homes] == ["default", "worker"]
class TestNamedProfileMultiplexerGuard:
"""_guard_named_profile_under_multiplexer is inert unless all conditions hold."""
@@ -51,4 +73,50 @@ class TestNamedProfileMultiplexerGuard:
monkeypatch.setattr(status, "_pid_from_record", lambda rec: 12345)
monkeypatch.setattr(status, "_pid_exists", lambda pid: True)
def test_unset_allowlist_preserves_historical_guard(self, monkeypatch, tmp_path):
self._fake_running_default_gateway(monkeypatch, tmp_path)
(tmp_path / "config.yaml").write_text(
"gateway:\n multiplex_profiles: true\n",
encoding="utf-8",
)
from hermes_cli import gateway as gw
with pytest.raises(SystemExit, match="1"):
gw._guard_named_profile_under_multiplexer(force=False)
def test_served_profile_is_still_guarded(self, monkeypatch, tmp_path):
self._fake_running_default_gateway(monkeypatch, tmp_path)
(tmp_path / "config.yaml").write_text(
"gateway:\n"
" multiplex_profiles: true\n"
" multiplex_profile_allowlist:\n"
" - Coder\n",
encoding="utf-8",
)
from hermes_cli import gateway as gw
with pytest.raises(SystemExit, match="1"):
gw._guard_named_profile_under_multiplexer(force=False)
@pytest.mark.parametrize(
"allowlist_yaml",
["[]", "[worker]", "coder"],
)
def test_unserved_profile_may_run_standalone(
self, monkeypatch, tmp_path, allowlist_yaml
):
self._fake_running_default_gateway(monkeypatch, tmp_path)
(tmp_path / "config.yaml").write_text(
"gateway:\n"
" multiplex_profiles: true\n"
f" multiplex_profile_allowlist: {allowlist_yaml}\n",
encoding="utf-8",
)
from hermes_cli import gateway as gw
gw._guard_named_profile_under_multiplexer(force=False)
+33
View File
@@ -75,6 +75,39 @@ class TestMultiplexConfigFlag:
cfg = GatewayConfig.from_dict({"multiplex_profiles": True})
assert cfg.multiplex_profiles is True
def test_profile_allowlist_defaults_to_serve_all(self):
assert GatewayConfig().multiplex_profile_allowlist is None
def test_profile_allowlist_normalizes_and_round_trips(self):
cfg = GatewayConfig.from_dict(
{
"gateway": {
"multiplex_profiles": True,
"multiplex_profile_allowlist": [
" Worker ",
"worker",
"Guest",
"default",
"bad/name",
7,
],
}
}
)
assert cfg.multiplex_profile_allowlist == ["worker", "guest"]
restored = GatewayConfig.from_dict(cfg.to_dict())
assert restored.multiplex_profile_allowlist == ["worker", "guest"]
def test_invalid_profile_allowlist_fails_safe_to_default_only(self, caplog):
with caplog.at_level("WARNING", logger="gateway.config"):
cfg = GatewayConfig.from_dict(
{"gateway": {"multiplex_profile_allowlist": "worker"}}
)
assert cfg.multiplex_profile_allowlist == []
assert "serving only the default profile" in caplog.text
class TestSessionStoreProfileResolution:
"""SessionStore._generate_session_key honors the flag: legacy namespace
+135 -6
View File
@@ -8,9 +8,9 @@ import pytest
from gateway.session import SessionSource, build_session_key
from gateway.run import GatewayRunner
from gateway.profile_routing import ProfileRoute
from gateway.profile_routing import ProfileRoute, ProfileRouteRejected
from gateway.config import GatewayConfig, Platform
from gateway.platforms.base import BasePlatformAdapter
from gateway.platforms.base import BasePlatformAdapter, MessageEvent
@pytest.fixture
@@ -165,7 +165,74 @@ class TestNonDiscordProfileRouting:
]
telegram_source.profile = None
assert mock_runner._profile_name_for_source(telegram_source) == "tg-profile"
with patch(
"hermes_cli.profiles.profiles_to_serve",
return_value=[("default", Path("/profiles/default")),
("tg-profile", Path("/profiles/tg-profile"))],
):
assert mock_runner._profile_name_for_source(telegram_source) == "tg-profile"
def test_route_inside_allowlist_resolves(self, mock_runner, telegram_source):
mock_runner.config.multiplex_profile_allowlist = ["worker"]
mock_runner.config.profile_routes = [
ProfileRoute(
name="worker-route",
platform="telegram",
profile="worker",
chat_id="route-chat",
)
]
telegram_source.chat_id = "route-chat"
with patch(
"hermes_cli.profiles.profiles_to_serve",
return_value=[("default", Path("/profiles/default")),
("worker", Path("/profiles/worker"))],
) as enumerate_profiles:
assert mock_runner._profile_name_for_source(telegram_source) == "worker"
enumerate_profiles.assert_called_once_with(
multiplex=True, profile_allowlist=["worker"]
)
def test_route_outside_allowlist_rejects(self, mock_runner, telegram_source, caplog):
mock_runner.config.multiplex_profile_allowlist = ["worker"]
mock_runner.config.profile_routes = [
ProfileRoute(
name="restricted-route",
platform="telegram",
profile="restricted",
chat_id="route-chat",
)
]
telegram_source.chat_id = "route-chat"
with patch(
"hermes_cli.profiles.profiles_to_serve",
return_value=[("default", Path("/profiles/default")),
("worker", Path("/profiles/worker"))],
), caplog.at_level(logging.WARNING, logger="gateway.run"):
with pytest.raises(ProfileRouteRejected):
mock_runner._profile_name_for_source(telegram_source)
assert "target profile 'restricted' is not served" in caplog.text
def test_no_route_match_preserves_default_sentinel(self, mock_runner, telegram_source):
mock_runner.config.multiplex_profile_allowlist = ["worker"]
mock_runner.config.profile_routes = [
ProfileRoute(
name="other-chat",
platform="telegram",
profile="worker",
chat_id="different-chat",
)
]
telegram_source.chat_id = "route-chat"
assert mock_runner._profile_name_for_source(telegram_source) is None
adapter = _stub_adapter(Platform.TELEGRAM, mock_runner)
source = adapter.build_source(chat_id="route-chat", chat_type="group")
assert source.profile is None
class TestGatewayRunnerInjection:
@@ -226,9 +293,14 @@ class TestAdapterToSessionKeyIntegration:
mock_runner.config.profile_routes = self._routes()
adapter = _stub_adapter(Platform.DISCORD, mock_runner)
source = adapter.build_source(
chat_id="222", chat_type="group", guild_id="111", user_id="u1",
)
with patch(
"hermes_cli.profiles.profiles_to_serve",
return_value=[("default", Path("/profiles/default")),
("coder", Path("/profiles/coder"))],
):
source = adapter.build_source(
chat_id="222", chat_type="group", guild_id="111", user_id="u1",
)
assert source.profile == "coder"
key = build_session_key(source, profile=source.profile)
@@ -236,6 +308,63 @@ class TestAdapterToSessionKeyIntegration:
# A default-profile key would land in agent:main — must differ.
assert key != build_session_key(source, profile=None)
@pytest.mark.asyncio
async def test_adapter_drops_rejected_route_before_dispatch(self, mock_runner):
mock_runner.config.multiplex_profile_allowlist = []
mock_runner.config.profile_routes = [
ProfileRoute(
name="restricted-route",
platform="telegram",
profile="restricted",
chat_id="route-chat",
)
]
adapter = _stub_adapter(Platform.TELEGRAM, mock_runner)
with patch(
"hermes_cli.profiles.profiles_to_serve",
return_value=[("default", Path("/profiles/default"))],
):
source = adapter.build_source(chat_id="route-chat", chat_type="group")
assert source.profile is None
assert source.profile_route_rejected is True
roundtrip = SessionSource.from_dict(source.to_dict())
assert roundtrip.profile_route_rejected is False
assert roundtrip == source
result = await GatewayRunner._handle_message(
mock_runner,
MessageEvent(text="discard me", source=source),
)
assert result is None
@pytest.mark.asyncio
async def test_direct_source_is_rejected_at_shared_ingress(self, mock_runner):
mock_runner.config.multiplex_profiles = True
mock_runner.config.multiplex_profile_allowlist = []
mock_runner.config.profile_routes = [
ProfileRoute(
name="restricted-route",
platform="telegram",
profile="restricted",
chat_id="route-chat",
)
]
source = SessionSource(platform=Platform.TELEGRAM, chat_id="route-chat")
with patch(
"hermes_cli.profiles.profiles_to_serve",
return_value=[("default", Path("/profiles/default"))],
):
result = await GatewayRunner._handle_message(
mock_runner,
MessageEvent(text="discard me", source=source),
)
assert result is None
assert source.profile is None
assert source.profile_route_rejected is True
class TestMultiplexGate:
"""``profile_routes`` only activates under ``gateway.multiplex_profiles``.
+1 -1
View File
@@ -920,7 +920,7 @@ class TestMultiplexProfileWebhookAuthentication:
adapter.gateway_runner = runner
monkeypatch.setattr(
"hermes_cli.profiles.profiles_to_serve",
lambda multiplex: [
lambda multiplex, profile_allowlist=None: [
("default", tmp_path),
("worker", tmp_path / "profiles" / "worker"),
("other", tmp_path / "profiles" / "other"),
+21
View File
@@ -915,5 +915,26 @@ class TestProfilesToServe:
assert serve["default"] == _get_default_hermes_home()
assert serve["coder"] == get_profile_dir("coder")
def test_empty_allowlist_serves_only_default(self, profile_env):
create_profile("worker", no_alias=True)
serve = dict(profiles_to_serve(multiplex=True, profile_allowlist=[]))
assert serve == {"default": _get_default_hermes_home()}
def test_allowlist_normalizes_deduplicates_and_keeps_default(self, profile_env):
create_profile("worker", no_alias=True)
create_profile("guest", no_alias=True)
serve = dict(
profiles_to_serve(
multiplex=True,
profile_allowlist=[" Worker ", "worker", "default", "missing"],
)
)
assert set(serve) == {"default", "worker"}
assert serve["worker"] == get_profile_dir("worker")
@@ -215,6 +215,31 @@ Kanban workers only ever see their own profile's secrets). Kanban,
profile-scoped skills/memory/SOUL, and model routing all behave per-profile
exactly as they do with separate gateways.
### Serving selected profiles
By default, `gateway.multiplex_profiles: true` serves every valid named profile
on the host. To keep unrelated profiles installed without starting their
adapters or cron jobs, set `gateway.multiplex_profile_allowlist`:
```yaml
gateway:
multiplex_profiles: true
multiplex_profile_allowlist:
- worker
- guest
```
The default profile is always served and does not need to be listed. An unset
allowlist preserves the historical serve-all behavior; an empty list serves
only the default profile. Names are normalized and deduplicated. Invalid list
entries or names that are not installed are skipped with a warning. A malformed
non-list value fails safely to default-only.
The resulting served set also controls `/p/<profile>/` API and webhook prefixes,
runtime status, profile-route eligibility, and which profiles the in-process
cron scheduler ticks. A named profile outside the allowlist may still run its
own standalone gateway.
### Routing shared-bot chats to profiles (`profile_routes`)
Multiplexing selects a profile per **credential** (each profile's own bot
@@ -255,9 +280,11 @@ per-profile isolation described above (config, skills, memory, credentials,
session namespace). Routing works on every platform adapter, not just Discord.
`profile_routes` requires `gateway.multiplex_profiles: true`; with
multiplexing off the routes are ignored. If a route names a profile that does
not exist on disk, the gateway logs a warning naming the profile and source and
falls back to the default home.
multiplexing off the routes are ignored. If an explicit route matches but its
target profile is not installed or is outside `multiplex_profile_allowlist`,
the gateway rejects that ingress and logs the route and target. It does not run
the default profile. Traffic that matches no route keeps the historical
default-profile behavior.
## Start, stop, or restart all gateways at once