From fdd5995ecbe33e78f2f504b47cc7d05de6107658 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:44:10 -0700 Subject: [PATCH] fix(tui_gateway): serve fails closed when hosting a second profile home; profile RPCs bind the full runtime scope `hermes serve` / the Desktop backend hosted many profile homes (session profile_home, the `profile` RPC param, hosted rooms) but never called agent.secret_scope.set_multiplex_active, so every unscoped get_secret read for a secondary silently returned the LAUNCH profile's os.environ value, and @_profile_scoped bound only HERMES_HOME: `config.get full` for profile B expanded B's `${VAR}` refs to the default profile's plaintext credentials, model.options listed the default's env-keyed providers, llm.oneshot billed the default's auxiliary key. - tui_gateway/launch_profile_policy.py (was launch_terminal_policy.py): the first time _profile_home registers a non-launch home the process freezes the launch env and flips get_secret to fail closed (activate_multi_profile_hosting); launch_secret_scope composes the launch profile's .env + external sources over that frozen env so systemd / op-run injection survives the flip while a secondary never sees it. - model_switch._profile_runtime_scope_tokens is the ONE composer for home + secret + terminal scope: a named profile binds its own files; the launch profile binds its frozen-env scope once multiplexing is active and stays unscoped in a single-profile process (legacy os.environ precedence). _profile_scoped, _profile_scoped_rpc, _session_profile_runtime_scope, _bind_build_profile_scopes and _prepare_turn_input all go through it. - Hosted-room / Group Chat turns for a DEFAULT-profile member in a `multiplex_profiles: true` gateway no longer die at agent build with UnscopedSecretError: `profile_home is None` was treated as "no scope" in _start_agent_build._build and _prepare_turn_input. - llm.oneshot runs under the session's (or params.profile's) scope; _lap_builtin_rows / _overlay_has_creds / _provider_has_credentials read provider keys through _scoped_key_env instead of raw os.environ; methods_groups._profile_execution_policy resolves the hosted-room policy (which reads provider credentials) under the profile's full scope. Live repro (real `hermes serve`, two homes, config.get {key: full, profile: b}): base a_ref: b_ref: ${B_ONLY_TOKEN} env_ref: head a_ref: ${A_ONLY_TOKEN} b_ref: env_ref: ${ENV_INJECTED_TOKEN} Control (one home, --single): launch config still resolves env_ref from os.environ. --- hermes_cli/model_switch_providers.py | 9 ++-- hermes_cli/models.py | 3 +- tools/terminal_scope.py | 2 +- tui_gateway/agent_callbacks.py | 2 +- tui_gateway/launch_profile_policy.py | 66 ++++++++++++++++++++++++ tui_gateway/launch_terminal_policy.py | 42 --------------- tui_gateway/methods_groups.py | 16 +++--- tui_gateway/methods_session.py | 17 +++--- tui_gateway/methods_tools.py | 20 ++++---- tui_gateway/model_switch.py | 69 ++++++++++++++++++++----- tui_gateway/prompt_turn.py | 26 +++------- tui_gateway/server.py | 74 +++++++++++++-------------- 12 files changed, 202 insertions(+), 144 deletions(-) create mode 100644 tui_gateway/launch_profile_policy.py delete mode 100644 tui_gateway/launch_terminal_policy.py diff --git a/hermes_cli/model_switch_providers.py b/hermes_cli/model_switch_providers.py index f8d5ee641d..fb6115cccb 100644 --- a/hermes_cli/model_switch_providers.py +++ b/hermes_cli/model_switch_providers.py @@ -739,10 +739,12 @@ class _PickerBuild: def _lap_builtin_rows(b: _PickerBuild, data: dict, user_providers: dict) -> None: """Section 1: models.dev-mapped providers with api_key auth.""" - from hermes_cli.model_switch import _declared_model_ids + from hermes_cli.model_switch import _declared_model_ids, _scoped_key_env from agent.models_dev import get_provider_info for hermes_id, mdev_id, pconfig, env_vars in _iter_builtin_candidates(data, b.excluded, b.seen_slugs): - if not (_any_env(env_vars) or _raw_pool_usable(hermes_id)): + # Per-profile scope, never raw os.environ: a secondary profile's picker otherwise listed the + # LAUNCH profile's env-keyed providers and hid its own .env-keyed ones. + if not (_any_env(env_vars, _scoped_key_env) or _raw_pool_usable(hermes_id)): continue model_ids = _live_or_curated_ids(hermes_id, b.curated) # A providers..models block extends the discovered catalog; section 3 cannot @@ -764,7 +766,8 @@ def _overlay_has_creds(b: _PickerBuild, pid: str, hermes_slug: str, overlay) -> if overlay.auth_type == "aws_sdk": has_creds = _has_aws_sdk_creds_for_listing(hermes_slug, b.current_provider) else: - has_creds = _overlay_has_env_creds(pid, hermes_slug, overlay, os.environ.get) + from hermes_cli.model_switch import _scoped_key_env + has_creds = _overlay_has_env_creds(pid, hermes_slug, overlay, _scoped_key_env) # External-process providers (copilot-acp) hold no key/token/pool entry by design — the # spawned ACP subprocess brings its own auth. "Configured" means the executable resolves. # "Configured" means the executable resolves, which is exactly what get_auth_status() reports for them; diff --git a/hermes_cli/models.py b/hermes_cli/models.py index b975b1dee7..d00b63c74f 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -705,7 +705,8 @@ def _provider_has_credentials(pid: str) -> bool: if pid == "custom": return bool((_get_custom_base_url() or "").strip()) if pid == "openrouter": - return has_usable_secret(os.getenv("OPENROUTER_API_KEY", "")) + from hermes_cli.model_switch import _scoped_key_env + return has_usable_secret(_scoped_key_env("OPENROUTER_API_KEY")) status = get_auth_status(pid) return bool(status.get("logged_in") or status.get("configured")) except Exception: diff --git a/tools/terminal_scope.py b/tools/terminal_scope.py index 56988ad2a6..1883ffb123 100644 --- a/tools/terminal_scope.py +++ b/tools/terminal_scope.py @@ -96,7 +96,7 @@ def build_profile_terminal_scope( file is unreadable. *env_overlay* is a TRUSTED ``TERMINAL_*`` mapping captured from the launch process before - multiplexing began (``tui_gateway/launch_terminal_policy.py``): the launch profile's + multiplexing began (``tui_gateway/launch_profile_policy.py``): the launch profile's env-only policy (``TERMINAL_ENV=ssh`` from systemd, ``op run``, a launcher bridge) has no file to rebuild it from, and reading live ``os.environ`` here is the leak this module closes. It sits where the process env sits in the standalone bridge — explicit YAML keys diff --git a/tui_gateway/agent_callbacks.py b/tui_gateway/agent_callbacks.py index b78d9b134c..c30dc36226 100644 --- a/tui_gateway/agent_callbacks.py +++ b/tui_gateway/agent_callbacks.py @@ -414,7 +414,7 @@ def _rebuild_session_agent(sid: str, session: dict, **kwargs): # No live agent to inherit from (rebuild before the deferred build ran): open the profile's store the # same FAIL-CLOSED way _start_agent_build does rather than letting _make_agent reach for the launch db. opened = session_db is None and bool(profile_home) - scopes = _bind_build_profile_scopes(profile_home) if profile_home else None + scopes = _bind_build_profile_scopes(profile_home) try: # Resolve fallible config before allocating a replacement or moving its handle. config_model_seen = _config_model_target() diff --git a/tui_gateway/launch_profile_policy.py b/tui_gateway/launch_profile_policy.py new file mode 100644 index 0000000000..c5c7aa3ef3 --- /dev/null +++ b/tui_gateway/launch_profile_policy.py @@ -0,0 +1,66 @@ +"""Launch-profile policy for a process that hosts several profile homes (``hermes serve`` / +``hermes dashboard`` pooling, ``?profile=``, hosted rooms; the multiplexed gateway's own worker). + +Two facts anchor this module: + +* ``agent.secret_scope.get_secret`` fails closed ONLY while ``set_multiplex_active(True)`` holds. + A ``serve`` backend that hosts a second profile home never flipped it, so every unscoped read + for a secondary profile silently returned the LAUNCH profile's ``os.environ`` value. The flip + happens here, at the moment the process first learns it hosts another profile home. +* Once multiplexing is active the launch profile is a profile too: its turns/RPCs must run under + their own scope instead of ambient ``os.environ`` (a secondary context may have poisoned it, + #107422). A scope rebuilt from ``/.env`` + ``config.yaml`` alone would drop the + launch process's legitimate env-only policy — ``TERMINAL_ENV=ssh`` or a provider key injected + by systemd / ``op run`` has no file to rebuild it from. The process env is trusted exactly + once: frozen at activation, before any secondary code has run, never re-read afterwards. +""" + +from __future__ import annotations + +import os +import threading +from pathlib import Path +from typing import Dict, Optional + +_lock = threading.Lock() +_snapshot: Optional[Dict[str, str]] = None + + +def capture_launch_env() -> Dict[str, str]: + """Freeze the process env as the launch profile's own; the first capture wins. + + Called at activation, immediately before the first secondary home is registered as + served — the last moment ambient env is provably the launch profile's. + """ + global _snapshot + with _lock: + if _snapshot is None: + _snapshot = dict(os.environ) + return dict(_snapshot) + + +def activate_multi_profile_hosting() -> None: + """This process now hosts a profile home other than its launch home: freeze the launch env + and make unscoped credential reads fail closed (``get_secret`` raises instead of borrowing).""" + from agent.secret_scope import set_multiplex_active + capture_launch_env() + set_multiplex_active(True) + + +def launch_terminal_env() -> Dict[str, str]: + """The frozen launch ``TERMINAL_*`` overlay for a launch-profile turn's terminal scope. + + Production always captured at activation; a first capture here only happens when the + multiplexer flag was set by another owner (the messaging gateway) or a harness. + """ + return {k: v for k, v in capture_launch_env().items() if k.startswith("TERMINAL_")} + + +def launch_secret_scope(launch_home: "str | Path") -> Dict[str, str]: + """The launch profile's secret mapping: its ``.env`` + external sources over the frozen + launch env (systemd / ``op run`` injection survives the fail-closed flip; a secondary never + sees it because its scope is built from its own files only).""" + from agent.secret_scope import _is_global_env, build_profile_secret_scope + scope = {k: v for k, v in capture_launch_env().items() if not _is_global_env(k)} + scope.update(build_profile_secret_scope(Path(launch_home))) + return scope diff --git a/tui_gateway/launch_terminal_policy.py b/tui_gateway/launch_terminal_policy.py deleted file mode 100644 index 8025388e57..0000000000 --- a/tui_gateway/launch_terminal_policy.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Launch-profile ``TERMINAL_*`` snapshot for multiplexed TUI-gateway turns. - -Once this backend serves a secondary profile, launch-profile turns bind a terminal scope instead -of reading ambient ``os.environ`` (a secondary context must never become the launch turn's -authority; #107422). A scope rebuilt from ``/.env`` + ``config.yaml`` alone drops -the launch process's legitimate env-only policy — ``TERMINAL_ENV=ssh TERMINAL_SSH_HOST=...`` -injected by systemd / ``op run`` / a launcher bridge has no file to rebuild it from and silently -became ``backend=local``. The env is trusted exactly once: frozen at multiplex activation, before -any secondary code has run in this process, and never re-read from ambient state afterwards. -""" - -from __future__ import annotations - -import os -import threading -from typing import Dict, Optional - -_lock = threading.Lock() -_snapshot: Optional[Dict[str, str]] = None - - -def capture_launch_terminal_env() -> Dict[str, str]: - """Freeze the process's ``TERMINAL_*`` env; the first capture wins, later calls are no-ops. - - Called by ``server._profile_home`` immediately before the first secondary home is registered - as served — the last moment ambient env is provably the launch profile's own. - """ - global _snapshot - with _lock: - if _snapshot is None: - _snapshot = {k: v for k, v in os.environ.items() if k.startswith("TERMINAL_")} - return dict(_snapshot) - - -def launch_terminal_env() -> Dict[str, str]: - """The frozen launch ``TERMINAL_*`` overlay for a launch-profile turn's terminal scope. - - Production always captured at activation (``_profile_home`` is the only writer of - ``_served_profile_homes``); a first capture here only happens when a harness populated the - served set directly. - """ - return capture_launch_terminal_env() diff --git a/tui_gateway/methods_groups.py b/tui_gateway/methods_groups.py index 3344e5cfb3..5778910f21 100644 --- a/tui_gateway/methods_groups.py +++ b/tui_gateway/methods_groups.py @@ -126,17 +126,15 @@ def _api_server_key(profile: str | None = None) -> str: def _profile_execution_policy(profile: str) -> dict: - """Resolve execution policy under the exact multiplexed profile home.""" + """Resolve execution policy under the exact multiplexed profile's FULL runtime scope: the policy + reads provider credentials (``_xai_credentials_present`` -> ``get_env_value``), which under a + home-only override resolved from the launch process env (or raised once hosting fails closed).""" from gateway.hosted_room_execution_policy import execution_policy_mapping - from hermes_constants import reset_hermes_home_override, set_hermes_home_override - token = None - if _bound_server is not None and profile not in {_current_profile(), _profile_name()}: - token = set_hermes_home_override(str(_foreign_profile_home(profile))) - try: + if _bound_server is None: + return execution_policy_mapping(target_profile=profile) + home = None if profile in {_current_profile(), _profile_name()} else _foreign_profile_home(profile) + with _bound_server._session_profile_runtime_scope({"profile_home": str(home) if home else None}): return execution_policy_mapping(target_profile=profile) - finally: - if token is not None: - reset_hermes_home_override(token) def _room_link_run_storage_durable() -> bool: diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 78eef412f0..cf4e17bbb0 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -1079,8 +1079,12 @@ def _(rid, params: dict, session: dict) -> dict: @method("llm.oneshot") +@_profile_scoped def _(rid, params: dict) -> dict: - """Stateless one-shot LLM request; a live ``session_id`` lends its model, else the ``task`` backend.""" + """Stateless one-shot LLM request; a live ``session_id`` lends its model, else the ``task`` backend. + Runs under the session's profile scope (else ``params.profile`` / the launch scope): the aux + task config and its API key otherwise resolved from the LAUNCH profile — a secondary's titles / + project ideas ran on, and billed, the default profile's auxiliary provider.""" template = (params.get("template") or "").strip() or None instructions = params.get("instructions") or "" user_input = params.get("input") or "" @@ -1094,11 +1098,12 @@ def _(rid, params: dict) -> dict: session = _sessions.get(params.get("session_id") or "") try: from agent.oneshot import run_oneshot - return _ok(rid, {"text": run_oneshot( - instructions=instructions, user_input=user_input, template=template, variables=variables, - task=(params.get("task") or "title_generation").strip() or "title_generation", - max_tokens=_int_param(params, "max_tokens", 1024) or 1024, temperature=temperature, - main_runtime=_main_runtime_from_agent(session.get("agent")) if session else None)}) + with (_session_profile_runtime_scope(session) if session else contextlib.nullcontext()): + return _ok(rid, {"text": run_oneshot( + instructions=instructions, user_input=user_input, template=template, variables=variables, + task=(params.get("task") or "title_generation").strip() or "title_generation", + max_tokens=_int_param(params, "max_tokens", 1024) or 1024, temperature=temperature, + main_runtime=_main_runtime_from_agent(session.get("agent")) if session else None)}) except (KeyError, ValueError) as e: return _err(rid, 4031 if isinstance(e, KeyError) else 4032, str(e)) except Exception as e: diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 80776f3a49..70dbbce39e 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -48,16 +48,17 @@ def _profile_scoped_rpc( return err args = (rid, params, session) scope = contextlib.nullcontext() - if profile := _str_arg(params, "profile") if scoped else "": + if scoped: + # _profile_home is the ONE resolver: it registers the served home (flipping this + # process to fail-closed multi-profile hosting) and answers None for the launch + # profile, which then binds its own scope once multiplexing is active. + profile = _str_arg(params, "profile") try: try: - profile_dir = _tools_mod("hermes_cli.profiles").get_profile_dir(profile) - except ValueError: # traversal-shaped name: same answer as a missing dir - profile_dir = None - if not profile_dir or not profile_dir.is_dir(): + home = _profile_home(profile) + except ProfileUnavailableError: return _err(rid, 4064, f"profile '{profile}' not found") - _tools_mod("hermes_cli.env_loader").hydrate_profile_secret_sources(profile_dir) - scope = _session_profile_runtime_scope({"profile_home": str(profile_dir)}) + scope = _session_profile_runtime_scope({"profile_home": str(home) if home else None}) except Exception as e: if not catch_resolve: raise @@ -1035,12 +1036,11 @@ def _(rid, params: dict) -> dict: return err # The client sends session_id, not profile; the live session is authoritative. home = (session or {}).get("profile_home") - scopes = _bind_build_profile_scopes(home) if home else None + scopes = _bind_build_profile_scopes(home) try: return _configure_session_tools(rid, params, sid, session) finally: - if scopes is not None: - _release_build_profile_scopes(scopes) + _release_build_profile_scopes(scopes) def _configure_session_tools(rid, params: dict, sid: str, session) -> dict: diff --git a/tui_gateway/model_switch.py b/tui_gateway/model_switch.py index a45804cc9d..dd4536aff7 100644 --- a/tui_gateway/model_switch.py +++ b/tui_gateway/model_switch.py @@ -51,25 +51,66 @@ def _restore_agent_model_runtime(agent, snapshot: dict | None) -> None: agent.reasoning_config = snapshot["reasoning_config"] -@contextlib.contextmanager -def _session_profile_runtime_scope(session: dict): - """Bind model resolution to the session's profile config and secrets.""" - profile_home = session.get("profile_home") - if not profile_home: - yield - return - home_token = set_hermes_home_override(profile_home) - secret_token = set_secret_scope(build_profile_secret_scope(Path(profile_home))) +def _launch_profile_scope_needed() -> bool: + """A launch-profile body must run scoped once this process multiplexes (``get_secret`` fails + closed and ambient ``os.environ`` may carry a secondary's residue); a single-profile process + stays unscoped so systemd / ``op run`` credential injection keeps its ``os.environ`` fallthrough.""" + from agent.secret_scope import is_multiplex_active + return is_multiplex_active() + + +def _profile_runtime_scope_tokens(profile_home) -> "_TurnScopes | None": + """Bind HERMES_HOME + secret + terminal scope for ``profile_home`` (None = launch profile) and + return the reset tokens; None when nothing needs binding (unscoped single-profile launch body). + The launch profile's scope is its ``.env`` over the env frozen at activation (never live + ``os.environ``: a secondary context may have written to it since, #107422).""" + scopes = _TurnScopes() + if profile_home: + home = Path(profile_home) + # External sources first: the requested profile may never have been served in this process. + from hermes_cli.env_loader import hydrate_profile_secret_sources + hydrate_profile_secret_sources(home) + secrets = build_profile_secret_scope(home) + overlay = None + scopes.home = set_hermes_home_override(str(home)) + elif _launch_profile_scope_needed(): + # No home override: the launch home IS get_hermes_home() (``_profile_home`` answers None for + # "already the launch profile"); only its secrets + terminal policy need binding. + from tui_gateway.launch_profile_policy import launch_secret_scope, launch_terminal_env + home = Path(_hermes_home) + secrets = launch_secret_scope(home) + overlay = launch_terminal_env() + else: + return None + scopes.secret = set_secret_scope(secrets) # Same terminal policy the gateway binds per turn: a docker-configured profile # must never resolve the launch process's pinned env. Failure → refusal scope. - from tools.terminal_scope import install_profile_terminal_scope, reset_terminal_scope - terminal_token = install_profile_terminal_scope(Path(profile_home)) + from tools.terminal_scope import install_profile_terminal_scope + scopes.terminal = install_profile_terminal_scope(home, env_overlay=overlay) + return scopes + + +def _release_profile_runtime_scope_tokens(scopes: "_TurnScopes | None") -> None: + if scopes is None: + return + from tools.terminal_scope import reset_terminal_scope + if scopes.terminal is not None: + reset_terminal_scope(scopes.terminal) + if scopes.secret is not None: + reset_secret_scope(scopes.secret) + if scopes.home is not None: + reset_hermes_home_override(scopes.home) + + +@contextlib.contextmanager +def _session_profile_runtime_scope(session: dict): + """Bind model resolution to the session's profile config and secrets (launch profile included + once the process multiplexes; see ``_profile_runtime_scope_tokens``).""" + scopes = _profile_runtime_scope_tokens(session.get("profile_home")) try: yield finally: - reset_terminal_scope(terminal_token) - reset_secret_scope(secret_token) - reset_hermes_home_override(home_token) + _release_profile_runtime_scope_tokens(scopes) def _restart_completed_failed_agent_build(sid: str, session: dict, failed_ready: threading.Event | None) -> bool: diff --git a/tui_gateway/prompt_turn.py b/tui_gateway/prompt_turn.py index 5270c4df83..d314dc0942 100644 --- a/tui_gateway/prompt_turn.py +++ b/tui_gateway/prompt_turn.py @@ -444,24 +444,14 @@ def _prepare_turn_input(sid: str, session: dict, st: _TurnRun, text: Any, images scopes = st.scopes scopes.approval = set_current_session_key(session["session_key"]) scopes.session_tokens = _set_session_context(session["session_key"], ui_session_id=sid) - profile_home = session.get("profile_home") - if profile_home: - scopes.home = set_hermes_home_override(profile_home) - scopes.secret = set_secret_scope(build_profile_secret_scope(Path(profile_home))) - from tools.terminal_scope import install_profile_terminal_scope - scopes.terminal = install_profile_terminal_scope(Path(profile_home)) - elif _served_profile_homes: - # Multiplex residual of #68559 / #107422: the launch profile used to run - # unscoped and fall back to ambient os.environ. Once any secondary home - # has been served, bind the launch home's own terminal policy so a - # poisoned ambient bridge can never become the launch turn's authority. - # The launch process's env-only policy (TERMINAL_ENV=ssh from systemd / - # a launcher) has no file to rebuild it from: overlay the TERMINAL_* - # snapshot frozen at multiplex activation, never live os.environ. - from tools.terminal_scope import install_profile_terminal_scope - from tui_gateway.launch_terminal_policy import launch_terminal_env - scopes.terminal = install_profile_terminal_scope( - Path(_hermes_home), env_overlay=launch_terminal_env()) + # Profile turn: that profile's home + secrets + terminal policy. Launch-profile turn: unscoped in a + # single-profile process; once multiplexing is active (#68559 / #107422 residual) its OWN scope, + # built from the env frozen at activation — get_secret() fails closed then, so an unscoped default + # member's hosted-room turn otherwise died with UnscopedSecretError, and ambient TERMINAL_* a + # secondary context poisoned must never become the launch turn's authority. + bound = _profile_runtime_scope_tokens(session.get("profile_home")) + if bound is not None: + scopes.home, scopes.secret, scopes.terminal = bound.home, bound.secret, bound.terminal # The sudo password callback is thread-local: without re-wiring here, sudo prompts # fall through to /dev/tty and hang the headless gateway (re-run is a no-op). _wire_callbacks(sid) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 99ef6639f1..5a46c015c3 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -511,10 +511,12 @@ def _profile_home(profile: str | None) -> Path | None: if home.resolve() == Path(_hermes_home).resolve(): return None # already the launch profile (no override needed) if home not in _served_profile_homes: - # Last moment ambient TERMINAL_* is provably the launch profile's own: freeze it for - # launch-profile turns before any secondary code runs (tui_gateway/launch_terminal_policy.py). - from tui_gateway.launch_terminal_policy import capture_launch_terminal_env - capture_launch_terminal_env() + # This process now hosts a second profile home: freeze the launch env as the launch + # profile's own and flip get_secret() to fail closed, so an unscoped read for a + # secondary raises instead of returning the launch profile's os.environ value + # (tui_gateway/launch_profile_policy.py). Must run before any secondary code. + from tui_gateway.launch_profile_policy import activate_multi_profile_hosting + activate_multi_profile_hosting() _served_profile_homes.add(home) # the change watcher must stat every served sibling store too return home @@ -525,25 +527,21 @@ _served_profile_homes: set[Path] = set() def _profile_scoped(handler): - """Bind ``params['profile']``'s HERMES_HOME around a handler (pets/projects resolve via - ``get_hermes_home``, so app-global remote mode still hits the focused profile). No-op for launch. + """Bind ``params['profile']``'s full runtime scope (HERMES_HOME + secrets + terminal policy) around a + handler, so config.yaml ``${VAR}`` refs, provider credential checks and ``.env`` writes resolve to + THAT profile (app-global remote mode hits the focused profile). Home alone left ``get_secret`` on the + launch process's ``os.environ``: ``config.get full`` for a secondary shipped the default profile's + expanded secrets and ``config.set`` published a secondary's ``.env`` edit into the shared process env. - Secondary-profile adapters are constructed inside ``_profile_runtime_scope`` (secret scope installed + - multiplex active) — the same discriminator the Buzz/SimpleX adapters use for this bug class (#98738). - Once multiplexing is active, launch-profile *turns* bind their own terminal scope - (``prompt_turn._prepare_turn_input``) so they never depend on ambient ``os.environ`` - that a secondary context might have poisoned (#107422). Single-profile processes stay - unscoped and keep legacy ``os.environ`` precedence. + Launch profile: unscoped while this is a single-profile process (legacy ``os.environ`` precedence, + systemd / ``op run`` injection); once multiplexing is active it binds its own scope from the env + frozen at activation (``_session_profile_runtime_scope``), never ambient state a secondary context + might have poisoned (#107422). """ def wrapper(rid, params): home = _profile_home(params.get("profile") if isinstance(params, dict) else None) - if home is None: + with _session_profile_runtime_scope({"profile_home": str(home) if home else None}): return handler(rid, params) - token = set_hermes_home_override(home) - try: - return handler(rid, params) - finally: - reset_hermes_home_override(token) return wrapper @@ -953,31 +951,29 @@ def _wait_agent_for_prompt(session: dict, rid: str, sid: str) -> dict | None: return _err(rid, 5032, err) if (err := session.get("agent_error")) else None -def _bind_build_profile_scopes(profile_home: str) -> "_TurnScopes": - """Bind a session profile's HERMES_HOME / secret / terminal scopes for an agent build. Fail-open per - scope (the build must not die on a scope helper); the terminal installer itself fails closed (malformed - policy → refusal scope) so _make_agent's terminal probing / cwd hints resolve the routed profile.""" +def _bind_build_profile_scopes(profile_home: "str | None") -> "_TurnScopes | None": + """Bind a session profile's HERMES_HOME / secret / terminal scopes for an agent build. ``None`` is the + launch profile: unscoped in a single-profile process, its own frozen-env scope once multiplexing is + active (a hosted-room turn for a default member otherwise died at build with ``UnscopedSecretError`` + because the launch profile was treated as "no scope"). Fail-open per scope (the build must not die on + a scope helper); the terminal installer itself fails closed (malformed policy → refusal scope) so + _make_agent's terminal probing / cwd hints resolve the routed profile.""" + if not profile_home and not _launch_profile_scope_needed(): + return None scopes = _TurnScopes() - scopes.home = set_hermes_home_override(profile_home) with contextlib.suppress(Exception): - scopes.secret = set_secret_scope(build_profile_secret_scope(Path(profile_home))) - scopes.terminal = None - with contextlib.suppress(Exception): - from tools.terminal_scope import install_profile_terminal_scope - scopes.terminal = install_profile_terminal_scope(Path(profile_home)) + return _profile_runtime_scope_tokens(profile_home) + if profile_home: # secret/terminal helper failed: keep at least the home + terminal refusal scope + scopes.home = set_hermes_home_override(profile_home) + with contextlib.suppress(Exception): + from tools.terminal_scope import install_profile_terminal_scope + scopes.terminal = install_profile_terminal_scope(Path(profile_home)) return scopes -def _release_build_profile_scopes(scopes: "_TurnScopes") -> None: - if scopes.home is not None: - reset_hermes_home_override(scopes.home) - if scopes.secret is not None: - with contextlib.suppress(Exception): - reset_secret_scope(scopes.secret) - if scopes.terminal is not None: - with contextlib.suppress(Exception): - from tools.terminal_scope import reset_terminal_scope - reset_terminal_scope(scopes.terminal) +def _release_build_profile_scopes(scopes: "_TurnScopes | None") -> None: + with contextlib.suppress(Exception): + _release_profile_runtime_scope_tokens(scopes) def _deferred_build_agent_kwargs(current: dict, session_db) -> dict: @@ -1117,8 +1113,8 @@ def _start_agent_build(sid: str, session: dict) -> None: # Global-remote: bind the session profile's HERMES_HOME and hand the agent that profile's db — # DEDICATED and ours until _transfer_db_to_agent in the finally; FAIL CLOSED rather than # binding the launch DB and bleeding rows into the wrong state.db. + scopes = _bind_build_profile_scopes(profile_home) if profile_home: - scopes = _bind_build_profile_scopes(profile_home) session_db = _open_profile_session_db(profile_home) try: from tui_gateway.entry import ensure_mcp_discovery_started