fix(multiplex): children spawned for a served profile start from that profile's env

Under gateway.multiplex_profiles (and the Desktop/dashboard backend serving named
profiles) os.environ holds the LAUNCH profile's .env. Five spawn sites built a
child's env from it while acting for another profile, so the child saw the
launch profile's HERMES_HOME (bot_relay, key_cmd), its credentials, HERMES_MODEL
and TERMINAL_* policy, and none of the served profile's own .env:

- tui_gateway/server.py _SlashWorker: pinned HERMES_HOME but kept the launch
  base with tier-2 credentials + settings.
- tools/bot_relay.py delivery_env (relay RPC + --run-delivery): dict(os.environ).
- tools/browser_tool.py _build_browser_env: re-added BROWSERBASE/FIRECRAWL/
  BROWSER_USE keys from os.environ after the scrub.
- plugins/platforms/a2a/adapter.py _forward_to_profile: {**os.environ}.
- agent/command_token_source.py _mint: key_cmd helper inherited os.environ.

tools.environments.local.served_profile_child_env is the one builder: pin the
target home, drop the launch profile's .env residue and bridged TERMINAL_*
(strip_launch_profile_env), and for children that legitimately run with the
profile's credentials (agent worker, token helper) overlay the target profile's
own secrets - what a standalone `hermes -p X` loads itself, never a sibling's.
The browser keeps the provider scrub and re-adds only its passthrough keys via
get_secret. Outside multiplex the env is unchanged.

