fix(profiles): pin the launch home so a mirrored HERMES_HOME cannot flip routed-profile decisions - #119129
fix(profiles): pin the launch home so a mirrored HERMES_HOME cannot flip routed-profile decisions#119129tancou wants to merge 1 commit into
Conversation
…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>
3aaa0e5 to
bc53d76
Compare
|
Rebased on current |
|
Thanks @tancou — this is the right shape. The split resolver ( Your commit is cherry-picked into #120103 with authorship intact ( |
…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.
…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.
…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.
…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.
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_HOMEon 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 configuringatlassianwith different credentials share whichever connection came first; aREAD_ONLY_MODE=falseprofile 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'sterminal.*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()andhermes_cli.env_loader._process_hermes_home()all compare againstget_process_hermes_home(), which readsHERMES_HOMElive. 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, elseget_process_hermes_home(); the four checks compare against it. Unpinned, behaviour is byte-for-byte the old one (tested); hosts that never mutateHERMES_HOMEneed nothing.Why this shape, and not…
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 resolverhermes_cli/env_loader.pydocuments.activate_multi_profile_hosting()(tui_gateway/launch_profile_policy.py): that seam flipsget_secretfail-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._MULTIPLEX_ACTIVEis process-global).tests/conftest.pyresets it per test.The pin never widens secret authority: no
get_secretpath 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_homewhen present; works without this change for its own status/reload paths, needs it for chat-turn isolation).Type of Change
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 (coversstrip_launch_profile_env,served_profile_child_env, kanban dispatch).hermes_cli/env_loader.py:_process_hermes_home()likewise (covers theterminal.*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
tests/agent/test_serves_routed_profile_pin.pyonto currentmain→ 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").scripts/run_tests.sh tests/agent/test_serves_routed_profile_pin.py -q→ 10 passed.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 frommain).scripts/run_tests.sh tests/ -qon this branch vsmainat 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 onmainwhen re-run in isolation. None touches profile-home resolution.False 'atlassian'under the mirror; this branch printsTrue ('<served_key>', 'atlassian').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
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — viascripts/run_tests.sh tests/ -q; the failing set (load-sensitive timing tests) is identical on currentmainDocumentation & Housekeeping
docs/, docstrings) —gateway/AGENTS.md,multi-profile-gateways.md, docstringscli-config.yaml.exampleif I added/changed config keys — N/A (no config key)CONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows —gateway/AGENTS.md§ Profile scope_expand_hermes_home/hermes_home_key(normcase); no OS-specific code🤖 Generated with Claude Code