fix(slack): scope app token in multiplex gateway

This commit is contained in:
Koho Zheng 2026-07-07 06:06:56 +08:00 committed by Teknium
parent 6160a80253
commit ea2c9bc10f
2 changed files with 77 additions and 6 deletions

View file

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

View file

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