From 01d3268e02821511691227d3b91f5c3ac65873f8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:12:27 -0700 Subject: [PATCH] fix(gateway): harden multiplex credential cluster salvage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on top of the cherry-picked cluster commits: - slack: scope-authoritative app-token read — get_secret() with a narrow UnscopedSecretError fallback to os.getenv. Keeps @kohoj's correct semantics (scoped profile can never silently inherit the default profile's Socket Mode app) while fixing the regression where the default-profile startup loop and background reconnect rebuild, which call connect() unscoped under multiplex, would raise and fail-loop. Supersedes the 'or os.getenv' variant from #64461 which reintroduced the cross-profile fallback leak. - test: unscoped-multiplex fallback regression test for connect(). - run.py: convert the last legacy self.adapters.get(source.platform) site (_rename_discord_auto_thread) to _adapter_for_source(source) so profile-routed Discord sources rename threads on the right adapter (from #57417's sweep). - AUTHOR_MAP entry for @aguung. --- gateway/run.py | 2 +- scripts/release.py | 1 + tests/gateway/test_slack.py | 67 +++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/gateway/run.py b/gateway/run.py index 2066f4dbb..1fb3cd663 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -14090,7 +14090,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew """Best-effort semantic rename of a newly auto-created Discord thread.""" if not await asyncio.to_thread(self._is_discord_auto_thread_lane, source): return - adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None + adapter = self._adapter_for_source(source) if getattr(self, "adapters", None) else None if adapter is None: return rename_thread = getattr(adapter, "rename_thread", None) diff --git a/scripts/release.py b/scripts/release.py index e0aae9d24..78ade2e9d 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "agungsubastian1963@gmail.com": "aguung", # PR #64461 salvage (gateway: multiplex secret_scope for authz/Slack/webhooks) "doogie@spark.local": "SAMBAS123", # PR #64986 salvage (gateway: multiplex primary bot token scope) "emrekoca2003@gmail.com": "kocaemre", # PR #36051 salvage (docs: audit round 3 code/doc reconciliation) "205466933+wesleion@users.noreply.github.com": "wesleion", # PR #36049 salvage (telegram: per-topic free-response allowlist) diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index ae7cc1f51..8a351dc7f 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -330,6 +330,73 @@ class TestAppMentionHandler: assert created_handlers assert created_handlers[0].app_token == "xapp-profile" + @pytest.mark.asyncio + async def test_connect_unscoped_multiplex_falls_back_to_env(self): + """Default-profile connect (multiplex active, NO scope installed) must + fall back to process env instead of raising UnscopedSecretError — + the primary startup loop and background reconnect rebuild both call + connect() unscoped (#59739 salvage follow-up).""" + config = PlatformConfig(enabled=True, token="xoxb-default") + 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_DEFAULT", + "user": "defaultbot", + "team_id": "T_DEFAULT", + "team": "DefaultTeam", + } + ) + + 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) + 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.set_multiplex_active(False) + + assert result is True + assert created_handlers + assert created_handlers[0].app_token == "xapp-default" + class TestSlackConnectCleanup: """Regression coverage for failed connect() cleanup."""