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:
+2
-36
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user