Skip to content

fix(profiles): pin the launch home so a mirrored HERMES_HOME cannot flip routed-profile decisions - #119129

Closed
tancou wants to merge 1 commit into
NousResearch:mainfrom
tancou:fix/pin-process-home-for-routed-profiles
Closed

tancou wants to merge 1 commit into
NousResearch:mainfrom
tancou:fix/pin-process-home-for-routed-profiles

Conversation

@tancou

@tancou tancou commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Lets an embedding host pin the Hermes home it serves as its own, so the four "is this task routed / is this the launch home" decisions keep working while the host mirrors the served profile into os.environ["HERMES_HOME"] for legacy readers. Fixes #119242.

Symptom. Hermes WebUI serves several profiles from one process and mirrors the active turn's profile into HERMES_HOME on every chat turn (next to the context-local override). Every launch-home decision then sees the served profile as the launch profile: MCP connections fall back to bare, cross-profile names (two profiles configuring atlassian with different credentials share whichever connection came first; a READ_ONLY_MODE=false profile calls the read-only server, nesquena/hermes-webui#7721), the launch residue is never stripped from the served profile's child env, the launch profile's bridged allow-all grant is seeded into the served profile's secret scope, and the served profile's terminal.* config bridges into the shared process env.

Cause. 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() all compare against get_process_hermes_home(), which reads HERMES_HOME live. Under the mirror both sides are equal for every turn.

Change. hermes_constants.pin_process_hermes_home(path | None) records the home the host 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. Unpinned, behaviour is byte-for-byte the old one (tested); hosts that never mutate HERMES_HOME need nothing.

Why this shape, and not…

  • …folding the pin 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 the routing decision changes; get_process_hermes_home() stays the override-immune resolver hermes_cli/env_loader.py documents.
  • …activate_multi_profile_hosting() (tui_gateway/launch_profile_policy.py): that seam flips get_secret fail-closed process-wide and freezes the launch env; an embedding host cannot adopt it as a bug fix, and it would not make the sibling launch-home checks agree either.
  • …a ContextVar: the pin names the process's own identity, not a per-task value (same reason _MULTIPLEX_ACTIVE is process-global). tests/conftest.py resets it per test.

The pin never widens secret authority: no get_secret path changes, only which home counts as "the launch home" in the four comparisons.

Related Issue

Fixes #119242. Downstream: nesquena/hermes-webui#7721; companion WebUI PR nesquena/hermes-webui#7720 (calls pin_process_hermes_home when present; works without this change for its own status/reload paths, needs it for chat-turn isolation).

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • hermes_constants.py: pin_process_hermes_home(), get_routing_process_hermes_home() (+23 lines).
  • agent/secret_scope.py: serves_routed_profile() and _is_process_home() compare against the routing home.
  • tools/environments/local.py: _is_routed_home() likewise (covers strip_launch_profile_env, served_profile_child_env, kanban dispatch).
  • hermes_cli/env_loader.py: _process_hermes_home() likewise (covers the terminal.* env-bridge guard and the config-cache reuse guard).
  • tests/agent/test_serves_routed_profile_pin.py (new): two invariant tests, parametrized over the four checks plus the MCP ledger key.
  • tests/conftest.py: the hermetic fixture resets the pin.
  • gateway/AGENTS.md § Profile scope: "one launch-home identity" bullet; website/docs/user-guide/multi-profile-gateways.md: one row in the isolation table.
  • contributors/emails/git.commits@tancou.me (attribution check).

