fix(slack): honor NO_PROXY for Slack transport

This commit is contained in:
Badgerbees
2026-04-18 13:47:43 +07:00
committed by Teknium
parent 7eaad06a87
commit 55f212a7a2
3 changed files with 275 additions and 6 deletions

View File

@ -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

View File

@ -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:

View File

@ -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
# ---------------------------------------------------------------------------