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>
73 lines
3.4 KiB
Python
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
|