Files
hermes-agent/tests/gateway/test_standalone_gateway_launch_scope.py
teknium1 51c4b6ba9d fix(gateway): standalone gateway binds the launch profile's own scope after hosted activation
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>
2026-09-16 14:20:17 -07:00

73 lines
3.4 KiB
Python

"""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