Skip to content

fix(mcp): dashboard-served profiles keep their own MCP connections and credentials (#111151) - #111481

Merged
teknium1 merged 2 commits into
mainfrom
fix/authmcp-mcp-scope-served-profiles
Sep 15, 2026
Merged

teknium1 merged 2 commits into
mainfrom
fix/authmcp-mcp-scope-served-profiles

Conversation

@teknium1

@teknium1 teknium1 commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

A dashboard/Desktop backend serving several profiles gives each profile its own MCP connection, so a profile's tools never call the server with another profile's credentials.

Fixes #111151

  • tools/mcp_tool.py::_mcp_registry_scope — a process serving a routed profile through the HERMES_HOME override (dashboard/Desktop backend, per-profile cron ticker) now uses the same per-profile connection key as the multiplexer, even with gateway.multiplex_profiles off. Every ledger already keys through _mcp_registry_scope (_server_key, _resolve_server_key, _select_new_servers, status, registry overlays), so no other site changes. A process with no override, or an override naming its own home, keeps the bare key — single-profile behaviour is byte-identical.
  • One invariant test in tests/tools/test_mcp_multiplex_connection_keys.py (multiplex flag off, two served homes, same server name with different Authorization headers).
  • Docs: multi-profile-gateways.md served-profile table.

Live repro: probe_mux.py — multiplex off, profile A discovers zernio (Bearer A) under its home override, then profile B's session resolves the same name

origin/main this branch
B's _resolve_server_key("zernio") 'zernio' → A's connection, headers Bearer A (<B home>, 'zernio') → no connection yet
B's mcp-zernio tools before its own discovery A's tools []
"zernio" in _select_new_servers(B config) False (silently reuses A) True (B connects with its own credentials)
B's get_mcp_status connected connecting
launch profile (no override) bare key, connected bare key, connected (unchanged)

Root cause: _mcp_registry_scope() returned None whenever is_multiplex_active() was false, and nothing under hermes_cli//tui_gateway/ sets that flag, so the profile-keyed connections added for #106005/#91654 were inert on exactly the surface that serves several profiles in one process.

Credit

#111158 by @KoNit-K located the inert flag on the dashboard; its approach (flipping the process-wide fail-closed secret mode from config.yaml in web_server.start_server) is not taken — the leak is the connection key, and the multiplex secret mode changes credential resolution for every code path in the dashboard process. Policy: isolation is between profiles via the home scope; no environ fallthrough is added.

Infographic

mcp-scope-served-profiles

Review fixes

  • fixed (c36dd99): check_fn_cache_scope() still returned None with multiplex off, so a served profile's (correct) "not my connection" verdict was cached under the launch profile's key and the owner lost its mcp__x__* tools for the TTL window. Both _mcp_registry_scope() and check_fn_cache_scope() now call one shared helper, agent.secret_scope.serves_routed_profile(). Reviewer probe probe_481b.py: False / False / []False (scope='/tmp/…B') / True / ['mcp__x__t']. New test test_served_profile_check_fn_verdict_does_not_shadow_launch_profile (red before, green after); mcp/registry/secret_scope suites 226 passed.

…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.
@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on c36dd99 — fix: key the check_fn cache by the same served-profile predi

⚠️ Warnings

OSV vulnerability scan · View job

76 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.


debug info

CI timings

CI timings · View report · View job

Wall time 6m46s vs 6m9s (+10.0%). 12 job(s) slower, 2 faster, 1 unchanged.

  • Docs Site / docs-site-checks: +48.0s
  • OS-specific tests / Windows-only tests: +47.0s
  • Python lints / Windows footguns (blocking): +17.0s
  • Python tests / e2e: +12.0s
  • OSV scan / Scan lockfiles / osv-scan: +12.0s

@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/tools Tool registry, model_tools, toolsets tool/mcp MCP client and OAuth area/profiles Multi-profile isolation, HERMES_HOME scoping sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Sep 15, 2026
@alt-glitch

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Supersedes community fixes #111354 and #111158 for #111151 (same dashboard-served-profile MCP connection scoping). Cross-linking so reviewers compare the three approaches in one place; this core-team PR keys the connection on the served HERMES_HOME override without enabling the global multiplex flag.

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed exact head 4fb175597a3633a84ace6753aa175f0e810960a1 against its actual merge base 2179a279ae04bfadf8efbc49a01ca0abfb738000 and current main dfc28b61a0cfed58bcc200038c6bfec6f31adcd2. This carrier is one commit ahead / 25 commits behind current main; the intervening main delta does not touch the three changed paths here, so I do not see a merge-order blocker from that staleness.

The connection-key part is narrow and correct as far as it goes. #108352 is the merged foundation for profile-qualified MCP ledgers under the real gateway multiplexer, and this patch correctly extends the same connection identity to dashboard/Desktop/per-profile-cron execution that already has an explicit routed HERMES_HOME even when gateway.multiplex_profiles is false. The normalization back to the bare key when the override names the process home also avoids gratuitously splitting the default profile from its startup-warm connection.

There is still one P1 authority-boundary blocker before this can honestly close #111151 or supersede the community carriers as a complete fix: the patch upgrades the routed profile into MCP connection identity without upgrading the same routed profile into credential authority.

At this exact head, agent.secret_scope.get_secret() still behaves differently from _mcp_registry_scope(): when _MULTIPLEX_ACTIVE is false, an installed profile secret scope that does not contain a requested key falls through to ambient os.environ; with no secret scope installed at all, it also falls through to ambient os.environ. The dashboard's _hermes_home_scope / _config_profile_scope installs only the context-local HERMES_HOME route. So profile B can now get the correct (B, server) connection slot while ${MCP_TOKEN} still resolves to the dashboard launch profile A's token. That can produce a distinct B-keyed connection authenticated as A, or—when the resolved route/fingerprint matches—permit B to reuse/adopt A's credentialed connection. The user-visible failure remains the same class: a B session can call the external service as A instead of failing closed.

That is not hypothetical scope expansion beyond the report: #111151 itself calls out that secret_scope fails open on this dashboard path because the multiplex flag is not set and explicitly requires the system to fail loudly rather than return another profile's data. The new regression test cannot witness this because it starts from already-resolved literal Bearer A / Bearer B configs; it proves ledger separation after credential resolution, not the credential-resolution authority that feeds the ledger.

The required invariant is: an explicit cross-profile HERMES_HOME route must bind connection identity and secret lookup authority together. Preserve ambient env fallback for a genuinely unscoped single-profile process, but once execution is routed to another profile, a missing or absent profile secret scope must not inherit the launch profile's credential. Please add the negative witness through the real ${MCP_TOKEN} resolver: keep ambient MCP_TOKEN=launch-token, route to profile B with is_multiplex_active=False, omit B's token, cover both an empty mapping and no mapping installed, and prove B cannot resolve launch-token or use/adopt A's connection.

Topology / provenance matters here because this PR is now explicitly presented as the superseding core carrier. #111158 (KoNit-K) is adjacent/partial: it correctly identified the inert dashboard secret-mode seam and installs profile secret scope, but derives strictness from gateway.multiplex_profiles, so it is insufficient for the reported flag-false configuration. #111354 (JoaoMarcos44) is a competing/superseded carrier for the same #111151 defect class, not unrelated work: it independently carries the same routed connection-key fix plus cache scoping and, after review, added a dedicated routed-profile secret-authority change. Its latest follow-up still has its own missing-map residual, so I am not treating it as already correct; however, if #111481 is the chosen carrier, the authority invariant and negative resolver coverage discovered there should be salvaged rather than lost under a blanket "superseded" closure. #108352 remains the merged foundation; #106005/#91654 are lineage for the qualified MCP state machinery, not duplicate carriers.

Acceptance is strong: exact-head CI 34920757233, Docker 34920756616, and Nix 34920756609 all completed successfully. This is a one-commit carrier, so every surviving implementation commit is also hosted-green. That clears acceptance risk; it does not clear the semantic P1 above.

Aside from that credential-authority mismatch, I did not find another blocker in the three-path patch. The connection-key projection is appropriately small, the current-main drift is path-disjoint, and the exact head is green. Close the routed-secret half of the authority boundary with the negative witness and this becomes a coherent core closure for #111151.

Comment thread tools/mcp_tool.py Outdated
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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — connection identity becomes profile-qualified here, but credential authority is still ambient in this exact mode. With is_multiplex_active()==False, agent.secret_scope.get_secret() still falls through to os.environ when the routed scope lacks a key, and also when no secret scope is installed. The dashboard route contexts install HERMES_HOME, so profile B can receive its own (B, name) connection key while ${MCP_TOKEN} still resolves to launch profile A's token. That preserves the wrong-account failure class #111151 is trying to eliminate. Please bind secret lookup authority to this explicit routed identity too, while keeping ambient fallback only for genuinely unscoped single-profile execution.

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"}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test starts from already-resolved literal Bearer A / Bearer B configs, so it proves ledger separation but bypasses the credential resolver that still leaks ambient authority. Please add the negative witness through _resolve_mcp_server_config: keep ambient MCP_TOKEN=launch-token, route to B with multiplex flag off, omit B's token, test both an empty secret mapping and no mapping installed, and prove B cannot resolve launch-token or use/adopt A's connection.

…he MCP registry scope

_mcp_registry_scope() became profile-keyed for served profiles with the
multiplex flag off, but check_fn_cache_scope() still returned None in that
mode, so the process-wide availability cache stayed keyed (fn, None) across
profiles. A served profile whose mcp__x__* check_fn now correctly resolves to
its own (absent) connection cached False for the TTL window and the launch
profile that owns the live connection lost its tools for that window.

Both sites now call one helper, agent.secret_scope.serves_routed_profile()
(multiplex on, or a HERMES_HOME override naming a home other than the
process home), so the registry scope and the cache key can no longer drift.

Review finding: served profile B's check_fn verdict shadowed the launch profile's live mcp tools via the unscoped check_fn cache.

@ehz0ah ehz0ah left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exact-head follow-up found one additional P1 lifecycle boundary. The earlier credential-authority finding remains valid and is not repeated here. I reproduced this separate case with the repository test runner on 4fb175597a3633a84ace6753aa175f0e810960a1.

Comment thread tools/mcp_tool.py Outdated
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()):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep the default connection owner distinct from wildcard teardown. This branch returns None for an explicit override that resolves to the process home. reconcile_mcp_servers_with_config() then passes scope=None, names={"x"} to shutdown_mcp_servers(), where scope is None selects every owner with that server name. With bare default key "x" and profile B key (scope_b, "x"), removing only the default config entry shuts down both sessions and removes B's connection. I reproduced this through the repository test runner at this exact head. Both shutdown callbacks ran and B's scoped key disappeared. Preserve startup reuse of the bare key, but carry an exact default-owner selector or exact keys into teardown. Add a default-plus-named same-server reconciliation test that proves B's session and bookkeeping remain untouched.

