Skip to content
Closed
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
30 changes: 30 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This production API change is only needed by the new test. Please construct GatewayConfig(multiplex_profiles=...) in the test instead; real config mappings are normalized through GatewayConfig.from_dict, not direct dataclass construction.

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
Expand Down Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions tests/gateway/test_multiplex_background_task_scope.py
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mocking _profile_runtime_scope proves the wrapper invokes it, but not that the inner task sees an active secret scope. Prefer a real scope and assert current_secret_scope() or a profile-scoped credential from the inner coroutine.

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()
Loading