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>
50 lines
1.7 KiB
Python
50 lines
1.7 KiB
Python
"""Regression: background tasks respect profile secret scope when multiplexing.
|
|
|
|
Issue #60726: /bg spawns _run_background_task as a fire-and-forget
|
|
asyncio task with no profile scope, so _resolve_session_agent_runtime()'s
|
|
credential reads raise UnscopedSecretError when multiplex_profiles is on.
|
|
The fix wraps the task body in _profile_runtime_scope, mirroring _run_agent.
|
|
"""
|
|
import asyncio
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
from gateway.config import GatewayConfig
|
|
from gateway.run import GatewayRunner
|
|
|
|
|
|
def _make_runner(multiplex: bool) -> GatewayRunner:
|
|
runner = GatewayRunner.__new__(GatewayRunner)
|
|
runner.config = GatewayConfig(multiplex_profiles=multiplex)
|
|
return runner
|
|
|
|
|
|
class TestBackgroundTaskProfileScope:
|
|
"""_run_background_task installs _profile_runtime_scope when multiplexing is active."""
|
|
|
|
def test_wraps_in_profile_scope_when_multiplex_active(self):
|
|
runner = _make_runner(multiplex=True)
|
|
inner = mock.AsyncMock(return_value=None)
|
|
runner._run_background_task_inner = inner
|
|
|
|
source = mock.MagicMock()
|
|
source.profile = "test_profile"
|
|
|
|
with mock.patch.object(
|
|
GatewayRunner,
|
|
"_resolve_profile_home_for_source",
|
|
return_value=Path("/fake/profile"),
|
|
), mock.patch("gateway.run._profile_runtime_scope") as scope:
|
|
scope.return_value.__enter__ = mock.MagicMock()
|
|
scope.return_value.__exit__ = mock.MagicMock(return_value=False)
|
|
asyncio.run(
|
|
runner._run_background_task(
|
|
prompt="test", source=source, task_id="bg_test"
|
|
)
|
|
)
|
|
|
|
scope.assert_called_once_with(Path("/fake/profile"))
|
|
inner.assert_awaited_once()
|
|
|
|
|