WeChat adapter credential isolation via get_secret in multiplex gateway
Cluster 59662. Data as of
Nothing open on GitHub. Fix merged in #65629 on 16 Jul 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #65629. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #65629. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items center on the gateway's WeChat (weixin) adapter reading credentials with os.getenv() rather than get_secret(), which is described as risking credential collision or cross-profile leakage in multiplex mode. One member is a broader salvage of several PRs that also mentions authz, Slack, and secondary adapters, but the titles give limited detail on those parts.
Open issues0
Open PRs0
Fixes on record
-
#65629 fix(gateway): multiplex credential isolation — authz, Slack, WeChat, secondary adapters (4-PR cluster salvage)
files:
gateway/authz_mixin.py,gateway/platforms/signal.py,gateway/platforms/webhook.py,gateway/platforms/weixin.py,gateway/run.py,plugins/platforms/homeassistant/adapter.py,plugins/platforms/mattermost/adapter.py,plugins/platforms/slack/adapter.py,scripts/release.py,tests/gateway/test_homeassistant.py,tests/gateway/test_mattermost.py,tests/gateway/test_signal.py,tests/gateway/test_slack.py,tools/send_message_tool.pyShow the diff
-
gateway/authz_mixin.py@@ -28,6 +28,21 @@ ) +def _auth_env(name: str, default: str = "") -> str: + """Read allowlist/auth env; prefer profile secret_scope under multiplex.""" + if not name: + return default + try: + from agent.secret_scope import get_secret + + val = get_secret(name) + if val is not None and str(val).strip(): + return str(val).strip() + except Exception: + pass + return (os.getenv(name) or default).strip() + + class GatewayAuthorizationMixin: """User/chat authorization methods for ``GatewayRunner``.""" @@ -425,7 +440,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool: # Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true) platform_allow_all_var = platform_allow_all_map.get(source.platform, "") - if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in {"true", "1", "yes"}: + if platform_allow_all_var and _auth_env(platform_allow_all_var).lower() in {"true", "1", "yes"}: return True # Adapter-verified role auth: the Discord adapter already confirmed the @@ -456,13 +471,13 @@ def _is_user_authorized(self, source: SessionSource) -> bool: return True # Check platform-specific and global allowlists - platform_allowlist = os.getenv(platform_env_map.get(source.platform, ""), "").strip() + platform_allowlist = _auth_env(platform_env_map.get(source.platform, "")) group_user_allowlist = "" group_chat_allowlist = "" if source.chat_type in {"group", "forum"}: - group_user_allowlist = os.getenv(platform_group_user_env_map.get(source.platform, ""), "").strip() - group_chat_allowlist = os.getenv(platform_group_chat_env_map.get(source.platform, ""), "").strip() - global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip() + group_user_allowlist = _auth_env(platform_group_user_env_map.get(source.platform, "")) + group_chat_allowlist = _auth_env(platform_group_chat_env_map.get(source.platform, "")) + global_allowlist = _auth_env("GATEWAY_ALLOWED_USERS") if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist: # No env allowlist configured. Adapters that own their own @@ -511,7 +526,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool: if effective_policy == "allowlist": return True # No allowlists configured -- check global allow-all flag - return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"} + return _auth_env("GATEWAY_ALLOW_ALL_USERS").lower() in {"true", "1", "yes"} # Telegram can optionally authorize group traffic by chat ID. # Keep this separate from TELEGRAM_GROUP_ALLOWED_USERS, which gates -
gateway/platforms/signal.py@@ -236,8 +236,16 @@ def _looks_like_e164_number(value: str) -> bool: def check_signal_requirements() -> bool: - """Check if Signal is configured (has URL and account).""" - return bool(os.getenv("SIGNAL_HTTP_URL") and os.getenv("SIGNAL_ACCOUNT")) + """Check if Signal runtime dependencies are available.""" + return True + + +def validate_signal_config(config: PlatformConfig) -> bool: + """Check if Signal has enough config to connect.""" + extra = getattr(config, "extra", {}) or {} + http_url = (extra.get("http_url", "") or os.getenv("SIGNAL_HTTP_URL", "")).strip() + account = (extra.get("account", "") or os.getenv("SIGNAL_ACCOUNT", "")).strip() + return bool(http_url and account) # --------------------------------------------------------------------------- -
gateway/platforms/webhook.py@@ -1131,6 +1131,8 @@ def _resolve(match: re.Match) -> str: # Special token: dump the entire payload as JSON if key == "__raw__": return json.dumps(payload, indent=2)[:4000] + if key == "event_type": + return event_type value: Any = payload for part in key.split("."): if isinstance(value, dict): @@ -1279,7 +1281,18 @@ async def _deliver_cross_platform( success=False, error=f"Unknown platform: {platform_name}" ) + # Default adapters first; multiplex may park Slack/etc. only on a + # secondary profile (self._profile_adapters). Fall back so webhook + # deliver:slack still works when default has slack disabled. adapter = self.gateway_runner.adapters.get(target_platform) + if not adapter: + for _prof, amap in (getattr(self.gateway_runner, "_profile_adapters", None) or {}).items(): + if not isinstance(amap, dict): + continue + cand = amap.get(target_platform) + if cand is not None: + adapter = cand + break if not adapter: return SendResult( success=False, -
gateway/platforms/weixin.py@@ -68,6 +68,7 @@ ) from hermes_constants import get_hermes_home from utils import atomic_json_write +from agent.secret_scope import get_secret ILINK_BASE_URL = "https://ilinkai.weixin.qq.com" WEIXIN_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c" @@ -1159,11 +1160,11 @@ def __init__(self, config: PlatformConfig): self._poll_task: Optional[asyncio.Task] = None self._dedup = MessageDeduplicator(ttl_seconds=MESSAGE_DEDUP_TTL_SECONDS) - self._account_id = str(extra.get("account_id") or os.getenv("WEIXIN_ACCOUNT_ID", "")).strip() - self._token = [redacted:secret-assignment] or extra.get("token") or os.getenv("WEIXIN_TOKEN", "")).strip() - self._base_url = str(extra.get("base_url") or os.getenv("WEIXIN_BASE_URL", ILINK_BASE_URL)).strip().rstrip("/") + self._account_id = str(extra.get("account_id") or get_secret("WEIXIN_ACCOUNT_ID", "")).strip() + self._token = [redacted:secret-assignment] or extra.get("token") or get_secret("WEIXIN_TOKEN", "")).strip() + self._base_url = str(extra.get("base_url") or get_secret("WEIXIN_BASE_URL", ILINK_BASE_URL)).strip().rstrip("/") self._cdn_base_url = str( - extra.get("cdn_base_url") or os.getenv("WEIXIN_CDN_BASE_URL", WEIXIN_CDN_BASE_URL) + extra.get("cdn_base_url") or get_secret("WEIXIN_CDN_BASE_URL", WEIXIN_CDN_BASE_URL) ).strip().rstrip("/") self._send_chunk_delay_seconds = float( extra.get("send_chunk_delay_seconds") or os.getenv("WEIXIN_SEND_CHUNK_DELAY_SECONDS", "1.5") @@ -2292,10 +2293,10 @@ async def send_weixin_direct( This bypasses the long-poll adapter lifecycle and uses the raw API directly. """ - account_id = str(extra.get("account_id") or os.getenv("WEIXIN_ACCOUNT_ID", "")).strip() - base_url = str(extra.get("base_url") or os.getenv("WEIXIN_BASE_URL", ILINK_BASE_URL)).strip().rstrip("/") - cdn_base_url = str(extra.get("cdn_base_url") or os.getenv("WEIXIN_CDN_BASE_URL", WEIXIN_CDN_BASE_URL)).strip().rstrip("/") - resolved_token = [redacted:secret-assignment] or extra.get("token") or os.getenv("WEIXIN_TOKEN", "")).strip() + account_id = str(extra.get("account_id") or get_secret("WEIXIN_ACCOUNT_ID", "")).strip() + base_url = str(extra.get("base_url") or get_secret("WEIXIN_BASE_URL", ILINK_BASE_URL)).strip().rstrip("/") + cdn_base_url = str(extra.get("cdn_base_url") or get_secret("WEIXIN_CDN_BASE_URL", WEIXIN_CDN_BASE_URL)).strip().rstrip("/") + resolved_token = [redacted:secret-assignment] or extra.get("token") or get_secret("WEIXIN_TOKEN", "")).strip() if not resolved_token: return {"error": "Weixin token missing. Configure WEIXIN_TOKEN or platforms.weixin.token."} if not account_id: -
gateway/run.py@@ -8828,9 +8828,24 @@ async def _start_one_profile_adapters( f"'{profile_name}'s config.yaml (configure it only on the " f"default profile)." ) - with _profile_runtime_scope(profile_home): - adapter = self._create_adapter(platform, platform_config) + try: + with _profile_runtime_scope(profile_home): + adapter = self._create_adapter(platform, platform_config) + except Exception as e: + logger.error( + "[MULTIPLEX] Profile '%s': _create_adapter('%s') raised %s", + profile_name, + platform.value, + e, + exc_info=True, + ) + continue if not adapter: + logger.warning( + "[MULTIPLEX] Profile '%s': skipping platform '%s' - adapter creation returned None", + profile_name, + platform.value, + ) continue # Same-token conflict detection — refuse a duplicate poll. @@ -8877,14 +8892,31 @@ async def _start_one_profile_adapters( return connected def _make_profile_message_handler(self, profile_name: str): - """Return a message handler that stamps source.profile then delegates.""" + """Return a message handler that stamps source.profile then delegates. + + Auth runs inside ``_handle_message`` *before* the agent-turn scope is + installed. For secondary profiles under multiplex, wrap the whole + handler in ``_profile_runtime_scope`` so allowlists/tokens from that + profile's ``.env`` are visible to ``get_secret`` / authz. + """ + from hermes_cli.profiles import get_profile_dir + + try: + profile_home = get_profile_dir(profile_name) + except Exception: + profile_home = None + async def _handler(event): try: if getattr(event, "source", None) is not None and not event.source.profile: event.source.profile = profile_name except Exception: pass + if profile_home is not None: + with _profile_runtime_scope(profile_home): + return await self._handle_message(event) return await self._handle_message(event) + return _handler @staticmethod @@ -8986,8 +9018,15 @@ def _create_adapter( return WhatsAppCloudAdapter(config) elif platform == Platform.SIGNAL: - from gateway.platforms.signal import SignalAdapter, check_signal_requirements + from gateway.platforms.signal import ( + SignalAdapter, + check_signal_requirements, + validate_signal_config, + ) if not check_signal_requirements(): + logger.warning("Signal: runtime requirements not met") + return None + if not validate_signal_config(config): logger.warning("Signal: SIGNAL_HTTP_URL or SIGNAL_ACCOUNT not configured") return None return SignalAdapter(config) @@ -11813,7 +11852,39 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g if not history and source.platform and source.platform != Platform.LOCAL and source.platform != Platform.WEBHOOK: platform_name = source.platform.value env_key = _home_target_env_var(platform_name) - if not os.getenv(env_key): + # Multiplex: home channel may live only in the profile secret + # scope / PlatformConfig, not process os.environ. + home_env = "" + try: + from agent.secret_scope import get_secret + + home_env = (get_secret(env_key) or "").strip() if env_key else "" + except Exception: + home_env = "" + if not home_env: + home_env = (os.getenv(env_key) or "").strip() if env_key else "" + # Also honor in-memory / yaml home_channel on this platform. + try: + if not home_env and self.config.get_home_channel(source.platform): + home_env = "set" + except Exception: + pass + # Secondary-profile platforms (e.g. Slack on yolo) may only exist + # under that profile's loaded config — check after scope install. + if not home_env: + try: + from hermes_cli.profiles import get_profile_dir + from gateway.config import load_gateway_config as _lgc + prof = (getattr(source, "profile", None) or "").strip() + if prof and prof != "default": + # Already inside profile scope for secondary handlers; + # re-read live config for home_channel. + _pcfg = _lgc() + if _pcfg.get_home_channel(source.platform): + home_env = "set" + except Exception: + pass + if not home_env: # Slack dispatches all Hermes commands through a single # parent slash command `/hermes`; bare `/sethome` is not # registered and would fail with "app did not respond". @@ -14019,7 +14090,7 @@ async def _rename_discord_auto_thread_for_session_title( """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) -
plugins/platforms/homeassistant/adapter.py@@ -40,12 +40,14 @@ def check_ha_requirements() -> bool: - """Check if Home Assistant dependencies are available and configured.""" - if not AIOHTTP_AVAILABLE: - return False - if not os.getenv("HASS_TOKEN"): - return False - return True + """Check if Home Assistant runtime dependencies are available.""" + return AIOHTTP_AVAILABLE + + +def validate_ha_config(config: PlatformConfig) -> bool: + """Return True when Home Assistant has enough credential config to connect.""" + token = [redacted:secret-assignment] "token", None) or os.getenv("HASS_TOKEN", "")).strip() + return bool(token) class HomeAssistantAdapter(BasePlatformAdapter): @@ -560,6 +562,7 @@ def register(ctx) -> None: label="Home Assistant", adapter_factory=_build_adapter, check_fn=check_ha_requirements, + validate_config=validate_ha_config, is_connected=_is_connected, required_env=["HASS_TOKEN"], install_hint="pip install aiohttp", -
plugins/platforms/mattermost/adapter.py@@ -51,15 +51,7 @@ def check_mattermost_requirements() -> bool: - """Return True if the Mattermost adapter can be used.""" - token = os.getenv("MATTERMOST_TOKEN", "") - url = os.getenv("MATTERMOST_URL", "") - if not token: - logger.debug("Mattermost: MATTERMOST_TOKEN not set") - return False - if not url: - logger.warning("Mattermost: MATTERMOST_URL not set") - return False + """Return True if the Mattermost adapter runtime dependency is available.""" try: import aiohttp # noqa: F401 return True @@ -68,6 +60,20 @@ def check_mattermost_requirements() -> bool: return False +def validate_mattermost_config(config: PlatformConfig) -> bool: + """Return True when Mattermost has enough config to connect.""" + extra = getattr(config, "extra", {}) or {} + token = [redacted:secret-assignment] "token", None) or os.getenv("MATTERMOST_TOKEN", "")).strip() + url = (extra.get("url", "") or os.getenv("MATTERMOST_URL", "")).strip() + if not token: + logger.debug("Mattermost: MATTERMOST_TOKEN not set") + return False + if not url: + logger.warning("Mattermost: MATTERMOST_URL not set") + return False + return True + + class MattermostAdapter(BasePlatformAdapter): """Gateway adapter for Mattermost (self-hosted or cloud).""" @@ -1248,6 +1254,7 @@ def register(ctx) -> None: label="Mattermost", adapter_factory=_build_adapter, check_fn=check_mattermost_requirements, + validate_config=validate_mattermost_config, is_connected=_is_connected, required_env=["MATTERMOST_URL", "MATTERMOST_TOKEN"], install_hint="pip install aiohttp", -
plugins/platforms/slack/adapter.py@@ -36,6 +36,7 @@ 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,7 +973,17 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: return False raw_token = self.config.token - app_token = os.getenv("SLACK_APP_TOKEN") + # 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: + app_token = get_secret("SLACK_APP_TOKEN") + except UnscopedSecretError: + app_token = os.getenv("SLACK_APP_TOKEN") if not raw_token: logger.error("[Slack] SLACK_BOT_TOKEN not set") @@ -3986,18 +3997,32 @@ def _is_interactive_user_authorized( if os.getenv("SLACK_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}: return True + def _env(name: str) -> str: + # Multiplex: profile .env is in secret_scope, not process environ. + try: + from agent.secret_scope import get_secret + + val = get_secret(name) + if val is not None and str(val).strip(): + return str(val).strip() + except Exception: + pass + return (os.getenv(name) or "").strip() + allowed_ids = set() - platform_allowlist = os.getenv("SLACK_ALLOWED_USERS", "").strip() + platform_allowlist = _env("SLACK_ALLOWED_USERS") if platform_allowlist: allowed_ids.update(uid.strip() for uid in platform_allowlist.split(",") if uid.strip()) - global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip() + global_allowlist = _env("GATEWAY_ALLOWED_USERS") if global_allowlist: allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip()) if allowed_ids: return "*" in allowed_ids or normalized_user_id in allowed_ids - return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"} + if _env("SLACK_ALLOW_ALL_USERS").lower() in {"true", "1", "yes"}: + return True + return _env("GATEWAY_ALLOW_ALL_USERS").lower() in {"true", "1", "yes"} async def _handle_slash_confirm_action(self, ack, body, action) -> None: """Handle a slash-confirm button click from Block Kit.""" -
scripts/release.py@@ -45,6 +45,8 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "[redacted:email]": "aguung", # PR #64461 salvage (gateway: multiplex secret_scope for authz/Slack/webhooks) + "[redacted:email]": "jtstothard", # PR #63256 salvage (gateway: multiplex secondary adapter config validation) "[redacted:email]": "SAMBAS123", # PR #64986 salvage (gateway: multiplex primary bot token scope) "[redacted:email]": "kocaemre", # PR #36051 salvage (docs: audit round 3 code/doc reconciliation) "[email protected]": "wesleion", # PR #36049 salvage (telegram: per-topic free-response allowlist) -
tests/gateway/test_homeassistant.py@@ -17,6 +17,7 @@ from plugins.platforms.homeassistant.adapter import ( HomeAssistantAdapter, check_ha_requirements, + validate_ha_config, ) @@ -26,9 +27,9 @@ class TestCheckRequirements: - def test_returns_false_without_token(self, monkeypatch): + def test_returns_true_without_token_when_aiohttp_available(self, monkeypatch): monkeypatch.delenv("HASS_TOKEN", raising=False) - assert check_ha_requirements() is False + assert check_ha_requirements() is True def test_returns_true_with_token(self, monkeypatch): monkeypatch.setenv("HASS_TOKEN", "test-token") @@ -39,6 +40,30 @@ def test_returns_false_without_aiohttp(self, monkeypatch): monkeypatch.setenv("HASS_TOKEN", "test-token") assert check_ha_requirements() is False + def test_validate_config_accepts_platform_token(self, monkeypatch): + monkeypatch.delenv("HASS_TOKEN", raising=False) + config = PlatformConfig(enabled=True, token="[redacted:secret-assignment]") + assert validate_ha_config(config) is True + + def test_validate_config_rejects_missing_token(self, monkeypatch): + monkeypatch.delenv("HASS_TOKEN", raising=False) + config = PlatformConfig(enabled=True, token="") + assert validate_ha_config(config) is False + + +class TestValidateConfig: + def test_returns_false_without_token_in_config_or_env(self, monkeypatch): + monkeypatch.delenv("HASS_TOKEN", raising=False) + assert validate_ha_config(PlatformConfig(enabled=True)) is False + + def test_returns_true_with_token_in_env(self, monkeypatch): + monkeypatch.setenv("HASS_TOKEN", "test-token") + assert validate_ha_config(PlatformConfig(enabled=True)) is True + + def test_returns_true_with_token_in_config(self, monkeypatch): + monkeypatch.delenv("HASS_TOKEN", raising=False) + assert validate_ha_config(PlatformConfig(enabled=True, token="[redacted:secret-assignment]")) is True + # --------------------------------------------------------------------------- # _format_state_change - pure function, all domain branches -
tests/gateway/test_mattermost.py@@ -916,13 +916,32 @@ def test_check_requirements_without_token(self, monkeypatch): monkeypatch.delenv("MATTERMOST_TOKEN", raising=False) monkeypatch.delenv("MATTERMOST_URL", raising=False) from plugins.platforms.mattermost.adapter import check_mattermost_requirements - assert check_mattermost_requirements() is False + assert check_mattermost_requirements() is True def test_check_requirements_without_url(self, monkeypatch): monkeypatch.setenv("MATTERMOST_TOKEN", "test-token") monkeypatch.delenv("MATTERMOST_URL", raising=False) from plugins.platforms.mattermost.adapter import check_mattermost_requirements - assert check_mattermost_requirements() is False + assert check_mattermost_requirements() is True + + def test_validate_config_accepts_platform_values(self, monkeypatch): + monkeypatch.delenv("MATTERMOST_TOKEN", raising=False) + monkeypatch.delenv("MATTERMOST_URL", raising=False) + from plugins.platforms.mattermost.adapter import validate_mattermost_config + + config = PlatformConfig( + enabled=True, + token="[redacted:secret-assignment]", + extra={"url": "https://mm.example.com"}, + ) + assert validate_mattermost_config(config) is True + + def test_validate_config_rejects_missing_url(self, monkeypatch): + monkeypatch.delenv("MATTERMOST_URL", raising=False) + from plugins.platforms.mattermost.adapter import validate_mattermost_config + + config = PlatformConfig(enabled=True, token="[redacted:secret-assignment]", extra={}) + assert validate_mattermost_config(config) is False # --------------------------------------------------------------------------- -
tests/gateway/test_signal.py@@ -302,7 +302,29 @@ def test_check_requirements_missing(self, monkeypatch): from gateway.platforms.signal import check_signal_requirements monkeypatch.delenv("SIGNAL_HTTP_URL", raising=False) monkeypatch.delenv("SIGNAL_ACCOUNT", raising=False) - assert check_signal_requirements() is False + assert check_signal_requirements() is True + + def test_validate_signal_config_accepts_platform_values(self, monkeypatch): + monkeypatch.delenv("SIGNAL_HTTP_URL", raising=False) + monkeypatch.delenv("SIGNAL_ACCOUNT", raising=False) + from gateway.platforms.signal import validate_signal_config + + config = PlatformConfig( + enabled=True, + extra={ + "http_url": "http://localhost:8080", + "account": "+155****4567", + }, + ) + assert validate_signal_config(config) is True + + def test_validate_signal_config_rejects_missing_account(self, monkeypatch): + monkeypatch.delenv("SIGNAL_HTTP_URL", raising=False) + monkeypatch.delenv("SIGNAL_ACCOUNT", raising=False) + from gateway.platforms.signal import validate_signal_config + + config = PlatformConfig(enabled=True, extra={"http_url": "http://localhost:8080"}) + assert validate_signal_config(config) is False # --------------------------------------------------------------------------- -
tests/gateway/test_slack.py@@ -16,6 +16,7 @@ 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,139 @@ def decorator(fn): 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="[redacted:secret-assignment]") + 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 = [redacted:secret-assignment] + 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 = [redacted:secret-assignment]"SLACK_APP_TOKEN": "[redacted:secret-assignment]"}) + 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": "[redacted:secret-assignment]"}), + 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" + + @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="[redacted:secret-assignment]") + 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 = [redacted:secret-assignment] + 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": "[redacted:secret-assignment]"}), + 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.""" -
tools/send_message_tool.py@@ -15,6 +15,7 @@ from email.utils import formatdate from agent.redact import redact_sensitive_text +from agent.secret_scope import get_secret logger = logging.getLogger(__name__) @@ -409,8 +410,8 @@ def _handle_send(args): # Weixin can be configured purely via .env; synthesize a pconfig so # send_message and cron delivery work without a gateway.yaml entry. if platform_name == "weixin": - wx_token = os.getenv("WEIXIN_TOKEN", "").strip() - wx_account = os.getenv("WEIXIN_ACCOUNT_ID", "").strip() + wx_token = get_secret("WEIXIN_TOKEN", "").strip() + wx_account = get_secret("WEIXIN_ACCOUNT_ID", "").strip() if wx_token and wx_account: from gateway.config import PlatformConfig pconfig = PlatformConfig( @@ -418,8 +419,8 @@ def _handle_send(args): token=[redacted:secret-assignment] extra={ "account_id": wx_account, - "base_url": os.getenv("WEIXIN_BASE_URL", "").strip(), - "cdn_base_url": os.getenv("WEIXIN_CDN_BASE_URL", "").strip(), + "base_url": get_secret("WEIXIN_BASE_URL", "").strip(), + "cdn_base_url": get_secret("WEIXIN_CDN_BASE_URL", "").strip(), }, ) else:
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#59674 fix(gateway/platforms): use get_secret() for WEIXIN_TOKEN to prevent cross-profile credential leakage · merged via another change →
#65629,#59662
Closed issues · 1
- Closed#59662🐛 WeChat (weixin) adapter uses os.getenv() instead of get_secret() — credential collision in multiplex mode
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Merged PRs
- #65629
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 16 Jul 2026