Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions agent/secret_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,14 @@ def serves_routed_profile() -> bool:
"""True when the current task runs for a profile other than the process's own: always under
multiplexing, else when a HERMES_HOME override names another home (dashboard/desktop backend,
per-profile cron ticker). The MCP registry scope and the check_fn cache key both follow this
predicate so a served profile's view never aliases the launch profile's (#111151)."""
predicate so a served profile's view never aliases the launch profile's (#111151). A host that
mirrors the turn's profile into ``HERMES_HOME`` pins its own home with
``hermes_constants.pin_process_hermes_home`` so the mirror cannot flip this predicate."""
if is_multiplex_active():
return True
from hermes_constants import get_hermes_home_override, get_process_hermes_home, hermes_home_key
from hermes_constants import get_hermes_home_override, get_routing_process_hermes_home, hermes_home_key
override = get_hermes_home_override()
return override is not None and hermes_home_key(override) != hermes_home_key(get_process_hermes_home())
return override is not None and hermes_home_key(override) != hermes_home_key(get_routing_process_hermes_home())


_SECRET_SCOPE: ContextVar[Optional[Mapping[str, str]]] = ContextVar("_SECRET_SCOPE", default=None)
Expand Down Expand Up @@ -338,8 +340,11 @@ def build_profile_secret_scope(hermes_home: Path) -> Dict[str, str]:


def _is_process_home(hermes_home: Path) -> bool:
from hermes_constants import get_process_hermes_home
"""Is *hermes_home* the profile this process serves as its own? Same launch-home identity as
``serves_routed_profile()``: a host that mirrors a served profile into ``HERMES_HOME`` would
otherwise seed the launch profile's bridged allow-all grant into that profile's scope."""
from hermes_constants import get_routing_process_hermes_home
try:
return Path(hermes_home).resolve() == get_process_hermes_home().resolve()
return Path(hermes_home).resolve() == get_routing_process_hermes_home().resolve()
except OSError:
return False
2 changes: 2 additions & 0 deletions contributors/emails/git.commits@tancou.me
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
tancou
# PR #119129 (pin process home for routed-profile detection)
8 changes: 8 additions & 0 deletions gateway/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,14 @@ gateway under the backend, and do NOT "fix" update locks by widening the tree-ki
the default profile only; a secondary enabling one is logged once with the remedy and stamped
into runtime status (`run_adapters.py::_note_unserved_secondary_platform`). `needs_attention` is
set and cleared at the single writer (`_update_platform_runtime_status`) on the connect path.
- **One launch-home identity.** "Does this task serve a routed profile?" compares the override
with `hermes_constants.get_routing_process_hermes_home()` (`agent/secret_scope.py::
serves_routed_profile` and `_is_process_home`, `tools/environments/local.py::_is_routed_home`,
`hermes_cli/env_loader.py::_process_hermes_home`), never with `os.environ["HERMES_HOME"]` read
live: an embedding host that mirrors the served profile into the env var per turn (Hermes
WebUI) pins its own home with `pin_process_hermes_home()`, and without a pin the resolver is
`get_process_hermes_home()` unchanged. Do not add another routing decision that compares
against `get_process_hermes_home()` directly; that resolver is for process-level assets.

## Tests

Expand Down
11 changes: 7 additions & 4 deletions hermes_cli/env_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -660,12 +660,15 @@ def _process_hermes_home() -> Path:
(mtime,size)-keyed config cache is safe to reuse; under an override it
must fall through to an isolated parse of the scoped profile.

``hermes_constants.get_process_hermes_home()`` is the override-immune
resolver built for exactly this; delegate to it.
``hermes_constants.get_routing_process_hermes_home()`` is the override-immune
resolver built for exactly this; delegate to it. It is also immune to a host
that mirrors the served profile into the live ``HERMES_HOME`` env var
(``pin_process_hermes_home``): without that, the mirrored profile satisfied
the guard above and bridged ITS ``terminal.*`` into the shared env.
"""
try:
from hermes_constants import get_process_hermes_home
from hermes_constants import get_routing_process_hermes_home

return get_process_hermes_home()
return get_routing_process_hermes_home()
except Exception:
return Path.home() / ".hermes"
29 changes: 29 additions & 0 deletions hermes_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,35 @@ def get_process_hermes_home() -> Path:
return _expand_hermes_home(val) if val else _get_platform_default_hermes_home()


# Host-pinned identity of the profile this process serves as its own (None: follow HERMES_HOME).
_PINNED_PROCESS_HERMES_HOME: str | None = None


def pin_process_hermes_home(path: str | Path | None) -> None:
"""Pin the home this process serves as its own profile, for "is this task routed?" decisions.

An embedding host that serves several profiles and mirrors the active turn's profile into
``os.environ["HERMES_HOME"]`` for legacy readers (Hermes WebUI) otherwise makes every turn's own
profile look like the launch profile: ``agent.secret_scope.serves_routed_profile()`` turns
False and that turn's MCP connections fall back to bare, cross-profile names; the sibling
launch-home checks (``secret_scope._is_process_home``, ``tools.environments.local._is_routed_home``,
``hermes_cli.env_loader._process_hermes_home``) misjudge the same way. ``None`` clears the pin.

Process-global on purpose: it names the process's own identity, not a per-task value. It is NOT
folded into :func:`get_process_hermes_home`: :func:`get_hermes_home` falls back to that for
tasks carrying no override, and the host's mirror exists precisely so those readers see the
served profile. Hosts that never mutate ``HERMES_HOME`` need not call this (no-op).
"""
global _PINNED_PROCESS_HERMES_HOME
_PINNED_PROCESS_HERMES_HOME = None if path is None else str(path)


def get_routing_process_hermes_home() -> Path:
"""Launch home for routed-profile decisions: the pinned home, else :func:`get_process_hermes_home`."""
pinned = _PINNED_PROCESS_HERMES_HOME
return _expand_hermes_home(pinned) if pinned else get_process_hermes_home()


# Hermes-managed runtime downloads at the root of a home (GGUF models, llama.cpp runtimes,
# managed Node): re-downloadable on demand and routinely tens to hundreds of GB. Shared by
# ``hermes backup`` (excludes them) and ``profile create --clone-all`` (skips them from the
Expand Down
82 changes: 82 additions & 0 deletions tests/agent/test_serves_routed_profile_pin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""A host that mirrors the served profile into HERMES_HOME must not flip launch-home identity.

Hermes WebUI serves several profiles from one process and, for legacy readers, mirrors the active
turn's profile into ``os.environ["HERMES_HOME"]`` while also installing the context-local override.
Every "is this task routed / is this the launch home" decision that compared the override with the
live env var then saw the served home as the launch home: MCP connections were keyed by bare name
(shared across profiles), the launch residue was never stripped from the served profile's child env,
the launch profile's bridged allow-all grant was seeded into the served profile's secret scope, and
the served profile's ``terminal.*`` config was bridged into the shared process env.
``hermes_constants.pin_process_hermes_home`` gives such hosts one stable anchor for all of them.
"""
from __future__ import annotations

from pathlib import Path

import pytest

import hermes_constants
from agent.secret_scope import _is_process_home, serves_routed_profile
from hermes_cli.env_loader import _process_hermes_home
from tools.environments.local import _is_routed_home
from tools.mcp_tool_scope import _server_key


def _under(home, fn):
"""Run *fn* with *home* installed as the task's Hermes-home override."""
token = hermes_constants.set_hermes_home_override(home)
try:
return fn()
finally:
hermes_constants.reset_hermes_home_override(token)


# name -> "does this decision treat *home* as a routed (non-launch) profile?"
ROUTED = {
"secret_scope.serves_routed_profile": lambda home: _under(home, serves_routed_profile),
"secret_scope._is_process_home": lambda home: not _is_process_home(home),
"environments.local._is_routed_home": lambda home: _is_routed_home(home),
"env_loader._process_hermes_home": lambda home: _process_hermes_home().resolve() != Path(home).resolve(),
"mcp_tool_scope._server_key": lambda home: _under(home, lambda: _server_key("atlassian")) != "atlassian",
}


@pytest.fixture
def homes(tmp_path, monkeypatch):
launch = tmp_path / "launch"
served = tmp_path / "profiles" / "served"
launch.mkdir()
served.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(launch))
monkeypatch.setattr(hermes_constants, "_PINNED_PROCESS_HERMES_HOME", None)
return launch, served


@pytest.mark.parametrize("decision", sorted(ROUTED))
def test_pinned_launch_home_survives_a_mirrored_hermes_home(homes, monkeypatch, decision):
launch, served = homes
routed = ROUTED[decision]
hermes_constants.pin_process_hermes_home(launch)
monkeypatch.setenv("HERMES_HOME", str(served)) # the host's per-turn mirror

assert routed(served) is True
assert routed(launch) is False
# The served profile's MCP connection is its own, keyed by its home, not a bare shared name.
assert _under(served, lambda: _server_key("atlassian")) == (hermes_constants.hermes_home_key(served), "atlassian")
# Process-asset readers keep following the env var: only routing decisions use the pin.
assert hermes_constants.get_process_hermes_home() == served
assert hermes_constants.get_routing_process_hermes_home() == launch


@pytest.mark.parametrize("decision", sorted(ROUTED))
def test_unpinned_or_cleared_pin_keeps_hermes_home_semantics(homes, monkeypatch, decision):
launch, served = homes
routed = ROUTED[decision]
for _ in ("never pinned", "pinned then cleared"):
monkeypatch.setenv("HERMES_HOME", str(launch))
assert routed(served) is True
assert routed(launch) is False
monkeypatch.setenv("HERMES_HOME", str(served)) # mirrored: the env var IS the launch home
assert routed(served) is False
hermes_constants.pin_process_hermes_home(launch)
hermes_constants.pin_process_hermes_home(None)
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,13 @@ def _hermetic_environment(tmp_path, monkeypatch):
(fake_hermes_home / "memories").mkdir()
(fake_hermes_home / "skills").mkdir()
monkeypatch.setenv("HERMES_HOME", str(fake_hermes_home))
# A test that pins the process home (hermes_constants.pin_process_hermes_home) must not
# leak that module-global into the next test's routed-profile decisions.
try:
import hermes_constants as _hc
monkeypatch.setattr(_hc, "_PINNED_PROCESS_HERMES_HOME", None, raising=False)
except Exception:
pass
# Per-TEST host-rendezvous dir (see the session-level block at the top): the
# host gateway/serve record is shared per OS user by design, so without this
# one test's published owner makes the next test's lifecycle code attach to it.
Expand Down
10 changes: 7 additions & 3 deletions tools/environments/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,10 +410,14 @@ def served_profile_child_env(


def _is_routed_home(target_home: "str | Path") -> bool:
"""True when ``target_home`` is not the process's own (launch) home."""
from hermes_constants import get_process_hermes_home
"""True when ``target_home`` is not the process's own (launch) home.

Same launch-home identity as ``agent.secret_scope.serves_routed_profile()``: under a host that
mirrors the served profile into ``HERMES_HOME``, the live env var names the served home and the
launch residue would never be stripped from that profile's child env."""
from hermes_constants import get_routing_process_hermes_home
try:
return Path(target_home).resolve() != get_process_hermes_home().resolve()
return Path(target_home).resolve() != get_routing_process_hermes_home().resolve()
except OSError:
return True

Expand Down
1 change: 1 addition & 0 deletions website/docs/user-guide/multi-profile-gateways.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,7 @@ profile and never shares with the default or any sibling:
| 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 |
| Every child that acts for a served profile (slash worker, Bot Chat delivery, A2A forward, `key_cmd` helper, browser driver) | That profile's own `.env` + secret sources over a credential-scrubbed base — with or without `gateway.multiplex_profiles` (the Desktop/dashboard `?profile=` route counts) | Absent from the child — a key that reached the launch process only through systemd / Compose / the shell is never inherited by another profile's child |
| Authorization gates in a child spawned for another profile (`*_ALLOWED_USERS` / `*_ALLOWED_CHANNELS` / `*_IGNORED_CHANNELS` / `*_ALLOW_ALL_USERS` / `*_ALLOW_BOTS`, `GATEWAY_ALLOW*`) — dashboard `hermes -p <name>` actions, kanban workers, Bot Chat delivery, the post-update per-profile `gateway restart` | The child's own `.env` / `config.yaml`, loaded by the child itself | Closed (the adapter's documented default) — a gate exported into the spawning process by a unit file or the shell is dropped before the child starts, so profile B never enforces profile A's channel or user list; a same-profile child keeps it |
| Routed-profile detection in an embedding host that mirrors the served profile into the live `HERMES_HOME` env var for legacy readers (Hermes WebUI) | The launch home the host pinned with `hermes_constants.pin_process_hermes_home()`; MCP connection keys, the launch-env strip for a served profile's children, the bridged allow-all seed and the `terminal.*` env-bridge guard all compare against it | Without a pin the live env var is the launch home, exactly as before — a host that never mutates `HERMES_HOME` needs nothing |
| The launch (default) profile's own credentials in a `hermes serve` / dashboard process that also serves another profile | Its `.env` + secret sources over the process env **frozen the moment the first other profile is served**; not re-read afterwards | A credential rotated only in the process env (`systemctl set-environment`, a refreshed `op run` wrapper that did not re-exec) is not picked up until the process restarts — put rotating keys in `.env` or a secret source, or restart after rotating |
| 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) | — |
Expand Down