diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 02c4cd468020..acf653af101b 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -36,6 +36,7 @@ from pathlib import Path as _Path sys.path.insert(0, str(_Path(__file__).resolve().parents[3])) +from agent.secret_scope import UnscopedSecretError, get_secret from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import ( @@ -972,13 +973,16 @@ class SlackAdapter(BasePlatformAdapter): return False raw_token = self.config.token - # Multiplex: profile secrets live in secret_scope, not process os.environ. - # Prefer get_secret; fall back to os.getenv for single-profile / tests. + # Multiplex: profile secrets live in the secret scope, not process + # os.environ. When a scope is installed (secondary-profile connect), + # it is AUTHORITATIVE — do not fall through to os.getenv, or a + # secondary profile missing SLACK_APP_TOKEN silently inherits the + # default profile's Socket Mode app (#59739). Only an UNSCOPED read + # under multiplex (default-profile startup loop, background reconnect + # rebuild) falls back to process env, which is that profile's own. try: - from agent.secret_scope import get_secret - - app_token = get_secret("SLACK_APP_TOKEN") or os.getenv("SLACK_APP_TOKEN") - except Exception: + app_token = get_secret("SLACK_APP_TOKEN") + except UnscopedSecretError: app_token = os.getenv("SLACK_APP_TOKEN") if not raw_token: diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index de9d92214861..ae7cc1f510fa 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -16,6 +16,7 @@ from unittest.mock import AsyncMock, MagicMock, patch, call import pytest +import agent.secret_scope as secret_scope from gateway.config import Platform, PlatformConfig from gateway.run import GatewayRunner from gateway.platforms.base import ( @@ -263,6 +264,72 @@ class TestAppMentionHandler: expected ), f"Slack slash regex does not match {expected}" + @pytest.mark.asyncio + async def test_connect_uses_profile_scoped_app_token(self): + """Socket Mode must use the active profile's app token in multiplex mode.""" + config = PlatformConfig(enabled=True, token="xoxb-profile") + adapter = SlackAdapter(config) + + def _noop_decorator(_matcher): + def decorator(fn): + return fn + + return decorator + + mock_app = MagicMock() + mock_app.event = _noop_decorator + mock_app.command = _noop_decorator + mock_app.action = _noop_decorator + mock_app.client = AsyncMock() + + mock_web_client = AsyncMock() + mock_web_client.auth_test = AsyncMock( + return_value={ + "user_id": "U_PROFILE", + "user": "profilebot", + "team_id": "T_PROFILE", + "team": "ProfileTeam", + } + ) + + created_handlers = [] + + class FakeSocketModeHandler: + def __init__(self, app, app_token, proxy=None): + self.app = app + self.app_token = app_token + self.proxy = proxy + self.client = MagicMock(proxy=None) + created_handlers.append(self) + + async def start_async(self): + return None + + async def close_async(self): + return None + + secret_scope.set_multiplex_active(True) + token = secret_scope.set_secret_scope({"SLACK_APP_TOKEN": "xapp-profile"}) + try: + with ( + patch.object(_slack_mod, "AsyncApp", return_value=mock_app), + patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), + patch.object( + _slack_mod, "AsyncSocketModeHandler", FakeSocketModeHandler + ), + patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-default"}), + patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), + patch("asyncio.create_task", side_effect=_fake_create_task), + ): + result = await adapter.connect() + finally: + secret_scope.reset_secret_scope(token) + secret_scope.set_multiplex_active(False) + + assert result is True + assert created_handlers + assert created_handlers[0].app_token == "xapp-profile" + class TestSlackConnectCleanup: """Regression coverage for failed connect() cleanup."""