Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions gateway/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<profile>:` 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
Expand Down
24 changes: 18 additions & 6 deletions gateway/run_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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]:
Expand Down
27 changes: 24 additions & 3 deletions gateway/run_turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -2163,14 +2163,35 @@ 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``). 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):
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
Expand Down
72 changes: 72 additions & 0 deletions tests/gateway/test_standalone_gateway_launch_scope.py
Original file line number Diff line number Diff line change
@@ -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
22 changes: 21 additions & 1 deletion tui_gateway/launch_profile_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Loading