fix(mcp): served profiles keep their own MCP connections without the multiplex flag

A dashboard/desktop backend (and the per-profile cron ticker) serves sessions of
several profiles through the HERMES_HOME contextvar override while
gateway.multiplex_profiles stays off. _mcp_registry_scope() keyed every MCP
connection by the bare server name in that mode, so the first profile to
discover `zernio` owned the only connection and every later served profile —
including one whose config carries a different Authorization header — called
the server through it and got the other account's data back (#111151).

The registry scope now follows the served home: a routed profile (an override
naming a home other than the process home) gets the same per-profile overlay
the multiplexer uses, so a same-named server with other credentials is a
separate connection, discovery for profile B is a connect candidate instead of
"already connected", and status/tool views stay per profile. Single-profile
processes (no override) keep bare keys, byte-identical to before.

Fixes #111151

Credit: #111158 by @KoNit-K located the inert flag on hermes_cli surfaces; its
fix (activating fail-closed multiplex secret scoping from config.yaml on the
dashboard) is not taken — the connection-key seam, not the secret-scope mode,
is what leaks the connection, and flipping the process-wide mode from the
dashboard would change credential resolution for every code path in it.
This commit is contained in:
teknium1
2026-09-14 18:57:32 -07:00
committed by Teknium
parent 8a2996503a
commit 57c4e1d963
3 changed files with 42 additions and 2 deletions
@@ -241,3 +241,34 @@ def test_parallel_safe_opt_in_is_per_profile(two_profiles):
two_profiles("a")
assert disc.is_mcp_tool_parallel_safe("mcp__x__t") is False
def test_served_profile_without_multiplex_flag_gets_its_own_connection(two_profiles, monkeypatch):
"""A dashboard/desktop backend serves profiles through the HERMES_HOME override with
``gateway.multiplex_profiles`` off; a same-named server with other credentials must still be a
separate connection there, or profile B calls the server as profile A (#111151). The launch
profile itself (no override) keeps the bare, unscoped key."""
import tools.mcp_tool as core
from tools import mcp_tool_discovery as disc
from tools import mcp_tool_registration as reg
from tools.mcp_tool_scope import _resolve_server_key, _server_key
from tools.registry import registry
monkeypatch.setattr("agent.secret_scope.is_multiplex_active", lambda: False)
cfg_a = {"url": "https://mcp.example/x", "headers": {"Authorization": "Bearer A"}}
cfg_b = {"url": "https://mcp.example/x", "headers": {"Authorization": "Bearer B"}}
scope_a = two_profiles("a")
srv_a = _server("x", cfg_a)
disc._adopt_server("x", srv_a)
srv_a._registered_tool_names = reg._register_server_tools("x", srv_a, cfg_a)
assert (scope_a, "x") in core._servers
two_profiles("b")
assert _resolve_server_key("x") != (scope_a, "x")
assert registry.get_tool_names_for_toolset("mcp-x") == []
assert "x" in disc._select_new_servers({"x": cfg_b})
with patch("hermes_constants.get_hermes_home_override", return_value=None):
assert core._mcp_registry_scope() is None
assert _server_key("x") == "x"
+10 -2
View File
@@ -637,10 +637,18 @@ def _update_death_supervisor(verb: str, pgids) -> None:
def _mcp_registry_scope() -> Optional[str]:
"""Registry scope for MCP registrations: a profile overlay under a multiplexer, else None."""
"""Registry scope for MCP registrations: a profile overlay when this process serves profiles,
else None. Under ``gateway.multiplex_profiles`` every turn runs scoped; a process that serves a
routed profile through the HERMES_HOME override (dashboard/desktop backend, per-profile cron
ticker) is a multiplexer too, even with the flag off — keying its connections by the bare name
would hand one profile's credentialed connection to every other served profile (#111151).
Single-profile processes (no override, or an override naming their own home) keep bare names."""
from agent.secret_scope import is_multiplex_active
from hermes_constants import get_hermes_home_override, get_process_hermes_home, hermes_home_key
if not is_multiplex_active():
return None
override = get_hermes_home_override()
if override is None or hermes_home_key(override) == hermes_home_key(get_process_hermes_home()):
return None
from tools.registry import registry
return registry.current_scope_key()
@@ -436,6 +436,7 @@ profile and never shares with the default or any sibling:
| 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` |
| MCP connections in the Desktop/dashboard backend and the per-profile cron ticker | Keyed per served profile even with `gateway.multiplex_profiles` off — same rule as the multiplexer | A same-named `mcp_servers` entry with other credentials is its own connection; a served profile never calls a server as another profile |
| 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 |
| Cron `.env` tuning (`HERMES_CRON_TIMEOUT`, `HERMES_MODEL` fallback, `HERMES_CRON_MAX_PARALLEL`, prefill file), worker / Bot Chat child env | The profile's own `.env`; children never inherit the default profile's `.env` settings or bridged `TERMINAL_*` policy | Cron defaults / model refusal, exactly as a standalone `hermes -p <name> gateway run` |
| Kanban workers and notifications for a profile's tasks | The assignee's `.env` + `config.yaml` (toolset pin, terminal backend, media policy, display language) | — |