diff --git a/agent/command_token_source.py b/agent/command_token_source.py index b4511ab3ae..38ace24324 100644 --- a/agent/command_token_source.py +++ b/agent/command_token_source.py @@ -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( diff --git a/plugins/platforms/a2a/adapter.py b/plugins/platforms/a2a/adapter.py index 79fbe9fe91..585c2ad690 100644 --- a/plugins/platforms/a2a/adapter.py +++ b/plugins/platforms/a2a/adapter.py @@ -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", diff --git a/tests/tui_gateway/test_served_profile_child_env.py b/tests/tui_gateway/test_served_profile_child_env.py new file mode 100644 index 0000000000..fe0251fc28 --- /dev/null +++ b/tests/tui_gateway/test_served_profile_child_env.py @@ -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 diff --git a/tools/bot_mode_dm.py b/tools/bot_mode_dm.py index 7261c61d75..a765705663 100644 --- a/tools/bot_mode_dm.py +++ b/tools/bot_mode_dm.py @@ -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) diff --git a/tools/bot_relay.py b/tools/bot_relay.py index eb8f2fd2b7..8544e06400 100644 --- a/tools/bot_relay.py +++ b/tools/bot_relay.py @@ -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 `` 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) diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 104bc7694d..70502a696b 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -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 diff --git a/tools/environments/local.py b/tools/environments/local.py index 950571f357..5fccddd0bd 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -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; diff --git a/tui_gateway/methods_bot_relay.py b/tui_gateway/methods_bot_relay.py index 4925e5435f..a638654d65 100644 --- a/tui_gateway/methods_bot_relay.py +++ b/tui_gateway/methods_bot_relay.py @@ -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: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5a46c015c3..cacf2eae97 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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(