diff --git a/gateway/run.py b/gateway/run.py index 527da4ff5132..9c70fb2d56b9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2802,6 +2802,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew def __init__(self, config: Optional[GatewayConfig] = None): global _gateway_runner_ref + # Support dict input for test compatibility; convert to GatewayConfig + if config is not None and isinstance(config, dict): + config = GatewayConfig(**config) self.config = config or load_gateway_config() # Mark the process as a profile multiplexer when configured. This flips # agent.secret_scope.get_secret() to fail-closed on any unscoped @@ -13100,6 +13103,33 @@ async def _run_background_task( event_message_id: Optional[str] = None, media_urls: Optional[List[str]] = None, media_types: Optional[List[str]] = None, + ) -> None: + """Profile-scoping wrapper around the background agent task. + + When multiplexing is active, resolve the inbound source's profile and + run the whole task inside ``_profile_runtime_scope`` so credentials + resolve from that profile's secret scope. Mirrors the pattern in + ``_run_agent``. + """ + if not getattr(getattr(self, "config", None), "multiplex_profiles", False): + return await self._run_background_task_inner( + prompt, source, task_id, event_message_id, media_urls, media_types, + ) + + profile_home = self._resolve_profile_home_for_source(source) + with _profile_runtime_scope(profile_home): + return await self._run_background_task_inner( + prompt, source, task_id, event_message_id, media_urls, media_types, + ) + + async def _run_background_task_inner( + self, + prompt: str, + source: "SessionSource", + task_id: str, + event_message_id: Optional[str] = None, + media_urls: Optional[List[str]] = None, + media_types: Optional[List[str]] = None, ) -> None: """Execute a background agent task and deliver the result to the chat.""" from run_agent import AIAgent diff --git a/tests/gateway/test_multiplex_background_task_scope.py b/tests/gateway/test_multiplex_background_task_scope.py new file mode 100644 index 000000000000..891a37493ca3 --- /dev/null +++ b/tests/gateway/test_multiplex_background_task_scope.py @@ -0,0 +1,77 @@ +"""Regression: background tasks respect profile secret scope when multiplexing. + +Issue #60726: /background command runs _run_background_task without a profile +scope, causing UnscopedSecretError when multiplexing is active and credentials +are profile-scoped. +""" +import pytest +from unittest import mock +from pathlib import Path + + +class TestBackgroundTaskProfileScope: + """_run_background_task installs _profile_runtime_scope when multiplexing is active.""" + + def test_background_task_calls_inner_wrapped_in_scope_when_multiplex_active(self): + """When multiplex_profiles is True, _run_background_task wraps call in _profile_runtime_scope.""" + from gateway.run import GatewayRunner + + config = {"multiplex_profiles": True} + gw = GatewayRunner(config=config) + gw._session_db = mock.MagicMock() + gw._adapter_for_source = mock.MagicMock(return_value=mock.MagicMock()) + + mock_inner = mock.AsyncMock(return_value=None) + gw._run_background_task_inner = mock_inner + + import asyncio + source = mock.MagicMock() + source.profile = "test_profile" + + # Mock _resolve_profile_home_for_source to return a known path + with mock.patch.object(gw, "_resolve_profile_home_for_source", return_value=Path("/fake/profile")): + # Mock _profile_runtime_scope + from gateway.run import _profile_runtime_scope + with mock.patch("gateway.run._profile_runtime_scope") as mock_scope: + mock_scope.return_value.__enter__ = mock.MagicMock() + mock_scope.return_value.__exit__ = mock.MagicMock() + + asyncio.run( + gw._run_background_task( + prompt="test", + source=source, + task_id="test_task", + ) + ) + + # _profile_runtime_scope should have been called with profile_home (Path object) + mock_scope.assert_called_once_with(Path("/fake/profile")) + mock_inner.assert_called_once() + + def test_background_task_calls_inner_direct_when_multiplex_disabled(self): + """When multiplex_profiles is False, _run_background_task calls inner directly.""" + from gateway.run import GatewayRunner + + config = {"multiplex_profiles": False} + gw = GatewayRunner(config=config) + gw._session_db = mock.MagicMock() + gw._adapter_for_source = mock.MagicMock(return_value=mock.MagicMock()) + + mock_inner = mock.AsyncMock(return_value=None) + gw._run_background_task_inner = mock_inner + + import asyncio + source = mock.MagicMock() + + with mock.patch("gateway.run._profile_runtime_scope") as mock_scope: + asyncio.run( + gw._run_background_task( + prompt="test", + source=source, + task_id="test_task", + ) + ) + + # _profile_runtime_scope should NOT have been called + mock_scope.assert_not_called() + mock_inner.assert_called_once() \ No newline at end of file