From fe2adcd19382f4bb7e990580608dd2c76793bf54 Mon Sep 17 00:00:00 2001 From: KoNit-K <124019182+KoNit-K@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:18:23 +0800 Subject: [PATCH 1/3] fix(gateway): scope standalone turns after hosted activation --- gateway/run_turn.py | 12 +++++++--- .../test_multiplex_background_task_scope.py | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/gateway/run_turn.py b/gateway/run_turn.py index 3eac3a283c48d..910582a00b650 100644 --- a/gateway/run_turn.py +++ b/gateway/run_turn.py @@ -2163,12 +2163,18 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g self._clear_session_env(_session_env_tokens) def _profile_scope_for_source(self, source: SessionSource): - """``_profile_runtime_scope`` for ``source``'s profile when multiplexing, else a no-op context. + """``_profile_runtime_scope`` for ``source``'s profile when a secret scope is required. Under multiplexing config/skills/memory resolve to the source profile's home AND credentials - come from its secret scope (never process-global ``os.environ``).""" + come from its secret scope (never process-global ``os.environ``). Hosted-room activity can + activate the same process-wide credential guard for a standalone gateway, which must bind + its default profile rather than leave a later credential read unscoped.""" + from agent.secret_scope import is_multiplex_active from gateway.run import _profile_runtime_scope - if getattr(getattr(self, "config", None), "multiplex_profiles", False): + if ( + getattr(getattr(self, "config", None), "multiplex_profiles", False) + or is_multiplex_active() + ): return _profile_runtime_scope(self._resolve_profile_home_for_source(source)) return nullcontext() diff --git a/tests/gateway/test_multiplex_background_task_scope.py b/tests/gateway/test_multiplex_background_task_scope.py index bf5c70e7241b2..3e32361a00717 100644 --- a/tests/gateway/test_multiplex_background_task_scope.py +++ b/tests/gateway/test_multiplex_background_task_scope.py @@ -9,6 +9,7 @@ from pathlib import Path from unittest import mock +from agent import secret_scope from gateway.config import GatewayConfig from gateway.run import GatewayRunner @@ -47,3 +48,26 @@ def test_wraps_in_profile_scope_when_multiplex_active(self): inner.assert_awaited_once() +def test_standalone_gateway_binds_default_scope_after_hosted_activation( + tmp_path, monkeypatch +): + """Regression for #112878: hosted rooms can activate fail-closed scopes globally. + + A gateway configured without multiplexing must still bind its default profile + when a hosted room has already activated the process-wide secret guard. + """ + from tui_gateway import launch_profile_policy + + home = tmp_path / "default" + home.mkdir() + (home / ".env").write_text("OPENAI_API_KEY=default-key\n", encoding="utf-8") + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False) + monkeypatch.setattr(launch_profile_policy, "_snapshot", None) + + runner = _make_runner(multiplex=False) + source = mock.MagicMock() + with mock.patch.object(runner, "_resolve_profile_home_for_source", return_value=home): + launch_profile_policy.activate_multi_profile_hosting() + with runner._profile_scope_for_source(source): + assert secret_scope.get_secret("OPENAI_API_KEY") == "default-key" + From 4a6d41979d72a04dc77633698f997d93c590e9e9 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:15:07 -0700 Subject: [PATCH 2/3] fix(gateway): standalone gateway binds the launch profile's own scope after hosted activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A native hosted room running a second profile calls tui_gateway.launch_profile_policy.activate_multi_profile_hosting() inside the messaging gateway process, so get_secret() fails closed for every unscoped read afterwards. A gateway with gateway.multiplex_profiles: false never bound a scope (the config flag was the only gate), so its next ordinary turn died in _resolve_session_agent_runtime with UnscopedSecretError / "Hermes could not read this profile's API key" until restart (#112878). Builds on the predicate from #112884: instead of re-entering the routed-profile scope (which rebuilds credentials from .env alone and would drop a key injected by systemd / `op run`), the standalone branch binds the launch profile's OWN scope — launch_secret_scope() (.env + external sources over the env frozen at activation) plus its terminal policy — exactly what the tui_gateway already binds for launch-profile RPC bodies. One predicate, GatewayTurnMixin ._standalone_launch_scope(), no-op while the process is single-profile. Whole class: every standalone entry point now runs under it — the foreground / background turn wrappers, busy, goals and heartbeat-restore paths already routed through _profile_scope_for_source, and the primary adapter's message, busy-session and platform-event handlers (slash commands such as /model, /status and /compress run inside _handle_message and were equally stranded). Cron already binds its own per-fire scope. The guard is not weakened: reads outside any scope still fail closed and a secondary never sees the launch env. Tests: tests/gateway/test_standalone_gateway_launch_scope.py — real activation helper, real server-bound secondary scope entered and left, then the standalone turn and handler resolve both the .env key and the env-injected key (red on origin/main with UnscopedSecretError); control: no activation → nullcontext and no scope in the handler. The #112884 test moves here in trimmed form. Co-authored-by: Lyti4 <205342405+Lyti4@users.noreply.github.com> Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com> --- gateway/run_adapters.py | 24 +++++-- gateway/run_turn.py | 33 ++++++--- .../test_multiplex_background_task_scope.py | 24 ------- .../test_standalone_gateway_launch_scope.py | 72 +++++++++++++++++++ tui_gateway/launch_profile_policy.py | 22 +++++- 5 files changed, 135 insertions(+), 40 deletions(-) create mode 100644 tests/gateway/test_standalone_gateway_launch_scope.py diff --git a/gateway/run_adapters.py b/gateway/run_adapters.py index 9f422d07143b6..ca506335d79a1 100644 --- a/gateway/run_adapters.py +++ b/gateway/run_adapters.py @@ -1438,14 +1438,26 @@ def _stamp_routed_profile(self, source) -> bool: def _primary_message_handler(self): """Return the correctly scoped handler for a primary adapter.""" - return self._make_default_profile_message_handler() if self._multiplex_on() else self._handle_message + if self._multiplex_on(): + return self._make_default_profile_message_handler() + return self._standalone_scoped(self._handle_message) def _primary_busy_session_handler(self): """Return the correctly scoped busy-session handler for a primary adapter.""" - return ( - self._make_default_profile_busy_session_handler() - if self._multiplex_on() else self._handle_active_session_busy_message - ) + if self._multiplex_on(): + return self._make_default_profile_busy_session_handler() + return self._standalone_scoped(self._handle_active_session_busy_message) + + def _standalone_scoped(self, handler): + """Standalone twin of the ``_make_default_profile_*`` wrappers: run ``handler`` under + ``_standalone_launch_scope`` so slash commands and turns keep resolving the launch profile's + credentials after a hosted room flipped the process-wide guard (#112878). Decided per event: + activation happens after the adapters were wired.""" + async def _handler(*args): + with self._standalone_launch_scope(): + return await handler(*args) + + return _handler def _multiplex_on(self) -> bool: return bool(getattr(self.config, "multiplex_profiles", False)) @@ -1486,7 +1498,7 @@ async def _handler(event, source): def _primary_platform_event_handler(self): if self._multiplex_on(): return self._make_default_profile_platform_event_handler() - return self._handle_gateway_platform_event + return self._standalone_scoped(self._handle_gateway_platform_event) @staticmethod def _adapter_credential_claim(platform: Platform, adapter: Any) -> Optional[tuple]: diff --git a/gateway/run_turn.py b/gateway/run_turn.py index 910582a00b650..1490ecf756101 100644 --- a/gateway/run_turn.py +++ b/gateway/run_turn.py @@ -2166,17 +2166,32 @@ def _profile_scope_for_source(self, source: SessionSource): """``_profile_runtime_scope`` for ``source``'s profile when a secret scope is required. Under multiplexing config/skills/memory resolve to the source profile's home AND credentials - come from its secret scope (never process-global ``os.environ``). Hosted-room activity can - activate the same process-wide credential guard for a standalone gateway, which must bind - its default profile rather than leave a later credential read unscoped.""" - from agent.secret_scope import is_multiplex_active + come from its secret scope (never process-global ``os.environ``). A standalone gateway + (``multiplex_profiles`` off) still binds once a hosted room has flipped the process-wide + credential guard — see ``_standalone_launch_scope``.""" from gateway.run import _profile_runtime_scope - if ( - getattr(getattr(self, "config", None), "multiplex_profiles", False) - or is_multiplex_active() - ): + if getattr(getattr(self, "config", None), "multiplex_profiles", False): return _profile_runtime_scope(self._resolve_profile_home_for_source(source)) - return nullcontext() + return self._standalone_launch_scope() + + @staticmethod + def _standalone_launch_scope(): + """Scope for a standalone gateway's own (launch-profile) work: a no-op until the process hosts + another profile home, then the launch profile's OWN runtime scope. + + A native hosted room running a second profile calls + ``tui_gateway.launch_profile_policy.activate_multi_profile_hosting`` inside the gateway process, + so ``get_secret`` fails closed for every unscoped read afterwards — including the standalone + gateway's ordinary turns, which never bound a scope because ``multiplex_profiles`` is off + (#112878). The launch profile is a profile too: bind its ``.env`` over the env frozen at + activation (a key injected by systemd / ``op run`` has no file to rebuild it from), never a + secondary's scope and never live ``os.environ``.""" + from agent.secret_scope import is_multiplex_active + if not is_multiplex_active(): + return nullcontext() + from hermes_constants import get_process_hermes_home + from tui_gateway.launch_profile_policy import launch_profile_runtime_scope + return launch_profile_runtime_scope(get_process_hermes_home()) def _media_delivery_scope_for_source(self, source: SessionSource): """Home + terminal-policy scope for validating a turn's MEDIA / local-file paths on the diff --git a/tests/gateway/test_multiplex_background_task_scope.py b/tests/gateway/test_multiplex_background_task_scope.py index 3e32361a00717..bf5c70e7241b2 100644 --- a/tests/gateway/test_multiplex_background_task_scope.py +++ b/tests/gateway/test_multiplex_background_task_scope.py @@ -9,7 +9,6 @@ from pathlib import Path from unittest import mock -from agent import secret_scope from gateway.config import GatewayConfig from gateway.run import GatewayRunner @@ -48,26 +47,3 @@ def test_wraps_in_profile_scope_when_multiplex_active(self): inner.assert_awaited_once() -def test_standalone_gateway_binds_default_scope_after_hosted_activation( - tmp_path, monkeypatch -): - """Regression for #112878: hosted rooms can activate fail-closed scopes globally. - - A gateway configured without multiplexing must still bind its default profile - when a hosted room has already activated the process-wide secret guard. - """ - from tui_gateway import launch_profile_policy - - home = tmp_path / "default" - home.mkdir() - (home / ".env").write_text("OPENAI_API_KEY=default-key\n", encoding="utf-8") - monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False) - monkeypatch.setattr(launch_profile_policy, "_snapshot", None) - - runner = _make_runner(multiplex=False) - source = mock.MagicMock() - with mock.patch.object(runner, "_resolve_profile_home_for_source", return_value=home): - launch_profile_policy.activate_multi_profile_hosting() - with runner._profile_scope_for_source(source): - assert secret_scope.get_secret("OPENAI_API_KEY") == "default-key" - diff --git a/tests/gateway/test_standalone_gateway_launch_scope.py b/tests/gateway/test_standalone_gateway_launch_scope.py new file mode 100644 index 0000000000000..e47d5fc7c47c5 --- /dev/null +++ b/tests/gateway/test_standalone_gateway_launch_scope.py @@ -0,0 +1,72 @@ +"""A standalone gateway (``multiplex_profiles`` off) keeps resolving the launch profile's credentials +after a native hosted room activated the process-wide secret guard (#112878). + +``tui_gateway.launch_profile_policy.activate_multi_profile_hosting`` runs inside the messaging +gateway process when a hosted room serves a second profile; ``get_secret`` then fails closed for +every unscoped read. The standalone gateway's turns and handler entry points must bind the launch +profile's OWN scope (``.env`` over the env frozen at activation) — not skip binding because the +config flag is off, and not rebuild from ``.env`` alone (systemd / ``op run`` injection has no file). +""" +import asyncio +from contextlib import nullcontext +from unittest import mock + +import pytest + +from agent import secret_scope +from agent.secret_scope import UnscopedSecretError, current_secret_scope, get_secret +from gateway.config import GatewayConfig +from gateway.run import GatewayRunner +from tui_gateway import launch_profile_policy + +INJECTED = "HOSTEDROOM_TEST_INJECTED_KEY" + + +@pytest.fixture +def standalone(tmp_path, monkeypatch): + launch = tmp_path / "launch" + launch.mkdir() + (launch / ".env").write_text("OPENAI_API_KEY=launch-dotenv-key\n", encoding="utf-8") + secondary = tmp_path / "profiles" / "roomie" + secondary.mkdir(parents=True) + (secondary / ".env").write_text("OPENAI_API_KEY=secondary-key\n", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(launch)) + monkeypatch.setenv(INJECTED, "launch-env-injected") # systemd / `op run` style injection + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False) + monkeypatch.setattr(launch_profile_policy, "_snapshot", None) + runner = GatewayRunner.__new__(GatewayRunner) + runner.config = GatewayConfig(multiplex_profiles=False) + return runner, secondary + + +def _keys(): + return get_secret("OPENAI_API_KEY"), get_secret(INJECTED) + + +def test_standalone_turn_binds_launch_profile_scope_after_hosted_activation(standalone): + runner, secondary = standalone + from tui_gateway.server import _session_profile_runtime_scope + + # A native hosted room ran a second profile: real activation, real secondary scope entered and left. + launch_profile_policy.activate_multi_profile_hosting() + with _session_profile_runtime_scope({"profile_home": str(secondary)}): + assert get_secret("OPENAI_API_KEY") == "secondary-key" + assert get_secret(INJECTED) is None # the launch env never leaks into a secondary + assert secret_scope.is_multiplex_active() + + source = mock.MagicMock(profile=None) + with runner._profile_scope_for_source(source): + assert _keys() == ("launch-dotenv-key", "launch-env-injected") + runner._handle_message = mock.AsyncMock(side_effect=lambda event: _keys()) + assert asyncio.run(runner._primary_message_handler()(mock.MagicMock(source=source))) == ( + "launch-dotenv-key", "launch-env-injected") + with pytest.raises(UnscopedSecretError): # the guard itself is not weakened + get_secret("OPENAI_API_KEY") + + +def test_standalone_without_hosted_activation_stays_unscoped(standalone): + runner, _secondary = standalone + source = mock.MagicMock(profile=None) + assert isinstance(runner._profile_scope_for_source(source), nullcontext) + runner._handle_message = mock.AsyncMock(side_effect=lambda event: current_secret_scope()) + assert asyncio.run(runner._primary_message_handler()(mock.MagicMock(source=source))) is None diff --git a/tui_gateway/launch_profile_policy.py b/tui_gateway/launch_profile_policy.py index a84b948ac0baf..42883f33ce874 100644 --- a/tui_gateway/launch_profile_policy.py +++ b/tui_gateway/launch_profile_policy.py @@ -17,10 +17,11 @@ from __future__ import annotations +import contextlib import os import threading from pathlib import Path -from typing import Dict, Optional +from typing import Dict, Iterator, Optional _lock = threading.Lock() _snapshot: Optional[Dict[str, str]] = None @@ -76,3 +77,22 @@ def launch_secret_scope(launch_home: "str | Path") -> Dict[str, str]: scope = {k: v for k, v in _launch_env().items() if not _is_global_env(k)} scope.update(build_profile_secret_scope(Path(launch_home))) return scope + + +@contextlib.contextmanager +def launch_profile_runtime_scope(launch_home: "str | Path") -> Iterator[None]: + """Bind the launch profile's own runtime scope for one body: ``launch_secret_scope`` plus its + terminal policy over the frozen launch ``TERMINAL_*`` overlay. No HERMES_HOME override — the + launch home IS the process home. For hosts whose launch-profile bodies are not RPC sessions + (the standalone messaging gateway after a hosted room activated multiplexing, #112878).""" + from agent.secret_scope import reset_secret_scope, set_secret_scope + from tools.terminal_scope import install_profile_terminal_scope, reset_terminal_scope + + home = Path(launch_home) + secret_token = set_secret_scope(launch_secret_scope(home)) + terminal_token = install_profile_terminal_scope(home, env_overlay=launch_terminal_env()) + try: + yield + finally: + reset_terminal_scope(terminal_token) + reset_secret_scope(secret_token) From fe87767eae6255fbf63a9d29925d73d7fbd35acb Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:15:59 -0700 Subject: [PATCH 3/3] docs(gateway): standalone gateways bind the launch scope once hosted activation flips the guard Records the #112878 rule in gateway/AGENTS.md so a new standalone path gates on _standalone_launch_scope rather than the config flag. --- gateway/AGENTS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/gateway/AGENTS.md b/gateway/AGENTS.md index 52fe85fb5be2c..3f7fc691c9c38 100644 --- a/gateway/AGENTS.md +++ b/gateway/AGENTS.md @@ -165,6 +165,14 @@ gateway under the backend, and do NOT "fix" update locks by widening the tree-ki Resolve the owning home from the session record (`profile_home`, `agent::` key), never from `os.environ`, which holds the launch profile. Why: eviction that flushed under the launch scope wrote a secondary profile's memories into the default profile's store, silently. +- **`multiplex_profiles: false` is not "no scope ever".** A native hosted room serving a second + profile flips the process-wide guard (`tui_gateway/launch_profile_policy.py:: + activate_multi_profile_hosting`) inside the gateway process, after the adapters were wired; every + standalone entry point (`run_turn.py::_profile_scope_for_source`, the primary adapter's message / + busy / platform-event handlers via `run_adapters.py::_standalone_scoped`) then binds the launch + profile's OWN scope through `run_turn.py::_standalone_launch_scope` — `.env` over the env frozen at + activation, never a `.env`-only rebuild (systemd / `op run` keys have no file) and never live + `os.environ`. Gate a new standalone path on that helper, not on the config flag (#112878). - **Hooks and observers register per served profile.** `builtin_hooks/`, `agent/shell_hooks.py:: register_from_config` and lifecycle observers are prepared under each profile's scope at startup and on profile add/remove; idempotence keys include the profile, and `hooks/` paths resolve at call