How to Test

  1. Red on main: drop tests/agent/test_serves_routed_profile_pin.py onto current main → all 10 cases fail (AttributeError: hermes_constants has no attribute '_PINNED_PROCESS_HERMES_HOME'; with a stub pin, the mirrored-env cases return the bare key / "not routed").
  2. scripts/run_tests.sh tests/agent/test_serves_routed_profile_pin.py -q → 10 passed.
  3. Neighbors: scripts/run_tests.sh tests/agent tests/test_hermes_constants.py tests/tools/test_mcp*.py tests/tools/test_registry*.py tests/tools/test_local*.py tests/hermes_cli/test_env_loader*.py tests/hermes_cli/test_terminal_bridge_profile_scope.py tests/hermes_cli/test_launch_profile*.py → part of the full-suite comparison below (no failure in these files differs from main).
  4. Full suite scripts/run_tests.sh tests/ -q on this branch vs main at the same base → main: 33222 passed, 40 failed; this branch: same failing set. Every failure is a load-sensitive timing/concurrency test (*_race, *_stall, *_drain, pinned-mtime watchers); the four files whose outcome differed between the two 32-worker runs fail identically on main when re-run in isolation. None touches profile-home resolution.
  5. Two-temp-home repro from [Bug]: a host that mirrors the served profile into HERMES_HOME defeats serves_routed_profile(): MCP connections fall back to bare cross-profile names #119242: base prints False 'atlassian' under the mirror; this branch prints True ('<served_key>', 'atlassian').
  6. End to end (Hermes WebUI + a stdio FastMCP server 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.

Checklist

Code

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — gateway/AGENTS.md, multi-profile-gateways.md, docstrings
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A (no config key)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — gateway/AGENTS.md § Profile scope
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — path comparison through the existing _expand_hermes_home / hermes_home_key (normcase); no OS-specific code
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

🤖 Generated with Claude Code

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint tool/mcp MCP client and OAuth area/profiles Multi-profile isolation, HERMES_HOME scoping sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels 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>
@tancou tancou changed the title fix(mcp): let embedding hosts pin the process home for routed-profile detection fix(profiles): pin the launch home so a mirrored HERMES_HOME cannot flip routed-profile decisions Sep 22, 2026
@tancou
tancou force-pushed the fix/pin-process-home-for-routed-profiles branch from 3aaa0e5 to bc53d76 Compare September 22, 2026 17:26
@tancou

tancou commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on current main and extended to the whole bug class: the three sibling launch-home checks a mirrored HERMES_HOME also fools (secret_scope._is_process_home, environments.local._is_routed_home, env_loader._process_hermes_home) now use the same routing home. Tests reduced to two parametrized invariants (red on main), tests/conftest.py resets the pin per test, docs row + gateway/AGENTS.md bullet added, contributor e-mail mapped for the attribution check. Tracking issue with a two-temp-home repro: #119242.

@teknium1

Copy link
Copy Markdown
Collaborator

Thanks @tancou — this is the right shape. The split resolver (get_routing_process_hermes_home() for the four routed-profile decisions only, get_process_hermes_home() still following the env) turned out to matter: an earlier attempt in #120103 folded the pin into get_process_hermes_home() and that let DELETE /api/profiles/<x> from a multi-profile dashboard resolve the host multiplexer's bare hermes-gateway unit.

Your commit is cherry-picked into #120103 with authorship intact (fix(profiles): pin the launch home so a mirrored HERMES_HOME cannot flip routed-profile decisions, test + docs + attribution file included). On top of it, #120103 has set_multiplex_active(True) auto-pin the launch home when no host pin exists, and release only that auto-pin on deactivation, so an embedding host's explicit pin_process_hermes_home(launch) survives transient toggles. This PR will be closed as salvaged when #120103 lands, with the merge SHA.

teknium1 added a commit that referenced this pull request Sep 23, 2026
…it under multiplex

Builds on tancou's #119129 (cherry-picked above): the pin now lives in
get_routing_process_hermes_home() and only the four routed-profile DECISIONS read it.
get_process_hermes_home()/get_hermes_home() keep following HERMES_HOME, so an env-only
home switch in a multiplexed process resolves as before.

- set_multiplex_active(True) pins the launch home only when no host pin exists, and
  set_multiplex_active(False) releases only the pin it created itself. A transient toggle
  (gateway_migrate._multiplex_read_mode, cron external-worker restore) no longer drops an
  embedding host's explicit pin_process_hermes_home(launch).
- profiles._cleanup_gateway_service binds set_hermes_home_override(profile_dir) beside the
  env write. Under the previous head, DELETE /api/profiles/<x> from a multi-profile dashboard
  resolved get_service_name() against the pinned launch home -> bare `hermes-gateway`, and
  disabled/stopped/unlinked the HOST multiplexer's unit. Same path serves rename_profile.

Tests (red on the previous head): explicit pin survives True->False; env readers follow the
env while pinned; two-home delete removes hermes-gateway-victim and leaves hermes-gateway.
teknium1 added a commit that referenced this pull request Sep 23, 2026
…it under multiplex

Builds on tancou's #119129 (cherry-picked above): the pin now lives in
get_routing_process_hermes_home() and only the four routed-profile DECISIONS read it.
get_process_hermes_home()/get_hermes_home() keep following HERMES_HOME, so an env-only
home switch in a multiplexed process resolves as before.

- set_multiplex_active(True) pins the launch home only when no host pin exists, and
  set_multiplex_active(False) releases only the pin it created itself. A transient toggle
  (gateway_migrate._multiplex_read_mode, cron external-worker restore) no longer drops an
  embedding host's explicit pin_process_hermes_home(launch).
- profiles._cleanup_gateway_service binds set_hermes_home_override(profile_dir) beside the
  env write. Under the previous head, DELETE /api/profiles/<x> from a multi-profile dashboard
  resolved get_service_name() against the pinned launch home -> bare `hermes-gateway`, and
  disabled/stopped/unlinked the HOST multiplexer's unit. Same path serves rename_profile.

Tests (red on the previous head): explicit pin survives True->False; env readers follow the
env while pinned; two-home delete removes hermes-gateway-victim and leaves hermes-gateway.
teknium1 added a commit that referenced this pull request Sep 23, 2026
…it under multiplex

Builds on tancou's #119129 (cherry-picked above): the pin now lives in
get_routing_process_hermes_home() and only the four routed-profile DECISIONS read it.
get_process_hermes_home()/get_hermes_home() keep following HERMES_HOME, so an env-only
home switch in a multiplexed process resolves as before.

- set_multiplex_active(True) pins the launch home only when no host pin exists, and
  set_multiplex_active(False) releases only the pin it created itself. A transient toggle
  (gateway_migrate._multiplex_read_mode, cron external-worker restore) no longer drops an
  embedding host's explicit pin_process_hermes_home(launch).
- profiles._cleanup_gateway_service binds set_hermes_home_override(profile_dir) beside the
  env write. Under the previous head, DELETE /api/profiles/<x> from a multi-profile dashboard
  resolved get_service_name() against the pinned launch home -> bare `hermes-gateway`, and
  disabled/stopped/unlinked the HOST multiplexer's unit. Same path serves rename_profile.

Tests (red on the previous head): explicit pin survives True->False; env readers follow the
env while pinned; two-home delete removes hermes-gateway-victim and leaves hermes-gateway.
teknium1 added a commit that referenced this pull request Sep 23, 2026
…it under multiplex

Builds on tancou's #119129 (cherry-picked above): the pin now lives in
get_routing_process_hermes_home() and only the four routed-profile DECISIONS read it.
get_process_hermes_home()/get_hermes_home() keep following HERMES_HOME, so an env-only
home switch in a multiplexed process resolves as before.

- set_multiplex_active(True) pins the launch home only when no host pin exists, and
  set_multiplex_active(False) releases only the pin it created itself. A transient toggle
  (gateway_migrate._multiplex_read_mode, cron external-worker restore) no longer drops an
  embedding host's explicit pin_process_hermes_home(launch).
- profiles._cleanup_gateway_service binds set_hermes_home_override(profile_dir) beside the
  env write. Under the previous head, DELETE /api/profiles/<x> from a multi-profile dashboard
  resolved get_service_name() against the pinned launch home -> bare `hermes-gateway`, and
  disabled/stopped/unlinked the HOST multiplexer's unit. Same path serves rename_profile.

Tests (red on the previous head): explicit pin survives True->False; env readers follow the
env while pinned; two-home delete removes hermes-gateway-victim and leaves hermes-gateway.
@teknium1

Copy link
Copy Markdown
Collaborator

Landed on main via #120103 (merge bd970b0588), cherry-picked with your authorship preserved — thank you @tancou. Closing this PR since its commits are now on main; the follow-up fixes and tests from review are in #120103's body.

@teknium1 teknium1 closed this Sep 23, 2026
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/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state 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]: a host that mirrors the served profile into HERMES_HOME defeats serves_routed_profile(): MCP connections fall back to bare cross-profile names

3 participants