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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user