diff --git a/gateway/config.py b/gateway/config.py index 3d385e33ed..a00fa0f9a1 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -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). diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 6799003ec8..0097c1e11b 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -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: diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 683a0cf6cb..f224aab323 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -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 diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index da5eddbe94..e43b432b90 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -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: diff --git a/gateway/profile_routing.py b/gateway/profile_routing.py index c0ec0acc2b..5d2b3b60be 100644 --- a/gateway/profile_routing.py +++ b/gateway/profile_routing.py @@ -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.""" diff --git a/gateway/run.py b/gateway/run.py index 853f7ef73f..15dab4750b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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( diff --git a/gateway/session.py b/gateway/session.py index f88eb1de43..d2c63aefdc 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -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 diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 5a8c593751..f7ee770719 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -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 diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index da35d02cfd..744e3ceb2c 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -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 diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index fbeb3f3fc3..fe582aca98 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -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 diff --git a/tests/gateway/test_api_server_multiplex_secret_scope.py b/tests/gateway/test_api_server_multiplex_secret_scope.py index b4d4dbc583..e11923ab6d 100644 --- a/tests/gateway/test_api_server_multiplex_secret_scope.py +++ b/tests/gateway/test_api_server_multiplex_secret_scope.py @@ -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", diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index aeec8fca9a..29c71a387b 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -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() diff --git a/tests/gateway/test_multiplex_adapter_registry.py b/tests/gateway/test_multiplex_adapter_registry.py index 6ee9258205..8cf090fb6c 100644 --- a/tests/gateway/test_multiplex_adapter_registry.py +++ b/tests/gateway/test_multiplex_adapter_registry.py @@ -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")), ], diff --git a/tests/gateway/test_multiplex_api_server_routing.py b/tests/gateway/test_multiplex_api_server_routing.py index 0ab755b97f..366cf5cae3 100644 --- a/tests/gateway/test_multiplex_api_server_routing.py +++ b/tests/gateway/test_multiplex_api_server_routing.py @@ -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): diff --git a/tests/gateway/test_multiplex_http_routing.py b/tests/gateway/test_multiplex_http_routing.py index e774152854..da917cd9a2 100644 --- a/tests/gateway/test_multiplex_http_routing.py +++ b/tests/gateway/test_multiplex_http_routing.py @@ -20,14 +20,22 @@ class TestSessionSourceProfileField: class TestWebhookProfileResolution: """_resolve_request_profile validates the /p// 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 + diff --git a/tests/gateway/test_multiplex_lifecycle.py b/tests/gateway/test_multiplex_lifecycle.py index c56626866b..fbc04b7e1b 100644 --- a/tests/gateway/test_multiplex_lifecycle.py +++ b/tests/gateway/test_multiplex_lifecycle.py @@ -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) + diff --git a/tests/gateway/test_multiplex_phase0.py b/tests/gateway/test_multiplex_phase0.py index d3684481d4..836a0d6355 100644 --- a/tests/gateway/test_multiplex_phase0.py +++ b/tests/gateway/test_multiplex_phase0.py @@ -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 diff --git a/tests/gateway/test_profile_resolution.py b/tests/gateway/test_profile_resolution.py index e14145c3d5..695b9c7b89 100644 --- a/tests/gateway/test_profile_resolution.py +++ b/tests/gateway/test_profile_resolution.py @@ -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``. diff --git a/tests/gateway/test_webhook_adapter.py b/tests/gateway/test_webhook_adapter.py index f2bfe1b5dc..f82fe6af2b 100644 --- a/tests/gateway/test_webhook_adapter.py +++ b/tests/gateway/test_webhook_adapter.py @@ -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"), diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 576396b49c..da0fd7aa7a 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -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") + diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md index 515856e969..b73d184595 100644 --- a/website/docs/user-guide/multi-profile-gateways.md +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -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//` 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