feat(gateway): allow selective multiplex profile serving
This commit is contained in:
+73
-1
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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``.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user