# NousResearch/hermes-agent · WeChat adapter credential isolation via get_secret in multiplex gateway

## Open now

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

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #65629. Verify it addresses your case.
- **Recommended:** read PR #65629 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-65629`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member 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.

## Closed / duplicate issues

- #59662: 🐛 WeChat (weixin) adapter uses os.getenv() instead of get_secret() — credential collision in multiplex mode

## Prior & competing PRs

- **#65629: fix(gateway): multiplex credential isolation — authz, Slack, WeChat, secondary adapters (4-PR cluster salvage)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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`
  - fix hunks:
  - `gateway/authz_mixin.py`

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

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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"
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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)
    ```

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

    ```diff
    @@ -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`

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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)."""
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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 (
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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)
         "205466933+wesleion@users.noreply.github.com": "wesleion",  # PR #36049 salvage (telegram: per-topic free-response allowlist)
    ```
  - `tests/gateway/test_homeassistant.py`

    ```diff
    @@ -17,6 +17,7 @@
     from plugins.platforms.homeassistant.adapter import (
         HomeAssistantAdapter,
         check_ha_requirements,
    +    validate_ha_config,
     )
     
     
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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 (
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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__)
     
    ```

    ```diff
    @@ -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(
    ```

    ```diff
    @@ -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:
    ```
- **#59674: fix(gateway/platforms): use get_secret() for WEIXIN_TOKEN to prevent cross-profile credential leakage**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#65629`, `#59662`

## Fix status

- Merged to main in PR #65629 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-16`.