Live proof from inside the child (launch A, served B, multiplex on): all five
children print HERMES_HOME == B, see B_MARKER=b from B's .env and do not see
A_MARKER; the browser child gets B's FIRECRAWL_API_KEY. On base every one leaked
A_MARKER and lacked B_MARKER; bot_relay and key_cmd also had A's HERMES_HOME.
This commit is contained in:
teknium1
2026-09-14 23:31:07 -07:00
committed by Teknium
parent b66e7f2149
commit d1794d5539
9 changed files with 172 additions and 24 deletions
+6 -1
View File
@@ -44,10 +44,15 @@ def materialize_probe_api_key(api_key: object) -> str:
def _mint(command: str, label: str) -> tuple[str, Optional[float]]:
"""Run *command*, returning ``(token, ttl_seconds_or_None)``."""
"""Run *command*, returning ``(token, ttl_seconds_or_None)``. The helper runs FOR the profile whose
provider is being minted: it gets that profile's own env (secrets + HERMES_HOME), never the multiplexer's
launch environ — an ``op read`` / ``vault kv get`` helper must sign in as the served profile."""
from tools.environments.local import served_profile_child_env
try:
completed = subprocess.run(
command, shell=True, capture_output=True, text=True, timeout=_MINT_TIMEOUT_SECONDS,
env=served_profile_child_env(inherit_credentials=True),
)
except subprocess.TimeoutExpired as exc:
raise CommandTokenError(
+5 -3
View File
@@ -585,9 +585,11 @@ class A2AAdapter(BasePlatformAdapter):
profile, "SELECT id FROM sessions WHERE title = ? ORDER BY started_at DESC LIMIT 1",
(session_title,), "A2A: could not lookup forwarded session")
cmd = ["hermes", "chat", "-q", framed_text, "-Q", "--source", "a2a"] + (["--resume", session_id] if session_id else [])
env = {**os.environ, "HERMES_A2A_PEER": peer}
if home := _profile_home(profile):
env["HERMES_HOME"] = home
# The child IS the target profile's turn: build its env for that home (launch .env /
# TERMINAL_* residue dropped, the target's own secrets overlaid), not the gateway's raw environ.
from tools.environments.local import served_profile_child_env
env = served_profile_child_env(target_home=_profile_home(profile), inherit_credentials=True)
env["HERMES_A2A_PEER"] = peer
start = time.time()
try:
proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace",
@@ -0,0 +1,102 @@
"""A child spawned FOR a served profile carries that profile's env, never the launch profile's.
Under ``gateway.multiplex_profiles`` one process serves several profiles and ``os.environ`` holds the
LAUNCH profile's ``.env``. Every ``hermes -p X`` / helper child (slash worker, relay delivery, A2A
forward, ``key_cmd`` helper, browser driver) must start from X's env: X's home pinned, X's own
secrets, and none of the launch profile's residue. The same build reaches every site through
``served_profile_child_env``; the slash worker is spawned through its production class here.
"""
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
from agent.secret_scope import set_multiplex_active
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
_PROBE = ("import json,os;print(json.dumps({k:os.environ.get(k) for k in "
"('HERMES_HOME','A_MARKER','B_MARKER','TERMINAL_ENV','HERMES_MODEL','FIRECRAWL_API_KEY')}))")
@pytest.fixture
def mux_homes(tmp_path, monkeypatch):
"""Launch home A (its .env mirrored into os.environ, as the multiplexer loads it) and served home B."""
a = tmp_path / ".hermes"
b = a / "profiles" / "b"
b.mkdir(parents=True)
(a / ".env").write_text("A_MARKER=a\nHERMES_MODEL=a-model\nTERMINAL_ENV=docker\nFIRECRAWL_API_KEY=a-fc\n", encoding="utf-8")
(b / ".env").write_text("B_MARKER=b\nFIRECRAWL_API_KEY=b-fc\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(a))
for key, val in (("A_MARKER", "a"), ("HERMES_MODEL", "a-model"), ("TERMINAL_ENV", "docker"),
("FIRECRAWL_API_KEY", "a-fc")):
monkeypatch.setenv(key, val)
monkeypatch.delenv("B_MARKER", raising=False)
set_multiplex_active(True)
try:
yield a, b
finally:
set_multiplex_active(False)
def _child_view(env: dict) -> dict:
out = subprocess.run([sys.executable, "-c", _PROBE], env=env, capture_output=True, text=True, encoding="utf-8", timeout=60)
return json.loads(out.stdout.strip().splitlines()[-1])
def _assert_is_b_env(seen: dict, b: Path, *, with_secrets: bool):
assert seen["HERMES_HOME"] == str(b)
assert seen["A_MARKER"] is None and seen["TERMINAL_ENV"] is None and seen["HERMES_MODEL"] is None
assert seen["B_MARKER"] == ("b" if with_secrets else None)
def test_slash_worker_child_runs_in_the_served_profiles_env(mux_homes, monkeypatch):
"""The real ``_SlashWorker`` spawn, observed from INSIDE the child: B's home and secrets, no A residue."""
import tui_gateway.server as server
a, b = mux_homes
captured = {}
class _Popen:
def __init__(self, argv, **kw):
captured["env"] = kw["env"]
self.stdout = self.stderr = iter(())
self.stdin = None
def poll(self):
return 0
with monkeypatch.context() as m: # restored before the probe child spawns through the real Popen
m.setattr(server.subprocess, "Popen", _Popen)
token = set_hermes_home_override(str(b))
try:
server._SlashWorker("sess", "", profile_home=str(b))
finally:
reset_hermes_home_override(token)
served_env = captured["env"]
# Outside multiplex the launch profile's own worker keeps its env untouched.
set_multiplex_active(False)
server._SlashWorker("sess", "", profile_home=None)
assert captured["env"]["A_MARKER"] == "a" and captured["env"]["HERMES_HOME"] == str(a)
_assert_is_b_env(_child_view(served_env), b, with_secrets=True)
def test_helper_children_resolve_secrets_through_the_served_profile(mux_homes):
"""``key_cmd`` helpers and the browser driver spawned during B's turn see B's key, never A's."""
from agent.command_token_source import _mint
from gateway.run import _profile_runtime_scope
from tools.browser_tool import _build_browser_env
a, b = mux_homes
helper = (f"{sys.executable} -c \"import os;print(os.environ.get('B_MARKER','-')+'|'"
f"+os.environ.get('A_MARKER','-')+'|'+os.environ.get('HERMES_HOME',''))\"")
with _profile_runtime_scope(b, hydrate_secrets=False):
token, _ttl = _mint(helper, "b-provider")
browser_env = _build_browser_env()
assert token == f"b|-|{b}"
seen = _child_view(browser_env)
_assert_is_b_env(seen, b, with_secrets=False) # provider tier stays scrubbed for the browser
assert seen["FIRECRAWL_API_KEY"] == "b-fc" # the passthrough key is B's, not the launch profile's
+1 -1
View File
@@ -504,7 +504,7 @@ def _run_delivery(argv: list[str], dm_file: str, *, stdin_file: bool,
try:
from tools.bot_relay import delivery_env
env = delivery_env(author)
env = delivery_env(author, profile_home if not stdin_file else None)
with _delivery_lock(argv, stdin_file=stdin_file):
if not stdin_file:
return _run_local_turn(argv, dm_file, env=env)
+11 -6
View File
@@ -415,15 +415,20 @@ def _delivery_child_session_env_names() -> "tuple[str, ...]":
return tuple(_VAR_MAP)
def delivery_env(author: Optional[dict]) -> dict[str, str]:
"""Environment for one delivery turn's ``hermes`` child. The dispatcher's own HERMES_TURN_AUTHOR is
dropped first so a delivery without an author never inherits the author of the turn that sent it.
Dispatcher session identity (the canonical ``gateway.session_context`` session env names) is
def delivery_env(author: Optional[dict], profile_home: "str | Path | None" = None) -> dict[str, str]:
"""Environment for one delivery turn's ``hermes -p <profile>`` child. The dispatcher's own
HERMES_TURN_AUTHOR is dropped first so a delivery without an author never inherits the author of the turn
that sent it. Dispatcher session identity (the canonical ``gateway.session_context`` session env names) is
dropped too: a nested recipient that ``message_agent``s onward must not stamp that grandchild
notify with the grandparent's key, or the live recipient never resumes."""
notify with the grandparent's key, or the live recipient never resumes. The child runs the target
profile's Bot Chat turn, so it starts from THAT profile's env (``served_profile_child_env``: launch
profile ``.env`` / TERMINAL_* residue dropped, target secrets overlaid), never the multiplexer's raw
``os.environ``; ``-p`` alone only pinned HERMES_HOME. ``profile_home`` is the target's home when the
caller knows it (relay RPC, roster); otherwise the active override."""
from agent.turn_author import TURN_AUTHOR_ENV, turn_author_env
from tools.environments.local import served_profile_child_env
env = dict(os.environ)
env = served_profile_child_env(base=os.environ, target_home=profile_home, inherit_credentials=True)
env.pop(TURN_AUTHOR_ENV, None)
for name in _delivery_child_session_env_names():
env.pop(name, None)
+13 -4
View File
@@ -37,11 +37,20 @@ _BROWSER_PASSTHROUGH_KEYS: tuple[str, ...] = (
def _build_browser_env() -> dict:
"""Credential-scrubbed env for an agent-browser subprocess (deferred import: test
harnesses stub the ``tools`` package)."""
from tools.environments.local import hermes_subprocess_env
harnesses stub the ``tools`` package). The passthrough keys are re-added from the active
profile's secret scope, never ``os.environ``: under multiplex that holds the LAUNCH profile's
Browserbase/Firecrawl keys, and a served profile's browser must run on its own (or none)."""
from agent.secret_scope import UnscopedSecretError, get_secret
from tools.environments.local import served_profile_child_env
env = hermes_subprocess_env(inherit_credentials=False)
env.update({k: os.environ[k] for k in _BROWSER_PASSTHROUGH_KEYS if k in os.environ})
env = served_profile_child_env(inherit_credentials=False)
for key in _BROWSER_PASSTHROUGH_KEYS:
try:
value = get_secret(key)
except UnscopedSecretError:
value = None # multiplex, no scope bound: no key rather than a sibling profile's
if value is not None:
env[key] = value
return env
+27
View File
@@ -337,6 +337,33 @@ def build_subprocess_env(
return delegated_child_subprocess_env(env)
def served_profile_child_env(
base: "Mapping[str, str] | None" = None, *, target_home: "str | Path | None" = None,
inherit_credentials: bool = False,
) -> dict[str, str]:
"""Child env for a process that acts FOR the active (possibly served) profile: ``hermes -p X``
workers, ``key_cmd`` helpers, browser drivers. The process env is the LAUNCH profile's, so its
``.env`` residue and bridged ``TERMINAL_*`` are dropped (``strip_launch_profile_env``; no-op
outside multiplex) and the target home is pinned. ``inherit_credentials=True`` is for children
that legitimately run with the profile's credentials (they run the agent or mint its token): the
target profile's own secrets (its ``.env`` + hydrated sources, i.e. what a standalone
``hermes -p X`` loads itself) are overlaid — never a sibling profile's. ``False`` keeps the
provider scrub; the caller re-adds the few keys the child needs via ``get_secret``.
``target_home`` defaults to the active override; ``base`` replaces the ``hermes_subprocess_env``
snapshot."""
from agent.secret_scope import build_profile_secret_scope, current_secret_scope
from hermes_constants import get_hermes_home_override
env = dict(base) if base is not None else hermes_subprocess_env(inherit_credentials=inherit_credentials)
target = str(target_home or get_hermes_home_override() or "")
if target:
env["HERMES_HOME"] = target
strip_launch_profile_env(env, target)
if inherit_credentials:
secrets = build_profile_secret_scope(Path(target)) if target else (current_secret_scope() or {})
env.update((k, v) for k, v in secrets.items() if v is not None)
return env
def strip_launch_profile_env(env: dict, target_home: "str | Path | None" = None) -> dict:
"""Drop the LAUNCH profile's residue from a child env built for another served profile.
``os.environ`` holds the default profile's ``.env`` and its bridged ``TERMINAL_*`` settings;
+1 -1
View File
@@ -116,7 +116,7 @@ def _(rid, params: dict, _root=_relay_root, _run=_run_delivery) -> dict:
def _detail(p) -> str:
return (p.stderr or p.stdout or "").strip()[-500:]
turn_env = delivery_env(author)
turn_env = delivery_env(author, live_home)
fd, tmp = tempfile.mkstemp(prefix="hermes-relay-dm-", suffix=".txt", text=True)
try:
+6 -8
View File
@@ -233,16 +233,14 @@ class _SlashWorker:
# slash_worker runs the Hermes agent → needs provider credentials. Tier-1 secrets
# (gateway/GitHub/infra) are still stripped (#29157). Global-remote / multi-profile sessions: the
# worker must resolve config/skills/state against the session's profile home, not the gateway's
# launch HERMES_HOME (#40677). The override goes through the build_subprocess_env factory's `extra`
# (applied last, always wins) instead of a hand-rolled env["HERMES_HOME"] assignment.
from tools.environments.local import build_subprocess_env
# launch HERMES_HOME (#40677).
from tools.environments.local import served_profile_child_env
# The worker runs the agent → needs provider credentials; tier-1 secrets (gateway/GitHub/
# infra) are still stripped. Multi-profile sessions resolve against the session's profile
# home via `extra` (applied last, always wins); the base already carries the HOME contract.
env = _prepend_tool_paths(build_subprocess_env(
hermes_subprocess_env(inherit_credentials=True), scrub_secrets=False,
inherit_profile_home=False, extra={"HERMES_HOME": str(profile_home)} if profile_home else None))
# infra) are still stripped. A served profile's worker gets THAT profile's home + secrets and
# none of the launch profile's .env / TERMINAL_* residue, exactly what a standalone
# `hermes -p X` would load itself.
env = _prepend_tool_paths(served_profile_child_env(target_home=profile_home, inherit_credentials=True))
# Internal slash workers must import the same checkout as their parent.
module_root = str(Path(__file__).resolve().parent.parent)
env["PYTHONPATH"] = os.pathsep.join(