diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 3604809dd9..72054e3364 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -336,6 +336,39 @@ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]: return {}, {"proxy": proxy_url} +def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = None) -> bool: + """Return True when ``hostname`` matches a ``NO_PROXY`` entry. + + Supports comma- or whitespace-separated entries with optional leading dots + and ``*.`` wildcards, which match both the apex domain and subdomains. + """ + raw = no_proxy_value + if raw is None: + raw = os.environ.get("NO_PROXY") or os.environ.get("no_proxy") or "" + + raw = raw.strip() + if not raw: + return False + + lower_hostname = hostname.lower() + for entry in re.split(r"[\s,]+", raw): + normalized = entry.strip().lower() + if not normalized: + continue + if normalized == "*": + return True + + if normalized.startswith("*."): + normalized = normalized[2:] + elif normalized.startswith("."): + normalized = normalized[1:] + + if lower_hostname == normalized or lower_hostname.endswith(f".{normalized}"): + return True + + return False + + from dataclasses import dataclass, field from datetime import datetime from pathlib import Path diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index fc92d11443..ea75130a9a 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -41,6 +41,8 @@ from gateway.platforms.base import ( ProcessingOutcome, SendResult, SUPPORTED_DOCUMENT_TYPES, + is_host_excluded_by_no_proxy, + resolve_proxy_url, safe_url_for_log, cache_document_from_bytes, ) @@ -217,6 +219,40 @@ def _serialize_slack_blocks_for_agent(blocks: list, max_chars: int = 6000) -> st return f"[Slack Block Kit payload for this message]\n```json\n{payload}\n```" +def _apply_slack_proxy(client: Any, proxy_url: Optional[str]) -> None: + """Apply a resolved proxy to a Slack SDK client or clear it explicitly.""" + if hasattr(client, "proxy"): + client.proxy = proxy_url + + +_SLACK_PROXY_HOSTS = ( + "slack.com", + "files.slack.com", + "wss-primary.slack.com", +) + + +def _resolve_slack_proxy_url() -> Optional[str]: + """Resolve a proxy URL that Slack SDK clients can safely use.""" + proxy_url = resolve_proxy_url() + if not proxy_url: + return None + + normalized = proxy_url.lower() + if not normalized.startswith(("http://", "https://")): + logger.info( + "[Slack] Ignoring unsupported proxy scheme for Slack transport: %s", + safe_url_for_log(proxy_url), + ) + return None + + if any(is_host_excluded_by_no_proxy(host) for host in _SLACK_PROXY_HOSTS): + logger.info("[Slack] NO_PROXY bypasses Slack proxy configuration") + return None + + return proxy_url + + class SlackAdapter(BasePlatformAdapter): """ Slack bot adapter using Socket Mode. @@ -237,13 +273,13 @@ class SlackAdapter(BasePlatformAdapter): def __init__(self, config: PlatformConfig): super().__init__(config, Platform.SLACK) - self._app: Optional[AsyncApp] = None - self._handler: Optional[AsyncSocketModeHandler] = None + self._app: Optional[Any] = None + self._handler: Optional[Any] = None self._bot_user_id: Optional[str] = None self._user_name_cache: Dict[str, str] = {} # user_id → display name self._socket_mode_task: Optional[asyncio.Task] = None # Multi-workspace support - self._team_clients: Dict[str, AsyncWebClient] = {} # team_id → WebClient + self._team_clients: Dict[str, Any] = {} # team_id → WebClient self._team_bot_user_ids: Dict[str, str] = {} # team_id → bot_user_id self._channel_team: Dict[str, str] = {} # channel_id → team_id # Dedup cache: prevents duplicate bot responses when Socket Mode @@ -350,6 +386,10 @@ class SlackAdapter(BasePlatformAdapter): logger.error("[Slack] SLACK_APP_TOKEN not set") return False + proxy_url = _resolve_slack_proxy_url() + if proxy_url: + logger.info("[Slack] Using proxy for Slack transport: %s", safe_url_for_log(proxy_url)) + # Support comma-separated bot tokens for multi-workspace bot_tokens = [t.strip() for t in raw_token.split(",") if t.strip()] @@ -377,10 +417,12 @@ class SlackAdapter(BasePlatformAdapter): # First token is the primary — used for AsyncApp / Socket Mode primary_token = bot_tokens[0] self._app = AsyncApp(token=primary_token) + _apply_slack_proxy(self._app.client, proxy_url) # Register each bot token and map team_id → client for token in bot_tokens: client = AsyncWebClient(token=token) + _apply_slack_proxy(client, proxy_url) auth_response = await client.auth_test() team_id = auth_response.get("team_id", "") bot_user_id = auth_response.get("user_id", "") @@ -473,7 +515,8 @@ class SlackAdapter(BasePlatformAdapter): self._app.action(_action_id)(self._handle_approval_action) # Start Socket Mode handler in background - self._handler = AsyncSocketModeHandler(self._app, app_token) + self._handler = AsyncSocketModeHandler(self._app, app_token, proxy=proxy_url) + _apply_slack_proxy(self._handler.client, proxy_url) self._socket_mode_task = asyncio.create_task(self._handler.start_async()) self._running = True @@ -503,7 +546,7 @@ class SlackAdapter(BasePlatformAdapter): logger.info("[Slack] Disconnected") - def _get_client(self, chat_id: str) -> AsyncWebClient: + def _get_client(self, chat_id: str) -> Any: """Return the workspace-specific WebClient for a channel.""" team_id = self._channel_team.get(chat_id) if team_id and team_id in self._team_clients: diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 1fbedfcd3b..ef9897bda0 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -11,7 +11,7 @@ We mock the slack modules at import time to avoid collection errors. import asyncio import os import sys -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch, call import pytest @@ -21,6 +21,7 @@ from gateway.platforms.base import ( MessageType, SendResult, SUPPORTED_DOCUMENT_TYPES, + is_host_excluded_by_no_proxy, ) @@ -188,6 +189,198 @@ class TestSlackConnectCleanup: assert adapter._platform_lock_identity is None +# --------------------------------------------------------------------------- +# TestSlackProxyBehavior +# --------------------------------------------------------------------------- + +class TestSlackProxyBehavior: + def test_no_proxy_helper_matches_slack_hosts(self): + assert is_host_excluded_by_no_proxy("slack.com", "localhost,.slack.com") + assert is_host_excluded_by_no_proxy("files.slack.com", "localhost slack.com") + assert is_host_excluded_by_no_proxy("wss-primary.slack.com", "*") + assert not is_host_excluded_by_no_proxy("slack.com", "localhost,.internal.corp") + + def test_resolve_slack_proxy_url_ignores_unsupported_proxy_schemes(self): + with patch.object(_slack_mod, "resolve_proxy_url", return_value="socks5://proxy.example.com:1080"): + assert _slack_mod._resolve_slack_proxy_url() is None + + def test_resolve_slack_proxy_url_checks_all_slack_hosts(self): + with patch.object(_slack_mod, "resolve_proxy_url", return_value="http://proxy.example.com:3128"), \ + patch.object(_slack_mod, "is_host_excluded_by_no_proxy", side_effect=lambda host: host == "wss-primary.slack.com") as excluded: + assert _slack_mod._resolve_slack_proxy_url() is None + excluded.assert_has_calls([ + call("slack.com"), + call("files.slack.com"), + call("wss-primary.slack.com"), + ]) + + @pytest.mark.asyncio + async def test_connect_uses_proxy_when_not_bypassed(self): + created_apps = [] + created_clients = [] + + class FakeWebClient: + def __init__(self, token): + self.token = token + self.proxy = "constructor-default" + suffix = token.split("-")[-1] + self.auth_test = AsyncMock(return_value={ + "team_id": f"T_{suffix}", + "user_id": f"U_{suffix}", + "user": f"bot-{suffix}", + "team": f"Team {suffix}", + }) + created_clients.append(self) + + class FakeApp: + def __init__(self, token): + self.token = token + self.client = FakeWebClient(token) + self.registered_events = [] + self.registered_commands = [] + self.registered_actions = [] + created_apps.append(self) + + def event(self, event_type): + self.registered_events.append(event_type) + + def decorator(fn): + return fn + + return decorator + + def command(self, command_name): + self.registered_commands.append(command_name) + + def decorator(fn): + return fn + + return decorator + + def action(self, action_id): + self.registered_actions.append(action_id) + + def decorator(fn): + return fn + + return decorator + + 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="constructor-default") + + def start_async(self): + return None + + async def close_async(self): + return None + + config = PlatformConfig(enabled=True, token="xoxb-primary,xoxb-secondary") + adapter = SlackAdapter(config) + + with patch.object(_slack_mod, "AsyncApp", side_effect=FakeApp), \ + patch.object(_slack_mod, "AsyncWebClient", side_effect=FakeWebClient), \ + patch.object(_slack_mod, "AsyncSocketModeHandler", FakeSocketModeHandler), \ + patch.object(_slack_mod, "_resolve_slack_proxy_url", return_value="http://proxy.example.com:3128"), \ + patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}, clear=False), \ + patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \ + patch("asyncio.create_task", return_value=MagicMock(name="socket-mode-task")): + result = await adapter.connect() + + assert result is True + assert created_apps[0].client.proxy == "http://proxy.example.com:3128" + assert all(client.proxy == "http://proxy.example.com:3128" for client in created_clients) + assert adapter._handler is not None + assert adapter._handler.proxy == "http://proxy.example.com:3128" + assert adapter._handler.client.proxy == "http://proxy.example.com:3128" + + @pytest.mark.asyncio + async def test_connect_clears_proxy_when_no_proxy_matches_slack(self): + created_apps = [] + created_clients = [] + + class FakeWebClient: + def __init__(self, token): + self.token = token + self.proxy = "constructor-default" + suffix = token.split("-")[-1] + self.auth_test = AsyncMock(return_value={ + "team_id": f"T_{suffix}", + "user_id": f"U_{suffix}", + "user": f"bot-{suffix}", + "team": f"Team {suffix}", + }) + created_clients.append(self) + + class FakeApp: + def __init__(self, token): + self.token = token + self.client = FakeWebClient(token) + self.registered_events = [] + self.registered_commands = [] + self.registered_actions = [] + created_apps.append(self) + + def event(self, event_type): + self.registered_events.append(event_type) + + def decorator(fn): + return fn + + return decorator + + def command(self, command_name): + self.registered_commands.append(command_name) + + def decorator(fn): + return fn + + return decorator + + def action(self, action_id): + self.registered_actions.append(action_id) + + def decorator(fn): + return fn + + return decorator + + 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="constructor-default") + + def start_async(self): + return None + + async def close_async(self): + return None + + config = PlatformConfig(enabled=True, token="xoxb-primary") + adapter = SlackAdapter(config) + + with patch.object(_slack_mod, "AsyncApp", side_effect=FakeApp), \ + patch.object(_slack_mod, "AsyncWebClient", side_effect=FakeWebClient), \ + patch.object(_slack_mod, "AsyncSocketModeHandler", FakeSocketModeHandler), \ + patch.object(_slack_mod, "_resolve_slack_proxy_url", return_value=None), \ + patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}, clear=False), \ + patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \ + patch("asyncio.create_task", return_value=MagicMock(name="socket-mode-task")): + result = await adapter.connect() + + assert result is True + assert created_apps[0].client.proxy is None + assert all(client.proxy is None for client in created_clients) + assert adapter._handler is not None + assert adapter._handler.proxy is None + assert adapter._handler.client.proxy is None + + # --------------------------------------------------------------------------- # TestSendDocument # ---------------------------------------------------------------------------