feat(multiplex)!: drop gateway.multiplex_profile_allowlist — serve every profile

The multiplexing default gateway now serves default + every live named profile
under profiles/. profiles_to_serve(multiplex=True) is a pure directory read
(tombstoned profiles skipped, never mkdir); every reader — gateway served set,
/p/<profile>/ prefixes for api_server + webhook, the named-profile standalone
guard, the Desktop cron ticker (its #108428 standdown for a profile owned by a
running gateway is unchanged) — drops the allowlist parameter.

Config v43 migration deletes the key from user config.yaml; DEFAULT_CONFIG,
GatewayConfig and the top-level yaml bridge no longer carry it.

BREAKING: anyone who set an allowlist now has their excluded profiles served.
Archive or delete a profile you do not want served (Teknium approved).
This commit is contained in:
Teknium
2026-09-11 16:53:54 -07:00
parent edff87fa1e
commit 9848e22ed6
12 changed files with 54 additions and 132 deletions
+2 -36
View File
@@ -42,37 +42,6 @@ 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`` = serve all; a malformed
outer value fails safe to ``[]`` (default profile only); bad entries are skipped."""
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] = []
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" and name not in normalized:
normalized.append(name)
return normalized
def _env_multiplex_profiles_override() -> "bool | None":
"""GATEWAY_MULTIPLEX_PROFILES operator override: True/False for a recognized token.
@@ -557,9 +526,8 @@ class GatewayConfig:
thread_sessions_per_user: bool = False # False = threads shared across participants
max_concurrent_sessions: Optional[int] = None # Positive int caps simultaneous active sessions
# Opt-in: the default profile's gateway serves every profile on the host (profiles stamped into
# session keys, per-profile adapters/credentials). Allowlist None = serve all; [] = default only.
# session keys, per-profile adapters/credentials).
multiplex_profiles: bool = False
multiplex_profile_allowlist: Optional[List[str]] = None
# Public HTTPS endpoint for scoped RoomLink calls (an API key alone must never advertise a
# route); HERMES_ROOM_LINK_URL overrides.
room_link_url: Optional[str] = None
@@ -588,14 +556,13 @@ class GatewayConfig:
_SCALAR_DICT_FIELDS = (
"write_sessions_json", "always_log_local", "filter_silence_narration", "stt_enabled",
"stt_echo_transcripts", "group_sessions_per_user", "thread_sessions_per_user",
"max_concurrent_sessions", "multiplex_profiles", "multiplex_profile_allowlist",
"max_concurrent_sessions", "multiplex_profiles",
"room_link_url", "systemd_watchdog_seconds", "loop_watchdog",
"loop_watchdog_probe_interval_s", "loop_watchdog_probe_timeout_s",
"loop_watchdog_max_strikes", "unauthorized_dm_behavior",
)
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)
def get_connected_platforms(self) -> List[Platform]:
@@ -731,7 +698,6 @@ class GatewayConfig:
stt_enabled=_coerce_bool(stt_setting("stt_enabled", "enabled"), True),
stt_echo_transcripts=_coerce_bool(stt_setting("stt_echo_transcripts", "echo_transcripts"), True),
multiplex_profiles=_coerce_bool(multiplex_profiles, False),
multiplex_profile_allowlist=pick("multiplex_profile_allowlist"),
room_link_url=room_link_url if isinstance(room_link_url, str) else None,
systemd_watchdog_seconds=systemd_watchdog_seconds,
loop_watchdog=_coerce_bool(pick("loop_watchdog"), True),
+1 -1
View File
@@ -72,7 +72,7 @@ _TOPLEVEL_BRIDGE: tuple = (
("stt", "stt", "presence", lambda v: isinstance(v, dict), None),
*_presence("stt_echo_transcripts", "group_sessions_per_user", "thread_sessions_per_user"),
("multiplex_profiles", "multiplex_profiles", "gwdata", None, None),
*_presence("multiplex_profile_allowlist", "room_link_url"),
*_presence("room_link_url"),
("profile_routes", "profile_routes", "none", lambda v: isinstance(v, list), None),
*_presence("max_concurrent_sessions"),
("systemd_watchdog_seconds", "systemd_watchdog_seconds", "nested", None, None),
+1 -3
View File
@@ -1459,9 +1459,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
return None if _prefix_names_served_profile(profile) else _PROFILE_REJECTED
try:
from hermes_cli.profiles import profiles_to_serve
served = {
name for name, _ in profiles_to_serve(
multiplex=True, profile_allowlist=getattr(cfg, "multiplex_profile_allowlist", None))}
served = {name for name, _ in profiles_to_serve(multiplex=True)}
except Exception:
return _PROFILE_REJECTED
return profile if profile in served else _PROFILE_REJECTED
+1 -2
View File
@@ -390,8 +390,7 @@ class WebhookAdapter(BasePlatformAdapter):
return _PROFILE_REJECTED
try:
from hermes_cli.profiles import profiles_to_serve
allowlist = getattr(cfg, "multiplex_profile_allowlist", None)
served = {name for name, _ in profiles_to_serve(multiplex=True, profile_allowlist=allowlist)}
served = {name for name, _ in profiles_to_serve(multiplex=True)}
except Exception:
return _PROFILE_REJECTED
return profile if profile in served else _PROFILE_REJECTED
+7 -1
View File
@@ -129,7 +129,13 @@ matchers; parser-derived flag sets; never blanket-exclude gateway ancestors, #87
`_apply_profile_override()` in `hermes_cli/main.py` sets `HERMES_HOME` before any module import, so
every `get_hermes_home()` scopes to the active profile (rules in root). Profiles are independent
islands by design — no live config inheritance; `--clone` copies at creation. Multiplex
(`gateway.multiplex_profiles`) secret-scope rules: `gateway/AGENTS.md`.
(`gateway.multiplex_profiles`) secret-scope rules: `gateway/AGENTS.md`. The served set is
`profiles.py::profiles_to_serve(multiplex=True)` = default + every live (non-tombstoned) dir under
`profiles/` — there is no allowlist (`gateway.multiplex_profile_allowlist` was retired in config v43).
Enumeration is a pure read: never `mkdir` a profile home from a served path (`SessionDB`, logging,
cron all go through `mkdir_under_hermes_home` / `_ensure_cron_dir`, which refuse a deleted or
missing named profile, #94590). Process-global per-profile slots (MCP discovery in `mcp_startup.py`,
tool registry overlays) key on `hermes_constants.hermes_home_key()`, never a single flag.
## Nous free tier (`hermes_cli/anon_auth.py`)
+1 -3
View File
@@ -1900,8 +1900,6 @@ DEFAULT_CONFIG = {
"export": {"otlp": {"enabled": False, "endpoint": "", "headers_env": {}}},
},
"gateway": { # Gateway settings (messaging platforms: Telegram, Discord, Slack, ...).
# Named-profile allowlist for multiplex mode. None = serve all; [] = default only.
"multiplex_profile_allowlist": None,
# Seconds to let a SIGTERM-interrupted gateway agent unwind before adapter/database
# teardown. Keep short so service-manager shutdowns don't exhaust their stop budget.
"signal_interrupt_grace_timeout": 1,
@@ -2381,7 +2379,7 @@ DEFAULT_CONFIG = {
# Extra ports detection probes for an external llama-server (besides 8080).
"detect_ports": [],
},
"_config_version": 42, # Config schema version - bump this when adding new required fields
"_config_version": 43, # Config schema version - bump this when adding new required fields
}
+10
View File
@@ -637,6 +637,16 @@ MIGRATIONS: Tuple[Tuple[int, Callable[[Dict[str, Any], bool], None]], ...] = (
" ✓ Removed cron.model_drift_guard — unpinned cron jobs now keep running on the "
"model/provider they were created under when the global default changes, instead "
"of being skipped. Pin a job or set cron.model to move it."))),
# 42 → 43: gateway.multiplex_profile_allowlist is gone. A multiplexing default gateway serves
# every live profile under profiles/; a profile that must not be served is archived or deleted.
(43, functools.partial(
_rewrite_key, section="gateway", key="multiplex_profile_allowlist", new=None,
match=lambda _cur: True,
added="removed gateway.multiplex_profile_allowlist",
message=(
" ✓ Removed gateway.multiplex_profile_allowlist — the multiplexing gateway now serves "
"every profile under profiles/. Delete or archive a profile you do not want served."),
extra_guard=lambda raw: "multiplex_profile_allowlist" in raw)),
)
+1 -8
View File
@@ -4361,14 +4361,7 @@ def named_profile_served_by_running_multiplexer(profile_name: str | None = None)
if not (cfg.get("multiplex_profiles") or (cfg.get("gateway", {}) or {}).get("multiplex_profiles")):
return False
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
profile_allowlist = _normalize_multiplex_profile_allowlist(raw_allowlist)
return profile_allowlist is None or normalize_profile_name(suffix) in profile_allowlist
return True # a multiplexing default gateway serves every named profile
except Exception:
logger.debug("Multiplexer-serving probe failed", exc_info=True)
return False
+4 -24
View File
@@ -22,7 +22,6 @@ from hermes_constants import clear_named_profile_deleted, mark_named_profile_del
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. ``home`` is the back-compat/Docker
# HOME for tool subprocesses (host subprocesses keep the real HOME so CLI credentials
@@ -704,38 +703,19 @@ def list_profiles() -> List[ProfileInfo]:
return profiles
def profiles_to_serve(multiplex: bool, profile_allowlist: Optional[List[str]] = None) -> List[Tuple[str, Path]]:
def profiles_to_serve(multiplex: bool) -> List[Tuple[str, Path]]:
"""``(profile_name, hermes_home)`` pairs a gateway should serve — the single chokepoint
for "which profiles does the inbound gateway handle".
``multiplex=False``: exactly one entry for the *active* profile (byte-for-byte the
historical single-profile behavior; name is ``"default"`` or the named profile's id).
``multiplex=True``: default plus every live named profile, optionally filtered by
*profile_allowlist* (invalid entries skipped, missing ones warned once)."""
``multiplex=True``: default plus every live named profile under ``profiles/`` (tombstoned
profiles skipped). Pure directory read: never creates a profile dir (#94590)."""
active = get_active_profile_name() or "default"
if not multiplex:
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 = _canon_valid(entry)
except ValueError:
continue
if name != "default":
allowed.add(name)
for entry in _iter_named_profile_dirs():
if allowed is None or entry.name in allowed:
serve.append((entry.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))
serve.extend((entry.name, entry) for entry in _iter_named_profile_dirs())
return serve
+2 -5
View File
@@ -99,12 +99,9 @@ def _start_desktop_cron_ticker(stop_event: "threading.Event", interval: int = 60
try:
from hermes_cli.profiles import (
_check_gateway_running, _served_by_running_multiplexer, profiles_to_serve)
from hermes_cli.web_server_cron import _default_multiplex_profile_allowlist
# Same served set as the multiplexer (allowlist honoured): a profile the default
# gateway deliberately does not serve must not be ticked from the Desktop either.
profile_homes = list(profiles_to_serve(
multiplex=True, profile_allowlist=_default_multiplex_profile_allowlist()))
# Same served set as the multiplexer: default + every live profile under profiles/.
profile_homes = list(profiles_to_serve(multiplex=True))
if profile_homes:
# Even one profile needs the per-tick gateway gate; otherwise
# Desktop races its dedicated gateway for the same cron store.
-17
View File
@@ -79,23 +79,6 @@ def _validate_dashboard_cron_context_from(refs: Optional[List[str]], profile_nam
detail=f"context_from job '{ref}' not found in profile '{profile_name}'")
def _default_multiplex_profile_allowlist() -> "list[str] | None":
"""``gateway.multiplex_profile_allowlist`` as the DEFAULT profile's config declares it (the
multiplexer's served set), so the Desktop ticker mirrors ``gateway/run.py::_multiplex_profile_homes``
instead of ticking every installed profile. ``None`` = serve all (historical behavior)."""
from gateway.config import _normalize_multiplex_profile_allowlist
from hermes_cli.config import read_user_config_raw
from hermes_constants import get_default_hermes_root
cfg_path = get_default_hermes_root() / "config.yaml"
if not cfg_path.exists():
return None
cfg = read_user_config_raw(cfg_path) or {}
raw = cfg.get("multiplex_profile_allowlist") if "multiplex_profile_allowlist" in cfg else (
cfg.get("gateway") or {}).get("multiplex_profile_allowlist")
return _normalize_multiplex_profile_allowlist(raw)
def _cron_profile_dicts() -> List[Dict[str, Any]]:
"""Minimal profile records (callers only consume ``name``); avoids ``list_profiles()``,
whose config parsing, gateway probes and skill counts are GIL pressure on large pools."""
@@ -136,7 +136,7 @@ spawning a `-p coder gateway restart` that could only fail.
"Served" is read from the running gateway's own record (`served_profiles` in the
default home's `gateway_state.json`), so it stays correct when the multiplexer was
enabled only through `GATEWAY_MULTIPLEX_PROFILES` in the default profile's
environment, or when the allowlist was edited after the gateway started.
environment, or when profiles were added after the gateway started.
The multiplexer is the single inbound process; a second profile gateway would
double-bind that profile's platforms. Pass `--force` (accepted by `run`, `start`,
@@ -352,43 +352,36 @@ profile and never shares with the default or any sibling:
| Working directory of a turn (unset `terminal.cwd`) | Same rule as a standalone gateway: `$HOME` for the local backend, sandbox default otherwise | Never the directory the multiplexer process was launched from |
| Command approvals (`command_allowlist`, "always" choices) | The profile's own `config.yaml` | A default-profile "always" never pre-approves a secondary's command; a secondary's choice is saved to its own config |
| Sandbox credential-file mounts (`terminal.credential_files`), `security.redact_secrets`, `browser.*` engine/headed flags, `lsp.*`, auxiliary-provider health marks, `logs/mcp-stderr.log` | The profile's own `config.yaml` / `.env` | Documented default — never the launch profile's cached value |
| Session-search knobs (`sessions.cjk_fts`, `sessions.search_slow_ms`) | The profile's `config.yaml` | Documented default — never the default profile's bridged value |
| Platform proxies (`TELEGRAM_PROXY`, `DISCORD_PROXY`, `HTTPS_PROXY`, …) | The profile's own `.env` | Direct connection — never the default profile's proxy |
| MCP discovery in the Desktop/dashboard backend | Once per served profile home | A profile selected after another has already built an agent still discovers its own `mcp_servers` |
| Dashboard actions (`hermes -p <name> …` spawned by the Desktop/dashboard) | A scrubbed child env pinned to that profile's `HERMES_HOME` | The child loads its own `.env`; the dashboard profile's tokens and ports are not inherited |
What is **shared** by design: the process, its PID/lock and `gateway_state.json`
(default home), the one HTTP listener, and the `profile_routes` table (declared
on the default profile).
### Serving selected profiles
### Which profiles are served
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`:
`gateway.multiplex_profiles: true` serves the default profile plus **every**
live named profile under `profiles/` — there is no per-profile opt-out list.
(The former `gateway.multiplex_profile_allowlist` key is retired; a config
migration removes it from `config.yaml`, and a profile you do not want served is
archived or deleted instead — `hermes profile delete <name>`, or move the
directory out of `profiles/`.) Deleted profiles leave a tombstone and are never
enumerated; a profile whose directory is gone is never recreated by a served
turn, the cron ticker or log routing.
```yaml
gateway:
multiplex_profiles: true
multiplex_profile_allowlist:
- worker
- guest
```
The served set controls `/p/<profile>/` API and webhook prefixes, runtime
status, profile-route eligibility, and which profiles the in-process cron
scheduler ticks (the Desktop backend's ticker enumerates the same set and stands
down for any profile a running multiplexer or its own gateway already serves). A
multiplexer started as `hermes -p <name> gateway run` always ticks its own
profile's cron store as well.
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 (the Desktop backend's ticker follows the same allowlist and
stands down for any profile a running multiplexer already serves). A multiplexer
started as `hermes -p <name> gateway run` always ticks its own profile's cron store
as well. A named profile outside the allowlist may still run its own standalone
gateway.
One caveat: the served set is a **start-time snapshot**. A profile created or
added to the allowlist while the multiplexer is running is not picked up until
`hermes gateway restart` (profiles deleted at runtime are dropped from cron
ticking automatically).
One caveat: the served set is a **start-time snapshot**. A profile created while
the multiplexer is running is not picked up until `hermes gateway restart`
(profiles deleted at runtime are dropped from cron ticking automatically).
### Routing shared-bot chats to profiles (`profile_routes`)
@@ -472,8 +465,7 @@ ids are unchanged.
`profile_routes` requires `gateway.multiplex_profiles: true`; with
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
target profile is not installed (or was deleted), 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.