diff --git a/gateway/config.py b/gateway/config.py index de05751a4d..ef78ed0e15 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -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), diff --git a/gateway/config_loader.py b/gateway/config_loader.py index 491685da5b..7435df0334 100644 --- a/gateway/config_loader.py +++ b/gateway/config_loader.py @@ -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), diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index bd647b2087..490376b3c3 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -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 diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 1a34f58a5d..8dd8281c40 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -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 diff --git a/hermes_cli/AGENTS.md b/hermes_cli/AGENTS.md index ee19866993..1d79b3880b 100644 --- a/hermes_cli/AGENTS.md +++ b/hermes_cli/AGENTS.md @@ -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`) diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 4a6fbb0fd4..581a6e9f31 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.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 } diff --git a/hermes_cli/config_migrations.py b/hermes_cli/config_migrations.py index 92b03cb4fc..f4e86788c5 100644 --- a/hermes_cli/config_migrations.py +++ b/hermes_cli/config_migrations.py @@ -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)), ) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 0f28e7c31c..2032d427db 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -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 diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 222ccd1c43..37547f54c5 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -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 diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 3472417510..d95f8182a6 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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. diff --git a/hermes_cli/web_server_cron.py b/hermes_cli/web_server_cron.py index 92bfb5d34b..e894a23494 100644 --- a/hermes_cli/web_server_cron.py +++ b/hermes_cli/web_server_cron.py @@ -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.""" diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md index 0ae62f4df8..0d4bdad508 100644 --- a/website/docs/user-guide/multi-profile-gateways.md +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -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 …` 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 `, 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//` 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 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//` 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 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.