@ehz0ah ehz0ah left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review of exact head c36dd99e522d20246426d8b701f759ce383c00fd. The new commit correctly keys the check_fn cache with the same served-profile predicate as the MCP connection registry. All 8 submitted regression tests pass at this head and in a synthetic merge over current main.

Two existing P1 blockers remain unresolved:

I reran three focused negative cases against this exact head and the synthetic current-main merge. All three still fail in the buggy direction. The adjacent secret-scope and reconcile suites pass 27 tests in both trees. A current-main cron control passes because routed cron execution installs _MULTIPLEX_CONTEXT, but the dashboard profile scope still does not install that context, so the reported dashboard path remains exposed.

git diff --check is clean, the review worktree is clean, and all required hosted checks are green. The cache change is good, but both linked authority and teardown boundaries need fixes before this closes #111151.

@kshitijk4poor

Copy link
Copy Markdown
Contributor

Review notes at head c36dd99 (read-only; two things I'd fix before merge, both verified against the head):

  1. Wildcard teardown regression — serves_routed_profile() returns False when the HERMES_HOME override names the process home, so _mcp_registry_scope() is None there; mcp_tool_discovery.reconcile_mcp_servers_with_config then calls shutdown_mcp_servers(scope=None, names={x}), and mcp_tool_lifecycle.py:139 treats scope is None as every owner. With this PR the registry now holds (B, x) keys for dashboard-served profiles, so the default profile removing x from its config tears down profile B's live connection. Before the PR no per-profile keys existed with multiplex off, so the wildcard was harmless. Fix: pass exact keys (or an "owner is None" selector) instead of scope=None.

  2. Credential authority — agent/secret_scope.py::get_secret miss branch is still default if is_multiplex_active() else os.environ, so profile B gets its own connection key but ${MCP_TOKEN} still resolves from the launch profile's env (compute_host.py installs a scope, so the reachable case is missing-key, not missing-scope).

Simplest whole-class fix now that dbede34 is on main: call set_multiplex_context(True) for routed homes at the four seams (tui_gateway/compute_host.py:316, hermes_cli/web_routers/mcp.py:60, hermes_cli/web_server_mcp.py:126, tui_gateway/mcp_oauth_sessions.py:127) the way cron/scheduler.py:2875 already does — then is_multiplex_active() is True, _mcp_registry_scope scopes, get_secret fails closed, and serves_routed_profile becomes unnecessary. Minor: tools/registry.py::check_fn_cache_scope now bypasses the warm cache for any override ≠ process home (perf only).

Disposition of the externals for #111151: #111354 (@JoaoMarcos44, earliest submitter — worth a credit line) has the same None-normalisation hole and a get_secret change that fails closed on ANY override incl. hermes -p single-profile CLI; #111158 is flag-derived. Both are covered by this PR once the two points above land.

@teknium1
teknium1 merged commit 5117e3b into main Sep 15, 2026
37 checks passed
@teknium1
teknium1 deleted the fix/authmcp-mcp-scope-served-profiles branch September 15, 2026 11:56
tancou added a commit to tancou/hermes-agent that referenced this pull request Sep 22, 2026
…lip routed-profile decisions

Symptom: a host that serves several profiles from one process and mirrors
the active turn's profile into `os.environ["HERMES_HOME"]` for legacy
readers (Hermes WebUI does this on every chat turn, next to the
context-local override) makes every launch-home decision see the served
profile as the launch profile. Two profiles that both configure `atlassian`
with different credentials share whichever MCP connection came first: a
READ_ONLY_MODE=false profile ends up calling a read-only server
(nesquena/hermes-webui#7721). The same misjudgement leaves the launch
residue in the served profile's child env, seeds the launch profile's
bridged allow-all grant into the served profile's secret scope, and lets
the served profile's `terminal.*` config bridge into the shared process env.

Cause: four launch-home checks compare the task's override with
`get_process_hermes_home()`, which reads `HERMES_HOME` live:
`agent.secret_scope.serves_routed_profile` (keys the MCP ledger via
`_mcp_registry_scope`, NousResearch#108352 / NousResearch#111481, and the check_fn cache, NousResearch#111151),
`agent.secret_scope._is_process_home`, `tools.environments.local._is_routed_home`
and `hermes_cli.env_loader._process_hermes_home`. Under the mirror the two
sides are equal for every turn.

Change: `hermes_constants.pin_process_hermes_home(path | None)` lets the
host record the home it serves as its own; `get_routing_process_hermes_home()`
returns the pin when set, else `get_process_hermes_home()`; the four checks
compare against it. The pin is deliberately NOT folded into
`get_process_hermes_home()`: `get_hermes_home()` falls back to it for tasks
carrying no override (MCP loop, spawners), and the host's mirror exists
precisely so those readers see the served profile. Only "is this task
routed / is this the launch home" changes. Unpinned, behaviour is
byte-for-byte the old one; hosts that never mutate `HERMES_HOME` need not
call it. `activate_multi_profile_hosting()` is not the seam for this: it
flips `get_secret` fail-closed process-wide and freezes the launch env,
which an embedding host cannot adopt as a bug fix.

Tests (2 invariants, parametrized over the four checks plus the MCP ledger
key; red on main, green here): pinned + mirrored env -> the served home is
routed and the launch home is not, the MCP key is `(home_key, name)`,
`get_process_hermes_home()` still follows the env var; never pinned or
pinned-then-cleared -> old semantics, including "a mirrored env var IS the
launch home". `tests/conftest.py` resets the pin per test so the
module-global cannot leak between files.

Live repro (WebUI + a stdio FastMCP server named `atlassian` in two
profiles, one gated by READ_ONLY_MODE): base -> one ledger key
`'atlassian'`, the write profile lists only the read-only tools; fixed ->
`(<read_home_key>, 'atlassian')` and `(<write_home_key>, 'atlassian')`,
each profile lists its own tools.

Docs: `gateway/AGENTS.md` § Profile scope (one launch-home identity) and the
isolation table in `website/docs/user-guide/multi-profile-gateways.md`.
Also maps the author e-mail under contributors/emails/ (attribution check).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
teknium1 pushed a commit that referenced this pull request Sep 23, 2026
…lip routed-profile decisions

Symptom: a host that serves several profiles from one process and mirrors
the active turn's profile into `os.environ["HERMES_HOME"]` for legacy
readers (Hermes WebUI does this on every chat turn, next to the
context-local override) makes every launch-home decision see the served
profile as the launch profile. Two profiles that both configure `atlassian`
with different credentials share whichever MCP connection came first: a
READ_ONLY_MODE=false profile ends up calling a read-only server
(nesquena/hermes-webui#7721). The same misjudgement leaves the launch
residue in the served profile's child env, seeds the launch profile's
bridged allow-all grant into the served profile's secret scope, and lets
the served profile's `terminal.*` config bridge into the shared process env.

Cause: four launch-home checks compare the task's override with
`get_process_hermes_home()`, which reads `HERMES_HOME` live:
`agent.secret_scope.serves_routed_profile` (keys the MCP ledger via
`_mcp_registry_scope`, #108352 / #111481, and the check_fn cache, #111151),
`agent.secret_scope._is_process_home`, `tools.environments.local._is_routed_home`
and `hermes_cli.env_loader._process_hermes_home`. Under the mirror the two
sides are equal for every turn.

Change: `hermes_constants.pin_process_hermes_home(path | None)` lets the
host record the home it serves as its own; `get_routing_process_hermes_home()`
returns the pin when set, else `get_process_hermes_home()`; the four checks
compare against it. The pin is deliberately NOT folded into
`get_process_hermes_home()`: `get_hermes_home()` falls back to it for tasks
carrying no override (MCP loop, spawners), and the host's mirror exists
precisely so those readers see the served profile. Only "is this task
routed / is this the launch home" changes. Unpinned, behaviour is
byte-for-byte the old one; hosts that never mutate `HERMES_HOME` need not
call it. `activate_multi_profile_hosting()` is not the seam for this: it
flips `get_secret` fail-closed process-wide and freezes the launch env,
which an embedding host cannot adopt as a bug fix.

Tests (2 invariants, parametrized over the four checks plus the MCP ledger
key; red on main, green here): pinned + mirrored env -> the served home is
routed and the launch home is not, the MCP key is `(home_key, name)`,
`get_process_hermes_home()` still follows the env var; never pinned or
pinned-then-cleared -> old semantics, including "a mirrored env var IS the
launch home". `tests/conftest.py` resets the pin per test so the
module-global cannot leak between files.

Live repro (WebUI + a stdio FastMCP server named `atlassian` in two
profiles, one gated by READ_ONLY_MODE): base -> one ledger key
`'atlassian'`, the write profile lists only the read-only tools; fixed ->
`(<read_home_key>, 'atlassian')` and `(<write_home_key>, 'atlassian')`,
each profile lists its own tools.

Docs: `gateway/AGENTS.md` § Profile scope (one launch-home identity) and the
isolation table in `website/docs/user-guide/multi-profile-gateways.md`.
Also maps the author e-mail under contributors/emails/ (attribution check).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
teknium1 pushed a commit that referenced this pull request Sep 23, 2026
…lip routed-profile decisions

Symptom: a host that serves several profiles from one process and mirrors
the active turn's profile into `os.environ["HERMES_HOME"]` for legacy
readers (Hermes WebUI does this on every chat turn, next to the
context-local override) makes every launch-home decision see the served
profile as the launch profile. Two profiles that both configure `atlassian`
with different credentials share whichever MCP connection came first: a
READ_ONLY_MODE=false profile ends up calling a read-only server
(nesquena/hermes-webui#7721). The same misjudgement leaves the launch
residue in the served profile's child env, seeds the launch profile's
bridged allow-all grant into the served profile's secret scope, and lets
the served profile's `terminal.*` config bridge into the shared process env.

Cause: four launch-home checks compare the task's override with
`get_process_hermes_home()`, which reads `HERMES_HOME` live:
`agent.secret_scope.serves_routed_profile` (keys the MCP ledger via
`_mcp_registry_scope`, #108352 / #111481, and the check_fn cache, #111151),
`agent.secret_scope._is_process_home`, `tools.environments.local._is_routed_home`
and `hermes_cli.env_loader._process_hermes_home`. Under the mirror the two
sides are equal for every turn.

Change: `hermes_constants.pin_process_hermes_home(path | None)` lets the
host record the home it serves as its own; `get_routing_process_hermes_home()`
returns the pin when set, else `get_process_hermes_home()`; the four checks
compare against it. The pin is deliberately NOT folded into
`get_process_hermes_home()`: `get_hermes_home()` falls back to it for tasks
carrying no override (MCP loop, spawners), and the host's mirror exists
precisely so those readers see the served profile. Only "is this task
routed / is this the launch home" changes. Unpinned, behaviour is
byte-for-byte the old one; hosts that never mutate `HERMES_HOME` need not
call it. `activate_multi_profile_hosting()` is not the seam for this: it
flips `get_secret` fail-closed process-wide and freezes the launch env,
which an embedding host cannot adopt as a bug fix.

Tests (2 invariants, parametrized over the four checks plus the MCP ledger
key; red on main, green here): pinned + mirrored env -> the served home is
routed and the launch home is not, the MCP key is `(home_key, name)`,
`get_process_hermes_home()` still follows the env var; never pinned or
pinned-then-cleared -> old semantics, including "a mirrored env var IS the
launch home". `tests/conftest.py` resets the pin per test so the
module-global cannot leak between files.

Live repro (WebUI + a stdio FastMCP server named `atlassian` in two
profiles, one gated by READ_ONLY_MODE): base -> one ledger key
`'atlassian'`, the write profile lists only the read-only tools; fixed ->
`(<read_home_key>, 'atlassian')` and `(<write_home_key>, 'atlassian')`,
each profile lists its own tools.

Docs: `gateway/AGENTS.md` § Profile scope (one launch-home identity) and the
isolation table in `website/docs/user-guide/multi-profile-gateways.md`.
Also maps the author e-mail under contributors/emails/ (attribution check).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
teknium1 pushed a commit that referenced this pull request Sep 23, 2026
…lip routed-profile decisions

Symptom: a host that serves several profiles from one process and mirrors
the active turn's profile into `os.environ["HERMES_HOME"]` for legacy
readers (Hermes WebUI does this on every chat turn, next to the
context-local override) makes every launch-home decision see the served
profile as the launch profile. Two profiles that both configure `atlassian`
with different credentials share whichever MCP connection came first: a
READ_ONLY_MODE=false profile ends up calling a read-only server
(nesquena/hermes-webui#7721). The same misjudgement leaves the launch
residue in the served profile's child env, seeds the launch profile's
bridged allow-all grant into the served profile's secret scope, and lets
the served profile's `terminal.*` config bridge into the shared process env.

Cause: four launch-home checks compare the task's override with
`get_process_hermes_home()`, which reads `HERMES_HOME` live:
`agent.secret_scope.serves_routed_profile` (keys the MCP ledger via
`_mcp_registry_scope`, #108352 / #111481, and the check_fn cache, #111151),
`agent.secret_scope._is_process_home`, `tools.environments.local._is_routed_home`
and `hermes_cli.env_loader._process_hermes_home`. Under the mirror the two
sides are equal for every turn.

Change: `hermes_constants.pin_process_hermes_home(path | None)` lets the
host record the home it serves as its own; `get_routing_process_hermes_home()`
returns the pin when set, else `get_process_hermes_home()`; the four checks
compare against it. The pin is deliberately NOT folded into
`get_process_hermes_home()`: `get_hermes_home()` falls back to it for tasks
carrying no override (MCP loop, spawners), and the host's mirror exists
precisely so those readers see the served profile. Only "is this task
routed / is this the launch home" changes. Unpinned, behaviour is
byte-for-byte the old one; hosts that never mutate `HERMES_HOME` need not
call it. `activate_multi_profile_hosting()` is not the seam for this: it
flips `get_secret` fail-closed process-wide and freezes the launch env,
which an embedding host cannot adopt as a bug fix.

Tests (2 invariants, parametrized over the four checks plus the MCP ledger
key; red on main, green here): pinned + mirrored env -> the served home is
routed and the launch home is not, the MCP key is `(home_key, name)`,
`get_process_hermes_home()` still follows the env var; never pinned or
pinned-then-cleared -> old semantics, including "a mirrored env var IS the
launch home". `tests/conftest.py` resets the pin per test so the
module-global cannot leak between files.

Live repro (WebUI + a stdio FastMCP server named `atlassian` in two
profiles, one gated by READ_ONLY_MODE): base -> one ledger key
`'atlassian'`, the write profile lists only the read-only tools; fixed ->
`(<read_home_key>, 'atlassian')` and `(<write_home_key>, 'atlassian')`,
each profile lists its own tools.

Docs: `gateway/AGENTS.md` § Profile scope (one launch-home identity) and the
isolation table in `website/docs/user-guide/multi-profile-gateways.md`.
Also maps the author e-mail under contributors/emails/ (attribution check).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
teknium1 pushed a commit that referenced this pull request Sep 23, 2026
…lip routed-profile decisions

Symptom: a host that serves several profiles from one process and mirrors
the active turn's profile into `os.environ["HERMES_HOME"]` for legacy
readers (Hermes WebUI does this on every chat turn, next to the
context-local override) makes every launch-home decision see the served
profile as the launch profile. Two profiles that both configure `atlassian`
with different credentials share whichever MCP connection came first: a
READ_ONLY_MODE=false profile ends up calling a read-only server
(nesquena/hermes-webui#7721). The same misjudgement leaves the launch
residue in the served profile's child env, seeds the launch profile's
bridged allow-all grant into the served profile's secret scope, and lets
the served profile's `terminal.*` config bridge into the shared process env.

Cause: four launch-home checks compare the task's override with
`get_process_hermes_home()`, which reads `HERMES_HOME` live:
`agent.secret_scope.serves_routed_profile` (keys the MCP ledger via
`_mcp_registry_scope`, #108352 / #111481, and the check_fn cache, #111151),
`agent.secret_scope._is_process_home`, `tools.environments.local._is_routed_home`
and `hermes_cli.env_loader._process_hermes_home`. Under the mirror the two
sides are equal for every turn.

Change: `hermes_constants.pin_process_hermes_home(path | None)` lets the
host record the home it serves as its own; `get_routing_process_hermes_home()`
returns the pin when set, else `get_process_hermes_home()`; the four checks
compare against it. The pin is deliberately NOT folded into
`get_process_hermes_home()`: `get_hermes_home()` falls back to it for tasks
carrying no override (MCP loop, spawners), and the host's mirror exists
precisely so those readers see the served profile. Only "is this task
routed / is this the launch home" changes. Unpinned, behaviour is
byte-for-byte the old one; hosts that never mutate `HERMES_HOME` need not
call it. `activate_multi_profile_hosting()` is not the seam for this: it
flips `get_secret` fail-closed process-wide and freezes the launch env,
which an embedding host cannot adopt as a bug fix.

Tests (2 invariants, parametrized over the four checks plus the MCP ledger
key; red on main, green here): pinned + mirrored env -> the served home is
routed and the launch home is not, the MCP key is `(home_key, name)`,
`get_process_hermes_home()` still follows the env var; never pinned or
pinned-then-cleared -> old semantics, including "a mirrored env var IS the
launch home". `tests/conftest.py` resets the pin per test so the
module-global cannot leak between files.

Live repro (WebUI + a stdio FastMCP server named `atlassian` in two
profiles, one gated by READ_ONLY_MODE): base -> one ledger key
`'atlassian'`, the write profile lists only the read-only tools; fixed ->
`(<read_home_key>, 'atlassian')` and `(<write_home_key>, 'atlassian')`,
each profile lists its own tools.

Docs: `gateway/AGENTS.md` § Profile scope (one launch-home identity) and the
isolation table in `website/docs/user-guide/multi-profile-gateways.md`.
Also maps the author e-mail under contributors/emails/ (attribution check).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/profiles Multi-profile isolation, HERMES_HOME scoping comp/tools Tool registry, model_tools, toolsets P1 High — major feature broken, no workaround sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data tool/mcp MCP client and OAuth type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Dashboard-served profile sessions call MCP servers with another profile's credentials (multiplex scope never active on hermes_cli surfaces)

5 participants