fix(slack): scope app token in multiplex gateway
parent
6160a80253
commit
ea2c9bc10f
|
|
@ -36,6 +36,7 @@ from pathlib import Path as _Path
|
||||||
|
|
||||||
sys.path.insert(0, str(_Path(__file__).resolve().parents[3]))
|
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.config import Platform, PlatformConfig
|
||||||
from gateway.platforms.helpers import MessageDeduplicator
|
from gateway.platforms.helpers import MessageDeduplicator
|
||||||
from gateway.platforms.base import (
|
from gateway.platforms.base import (
|
||||||
|
|
@ -972,13 +973,16 @@ class SlackAdapter(BasePlatformAdapter):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
raw_token = self.config.token
|
raw_token = self.config.token
|
||||||
# Multiplex: profile secrets live in secret_scope, not process os.environ.
|
# Multiplex: profile secrets live in the secret scope, not process
|
||||||
# Prefer get_secret; fall back to os.getenv for single-profile / tests.
|
# 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:
|
try:
|
||||||
from agent.secret_scope import get_secret
|
app_token = get_secret("SLACK_APP_TOKEN")
|
||||||
|
except UnscopedSecretError:
|
||||||
app_token = get_secret("SLACK_APP_TOKEN") or os.getenv("SLACK_APP_TOKEN")
|
|
||||||
except Exception:
|
|
||||||
app_token = os.getenv("SLACK_APP_TOKEN")
|
app_token = os.getenv("SLACK_APP_TOKEN")
|
||||||
|
|
||||||
if not raw_token:
|
if not raw_token:
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from unittest.mock import AsyncMock, MagicMock, patch, call
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import agent.secret_scope as secret_scope
|
||||||
from gateway.config import Platform, PlatformConfig
|
from gateway.config import Platform, PlatformConfig
|
||||||
from gateway.run import GatewayRunner
|
from gateway.run import GatewayRunner
|
||||||
from gateway.platforms.base import (
|
from gateway.platforms.base import (
|
||||||
|
|
@ -263,6 +264,72 @@ class TestAppMentionHandler:
|
||||||
expected
|
expected
|
||||||
), f"Slack slash regex does not match {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:
|
class TestSlackConnectCleanup:
|
||||||
"""Regression coverage for failed connect() cleanup."""
|
"""Regression coverage for failed connect() cleanup."""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue