NousResearch/hermes-agent

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

0open issues
0open PRs
3items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #65629 fix(gateway): multiplex credential isolation — authz, Slack, WeChat, secondary adapters (4-PR cluster salvage)

    merged to main

    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.py

    Show 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.00 means 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 Lab

At a glance

Merged PRs
#65629
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
16 Jul 2026

For your agent

The same facts, typed.