{"repo":"NousResearch/hermes-agent","public_id":"59662","display_label":"WeChat adapter credential isolation via get_secret in multiplex gateway","summary":"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.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":65629,"merged_at":"2026-07-16T12:39:58+00:00"},"resolution":{"state":"resolved","by":"pr-65629"},"freshness":{"state":"verified","last_verified_at":"2026-07-16T12:39:58+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":59662,"pr_number":65629,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-16T12:39:58+00:00"},{"issue_number":59739,"pr_number":65629,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-16T12:39:58+00:00"}],"members":[{"kind":"issue","number":59662,"title":"🐛 WeChat (weixin) adapter uses os.getenv() instead of get_secret() — credential collision in multiplex mode","url":null,"state":"closed"},{"kind":"pr","number":59674,"title":"fix(gateway/platforms): use get_secret() for WEIXIN_TOKEN to prevent cross-profile credential leakage","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65629","number":65629},{"kind":"pr","value":"#59662","number":59662}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":65629,"title":"fix(gateway): multiplex credential isolation — authz, Slack, WeChat, secondary adapters (4-PR cluster salvage)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"gateway/authz_mixin.py","hunks":["@@ -28,6 +28,21 @@\n )\n \n \n+def _auth_env(name: str, default: str = \"\") -> str:\n+    \"\"\"Read allowlist/auth env; prefer profile secret_scope under multiplex.\"\"\"\n+    if not name:\n+        return default\n+    try:\n+        from agent.secret_scope import get_secret\n+\n+        val = get_secret(name)\n+        if val is not None and str(val).strip():\n+            return str(val).strip()\n+    except Exception:\n+        pass\n+    return (os.getenv(name) or default).strip()\n+\n+\n class GatewayAuthorizationMixin:\n     \"\"\"User/chat authorization methods for ``GatewayRunner``.\"\"\"\n ","@@ -425,7 +440,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool:\n \n         # Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)\n         platform_allow_all_var = platform_allow_all_map.get(source.platform, \"\")\n-        if platform_allow_all_var and os.getenv(platform_allow_all_var, \"\").lower() in {\"true\", \"1\", \"yes\"}:\n+        if platform_allow_all_var and _auth_env(platform_allow_all_var).lower() in {\"true\", \"1\", \"yes\"}:\n             return True\n \n         # Adapter-verified role auth: the Discord adapter already confirmed the","@@ -456,13 +471,13 @@ def _is_user_authorized(self, source: SessionSource) -> bool:\n             return True\n \n         # Check platform-specific and global allowlists\n-        platform_allowlist = os.getenv(platform_env_map.get(source.platform, \"\"), \"\").strip()\n+        platform_allowlist = _auth_env(platform_env_map.get(source.platform, \"\"))\n         group_user_allowlist = \"\"\n         group_chat_allowlist = \"\"\n         if source.chat_type in {\"group\", \"forum\"}:\n-            group_user_allowlist = os.getenv(platform_group_user_env_map.get(source.platform, \"\"), \"\").strip()\n-            group_chat_allowlist = os.getenv(platform_group_chat_env_map.get(source.platform, \"\"), \"\").strip()\n-        global_allowlist = os.getenv(\"GATEWAY_ALLOWED_USERS\", \"\").strip()\n+            group_user_allowlist = _auth_env(platform_group_user_env_map.get(source.platform, \"\"))\n+            group_chat_allowlist = _auth_env(platform_group_chat_env_map.get(source.platform, \"\"))\n+        global_allowlist = _auth_env(\"GATEWAY_ALLOWED_USERS\")\n \n         if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist:\n             # No env allowlist configured. Adapters that own their own","@@ -511,7 +526,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool:\n                 if effective_policy == \"allowlist\":\n                     return True\n             # No allowlists configured -- check global allow-all flag\n-            return os.getenv(\"GATEWAY_ALLOW_ALL_USERS\", \"\").lower() in {\"true\", \"1\", \"yes\"}\n+            return _auth_env(\"GATEWAY_ALLOW_ALL_USERS\").lower() in {\"true\", \"1\", \"yes\"}\n \n         # Telegram can optionally authorize group traffic by chat ID.\n         # Keep this separate from TELEGRAM_GROUP_ALLOWED_USERS, which gates"]},{"file":"gateway/platforms/signal.py","hunks":["@@ -236,8 +236,16 @@ def _looks_like_e164_number(value: str) -> bool:\n \n \n def check_signal_requirements() -> bool:\n-    \"\"\"Check if Signal is configured (has URL and account).\"\"\"\n-    return bool(os.getenv(\"SIGNAL_HTTP_URL\") and os.getenv(\"SIGNAL_ACCOUNT\"))\n+    \"\"\"Check if Signal runtime dependencies are available.\"\"\"\n+    return True\n+\n+\n+def validate_signal_config(config: PlatformConfig) -> bool:\n+    \"\"\"Check if Signal has enough config to connect.\"\"\"\n+    extra = getattr(config, \"extra\", {}) or {}\n+    http_url = (extra.get(\"http_url\", \"\") or os.getenv(\"SIGNAL_HTTP_URL\", \"\")).strip()\n+    account = (extra.get(\"account\", \"\") or os.getenv(\"SIGNAL_ACCOUNT\", \"\")).strip()\n+    return bool(http_url and account)\n \n \n # ---------------------------------------------------------------------------"]},{"file":"gateway/platforms/webhook.py","hunks":["@@ -1131,6 +1131,8 @@ def _resolve(match: re.Match) -> str:\n             # Special token: dump the entire payload as JSON\n             if key == \"__raw__\":\n                 return json.dumps(payload, indent=2)[:4000]\n+            if key == \"event_type\":\n+                return event_type\n             value: Any = payload\n             for part in key.split(\".\"):\n                 if isinstance(value, dict):","@@ -1279,7 +1281,18 @@ async def _deliver_cross_platform(\n                 success=False, error=f\"Unknown platform: {platform_name}\"\n             )\n \n+        # Default adapters first; multiplex may park Slack/etc. only on a\n+        # secondary profile (self._profile_adapters). Fall back so webhook\n+        # deliver:slack still works when default has slack disabled.\n         adapter = self.gateway_runner.adapters.get(target_platform)\n+        if not adapter:\n+            for _prof, amap in (getattr(self.gateway_runner, \"_profile_adapters\", None) or {}).items():\n+                if not isinstance(amap, dict):\n+                    continue\n+                cand = amap.get(target_platform)\n+                if cand is not None:\n+                    adapter = cand\n+                    break\n         if not adapter:\n             return SendResult(\n                 success=False,"]},{"file":"gateway/platforms/weixin.py","hunks":["@@ -68,6 +68,7 @@\n )\n from hermes_constants import get_hermes_home\n from utils import atomic_json_write\n+from agent.secret_scope import get_secret\n \n ILINK_BASE_URL = \"https://ilinkai.weixin.qq.com\"\n WEIXIN_CDN_BASE_URL = \"https://novac2c.cdn.weixin.qq.com/c2c\"","@@ -1159,11 +1160,11 @@ def __init__(self, config: PlatformConfig):\n         self._poll_task: Optional[asyncio.Task] = None\n         self._dedup = MessageDeduplicator(ttl_seconds=MESSAGE_DEDUP_TTL_SECONDS)\n \n-        self._account_id = str(extra.get(\"account_id\") or os.getenv(\"WEIXIN_ACCOUNT_ID\", \"\")).strip()\n-        self._token = [redacted:secret-assignment] or extra.get(\"token\") or os.getenv(\"WEIXIN_TOKEN\", \"\")).strip()\n-        self._base_url = str(extra.get(\"base_url\") or os.getenv(\"WEIXIN_BASE_URL\", ILINK_BASE_URL)).strip().rstrip(\"/\")\n+        self._account_id = str(extra.get(\"account_id\") or get_secret(\"WEIXIN_ACCOUNT_ID\", \"\")).strip()\n+        self._token = [redacted:secret-assignment] or extra.get(\"token\") or get_secret(\"WEIXIN_TOKEN\", \"\")).strip()\n+        self._base_url = str(extra.get(\"base_url\") or get_secret(\"WEIXIN_BASE_URL\", ILINK_BASE_URL)).strip().rstrip(\"/\")\n         self._cdn_base_url = str(\n-            extra.get(\"cdn_base_url\") or os.getenv(\"WEIXIN_CDN_BASE_URL\", WEIXIN_CDN_BASE_URL)\n+            extra.get(\"cdn_base_url\") or get_secret(\"WEIXIN_CDN_BASE_URL\", WEIXIN_CDN_BASE_URL)\n         ).strip().rstrip(\"/\")\n         self._send_chunk_delay_seconds = float(\n             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(\n \n     This bypasses the long-poll adapter lifecycle and uses the raw API directly.\n     \"\"\"\n-    account_id = str(extra.get(\"account_id\") or os.getenv(\"WEIXIN_ACCOUNT_ID\", \"\")).strip()\n-    base_url = str(extra.get(\"base_url\") or os.getenv(\"WEIXIN_BASE_URL\", ILINK_BASE_URL)).strip().rstrip(\"/\")\n-    cdn_base_url = str(extra.get(\"cdn_base_url\") or os.getenv(\"WEIXIN_CDN_BASE_URL\", WEIXIN_CDN_BASE_URL)).strip().rstrip(\"/\")\n-    resolved_token = [redacted:secret-assignment] or extra.get(\"token\") or os.getenv(\"WEIXIN_TOKEN\", \"\")).strip()\n+    account_id = str(extra.get(\"account_id\") or get_secret(\"WEIXIN_ACCOUNT_ID\", \"\")).strip()\n+    base_url = str(extra.get(\"base_url\") or get_secret(\"WEIXIN_BASE_URL\", ILINK_BASE_URL)).strip().rstrip(\"/\")\n+    cdn_base_url = str(extra.get(\"cdn_base_url\") or get_secret(\"WEIXIN_CDN_BASE_URL\", WEIXIN_CDN_BASE_URL)).strip().rstrip(\"/\")\n+    resolved_token = [redacted:secret-assignment] or extra.get(\"token\") or get_secret(\"WEIXIN_TOKEN\", \"\")).strip()\n     if not resolved_token:\n         return {\"error\": \"Weixin token missing. Configure WEIXIN_TOKEN or platforms.weixin.token.\"}\n     if not account_id:"]},{"file":"gateway/run.py","hunks":["@@ -8828,9 +8828,24 @@ async def _start_one_profile_adapters(\n                     f\"'{profile_name}'s config.yaml (configure it only on the \"\n                     f\"default profile).\"\n                 )\n-            with _profile_runtime_scope(profile_home):\n-                adapter = self._create_adapter(platform, platform_config)\n+            try:\n+                with _profile_runtime_scope(profile_home):\n+                    adapter = self._create_adapter(platform, platform_config)\n+            except Exception as e:\n+                logger.error(\n+                    \"[MULTIPLEX] Profile '%s': _create_adapter('%s') raised %s\",\n+                    profile_name,\n+                    platform.value,\n+                    e,\n+                    exc_info=True,\n+                )\n+                continue\n             if not adapter:\n+                logger.warning(\n+                    \"[MULTIPLEX] Profile '%s': skipping platform '%s' - adapter creation returned None\",\n+                    profile_name,\n+                    platform.value,\n+                )\n                 continue\n \n             # Same-token conflict detection — refuse a duplicate poll.","@@ -8877,14 +8892,31 @@ async def _start_one_profile_adapters(\n         return connected\n \n     def _make_profile_message_handler(self, profile_name: str):\n-        \"\"\"Return a message handler that stamps source.profile then delegates.\"\"\"\n+        \"\"\"Return a message handler that stamps source.profile then delegates.\n+\n+        Auth runs inside ``_handle_message`` *before* the agent-turn scope is\n+        installed. For secondary profiles under multiplex, wrap the whole\n+        handler in ``_profile_runtime_scope`` so allowlists/tokens from that\n+        profile's ``.env`` are visible to ``get_secret`` / authz.\n+        \"\"\"\n+        from hermes_cli.profiles import get_profile_dir\n+\n+        try:\n+            profile_home = get_profile_dir(profile_name)\n+        except Exception:\n+            profile_home = None\n+\n         async def _handler(event):\n             try:\n                 if getattr(event, \"source\", None) is not None and not event.source.profile:\n                     event.source.profile = profile_name\n             except Exception:\n                 pass\n+            if profile_home is not None:\n+                with _profile_runtime_scope(profile_home):\n+                    return await self._handle_message(event)\n             return await self._handle_message(event)\n+\n         return _handler\n \n     @staticmethod","@@ -8986,8 +9018,15 @@ def _create_adapter(\n             return WhatsAppCloudAdapter(config)\n         \n         elif platform == Platform.SIGNAL:\n-            from gateway.platforms.signal import SignalAdapter, check_signal_requirements\n+            from gateway.platforms.signal import (\n+                SignalAdapter,\n+                check_signal_requirements,\n+                validate_signal_config,\n+            )\n             if not check_signal_requirements():\n+                logger.warning(\"Signal: runtime requirements not met\")\n+                return None\n+            if not validate_signal_config(config):\n                 logger.warning(\"Signal: SIGNAL_HTTP_URL or SIGNAL_ACCOUNT not configured\")\n                 return None\n             return SignalAdapter(config)","@@ -11813,7 +11852,39 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n         if not history and source.platform and source.platform != Platform.LOCAL and source.platform != Platform.WEBHOOK:\n             platform_name = source.platform.value\n             env_key = _home_target_env_var(platform_name)\n-            if not os.getenv(env_key):\n+            # Multiplex: home channel may live only in the profile secret\n+            # scope / PlatformConfig, not process os.environ.\n+            home_env = \"\"\n+            try:\n+                from agent.secret_scope import get_secret\n+\n+                home_env = (get_secret(env_key) or \"\").strip() if env_key else \"\"\n+            except Exception:\n+                home_env = \"\"\n+            if not home_env:\n+                home_env = (os.getenv(env_key) or \"\").strip() if env_key else \"\"\n+            # Also honor in-memory / yaml home_channel on this platform.\n+            try:\n+                if not home_env and self.config.get_home_channel(source.platform):\n+                    home_env = \"set\"\n+            except Exception:\n+                pass\n+            # Secondary-profile platforms (e.g. Slack on yolo) may only exist\n+            # under that profile's loaded config — check after scope install.\n+            if not home_env:\n+                try:\n+                    from hermes_cli.profiles import get_profile_dir\n+                    from gateway.config import load_gateway_config as _lgc\n+                    prof = (getattr(source, \"profile\", None) or \"\").strip()\n+                    if prof and prof != \"default\":\n+                        # Already inside profile scope for secondary handlers;\n+                        # re-read live config for home_channel.\n+                        _pcfg = _lgc()\n+                        if _pcfg.get_home_channel(source.platform):\n+                            home_env = \"set\"\n+                except Exception:\n+                    pass\n+            if not home_env:\n                 # Slack dispatches all Hermes commands through a single\n                 # parent slash command `/hermes`; bare `/sethome` is not\n                 # registered and would fail with \"app did not respond\".","@@ -14019,7 +14090,7 @@ async def _rename_discord_auto_thread_for_session_title(\n         \"\"\"Best-effort semantic rename of a newly auto-created Discord thread.\"\"\"\n         if not await asyncio.to_thread(self._is_discord_auto_thread_lane, source):\n             return\n-        adapter = self.adapters.get(source.platform) if getattr(self, \"adapters\", None) else None\n+        adapter = self._adapter_for_source(source) if getattr(self, \"adapters\", None) else None\n         if adapter is None:\n             return\n         rename_thread = getattr(adapter, \"rename_thread\", None)"]},{"file":"plugins/platforms/homeassistant/adapter.py","hunks":["@@ -40,12 +40,14 @@\n \n \n def check_ha_requirements() -> bool:\n-    \"\"\"Check if Home Assistant dependencies are available and configured.\"\"\"\n-    if not AIOHTTP_AVAILABLE:\n-        return False\n-    if not os.getenv(\"HASS_TOKEN\"):\n-        return False\n-    return True\n+    \"\"\"Check if Home Assistant runtime dependencies are available.\"\"\"\n+    return AIOHTTP_AVAILABLE\n+\n+\n+def validate_ha_config(config: PlatformConfig) -> bool:\n+    \"\"\"Return True when Home Assistant has enough credential config to connect.\"\"\"\n+    token = [redacted:secret-assignment] \"token\", None) or os.getenv(\"HASS_TOKEN\", \"\")).strip()\n+    return bool(token)\n \n \n class HomeAssistantAdapter(BasePlatformAdapter):","@@ -560,6 +562,7 @@ def register(ctx) -> None:\n         label=\"Home Assistant\",\n         adapter_factory=_build_adapter,\n         check_fn=check_ha_requirements,\n+        validate_config=validate_ha_config,\n         is_connected=_is_connected,\n         required_env=[\"HASS_TOKEN\"],\n         install_hint=\"pip install aiohttp\","]},{"file":"plugins/platforms/mattermost/adapter.py","hunks":["@@ -51,15 +51,7 @@\n \n \n def check_mattermost_requirements() -> bool:\n-    \"\"\"Return True if the Mattermost adapter can be used.\"\"\"\n-    token = os.getenv(\"MATTERMOST_TOKEN\", \"\")\n-    url = os.getenv(\"MATTERMOST_URL\", \"\")\n-    if not token:\n-        logger.debug(\"Mattermost: MATTERMOST_TOKEN not set\")\n-        return False\n-    if not url:\n-        logger.warning(\"Mattermost: MATTERMOST_URL not set\")\n-        return False\n+    \"\"\"Return True if the Mattermost adapter runtime dependency is available.\"\"\"\n     try:\n         import aiohttp  # noqa: F401\n         return True","@@ -68,6 +60,20 @@ def check_mattermost_requirements() -> bool:\n         return False\n \n \n+def validate_mattermost_config(config: PlatformConfig) -> bool:\n+    \"\"\"Return True when Mattermost has enough config to connect.\"\"\"\n+    extra = getattr(config, \"extra\", {}) or {}\n+    token = [redacted:secret-assignment] \"token\", None) or os.getenv(\"MATTERMOST_TOKEN\", \"\")).strip()\n+    url = (extra.get(\"url\", \"\") or os.getenv(\"MATTERMOST_URL\", \"\")).strip()\n+    if not token:\n+        logger.debug(\"Mattermost: MATTERMOST_TOKEN not set\")\n+        return False\n+    if not url:\n+        logger.warning(\"Mattermost: MATTERMOST_URL not set\")\n+        return False\n+    return True\n+\n+\n class MattermostAdapter(BasePlatformAdapter):\n     \"\"\"Gateway adapter for Mattermost (self-hosted or cloud).\"\"\"\n ","@@ -1248,6 +1254,7 @@ def register(ctx) -> None:\n         label=\"Mattermost\",\n         adapter_factory=_build_adapter,\n         check_fn=check_mattermost_requirements,\n+        validate_config=validate_mattermost_config,\n         is_connected=_is_connected,\n         required_env=[\"MATTERMOST_URL\", \"MATTERMOST_TOKEN\"],\n         install_hint=\"pip install aiohttp\","]},{"file":"plugins/platforms/slack/adapter.py","hunks":["@@ -36,6 +36,7 @@\n \n sys.path.insert(0, str(_Path(__file__).resolve().parents[3]))\n \n+from agent.secret_scope import UnscopedSecretError, get_secret\n from gateway.config import Platform, PlatformConfig\n from gateway.platforms.helpers import MessageDeduplicator\n from gateway.platforms.base import (","@@ -972,7 +973,17 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:\n             return False\n \n         raw_token = self.config.token\n-        app_token = os.getenv(\"SLACK_APP_TOKEN\")\n+        # Multiplex: profile secrets live in the secret scope, not process\n+        # os.environ. When a scope is installed (secondary-profile connect),\n+        # it is AUTHORITATIVE — do not fall through to os.getenv, or a\n+        # secondary profile missing SLACK_APP_TOKEN silently inherits the\n+        # default profile's Socket Mode app (#59739). Only an UNSCOPED read\n+        # under multiplex (default-profile startup loop, background reconnect\n+        # rebuild) falls back to process env, which is that profile's own.\n+        try:\n+            app_token = get_secret(\"SLACK_APP_TOKEN\")\n+        except UnscopedSecretError:\n+            app_token = os.getenv(\"SLACK_APP_TOKEN\")\n \n         if not raw_token:\n             logger.error(\"[Slack] SLACK_BOT_TOKEN not set\")","@@ -3986,18 +3997,32 @@ def _is_interactive_user_authorized(\n         if os.getenv(\"SLACK_ALLOW_ALL_USERS\", \"\").lower() in {\"true\", \"1\", \"yes\"}:\n             return True\n \n+        def _env(name: str) -> str:\n+            # Multiplex: profile .env is in secret_scope, not process environ.\n+            try:\n+                from agent.secret_scope import get_secret\n+\n+                val = get_secret(name)\n+                if val is not None and str(val).strip():\n+                    return str(val).strip()\n+            except Exception:\n+                pass\n+            return (os.getenv(name) or \"\").strip()\n+\n         allowed_ids = set()\n-        platform_allowlist = os.getenv(\"SLACK_ALLOWED_USERS\", \"\").strip()\n+        platform_allowlist = _env(\"SLACK_ALLOWED_USERS\")\n         if platform_allowlist:\n             allowed_ids.update(uid.strip() for uid in platform_allowlist.split(\",\") if uid.strip())\n-        global_allowlist = os.getenv(\"GATEWAY_ALLOWED_USERS\", \"\").strip()\n+        global_allowlist = _env(\"GATEWAY_ALLOWED_USERS\")\n         if global_allowlist:\n             allowed_ids.update(uid.strip() for uid in global_allowlist.split(\",\") if uid.strip())\n \n         if allowed_ids:\n             return \"*\" in allowed_ids or normalized_user_id in allowed_ids\n \n-        return os.getenv(\"GATEWAY_ALLOW_ALL_USERS\", \"\").lower() in {\"true\", \"1\", \"yes\"}\n+        if _env(\"SLACK_ALLOW_ALL_USERS\").lower() in {\"true\", \"1\", \"yes\"}:\n+            return True\n+        return _env(\"GATEWAY_ALLOW_ALL_USERS\").lower() in {\"true\", \"1\", \"yes\"}\n \n     async def _handle_slash_confirm_action(self, ack, body, action) -> None:\n         \"\"\"Handle a slash-confirm button click from Block Kit.\"\"\""]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,8 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"aguung\",  # PR #64461 salvage (gateway: multiplex secret_scope for authz/Slack/webhooks)\n+    \"[redacted:email]\": \"jtstothard\",  # PR #63256 salvage (gateway: multiplex secondary adapter config validation)\n     \"[redacted:email]\": \"SAMBAS123\",  # PR #64986 salvage (gateway: multiplex primary bot token scope)\n     \"[redacted:email]\": \"kocaemre\",  # PR #36051 salvage (docs: audit round 3 code/doc reconciliation)\n     \"205466933+wesleion@users.noreply.github.com\": \"wesleion\",  # PR #36049 salvage (telegram: per-topic free-response allowlist)"]},{"file":"tests/gateway/test_homeassistant.py","hunks":["@@ -17,6 +17,7 @@\n from plugins.platforms.homeassistant.adapter import (\n     HomeAssistantAdapter,\n     check_ha_requirements,\n+    validate_ha_config,\n )\n \n ","@@ -26,9 +27,9 @@\n \n \n class TestCheckRequirements:\n-    def test_returns_false_without_token(self, monkeypatch):\n+    def test_returns_true_without_token_when_aiohttp_available(self, monkeypatch):\n         monkeypatch.delenv(\"HASS_TOKEN\", raising=False)\n-        assert check_ha_requirements() is False\n+        assert check_ha_requirements() is True\n \n     def test_returns_true_with_token(self, monkeypatch):\n         monkeypatch.setenv(\"HASS_TOKEN\", \"test-token\")","@@ -39,6 +40,30 @@ def test_returns_false_without_aiohttp(self, monkeypatch):\n         monkeypatch.setenv(\"HASS_TOKEN\", \"test-token\")\n         assert check_ha_requirements() is False\n \n+    def test_validate_config_accepts_platform_token(self, monkeypatch):\n+        monkeypatch.delenv(\"HASS_TOKEN\", raising=False)\n+        config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n+        assert validate_ha_config(config) is True\n+\n+    def test_validate_config_rejects_missing_token(self, monkeypatch):\n+        monkeypatch.delenv(\"HASS_TOKEN\", raising=False)\n+        config = PlatformConfig(enabled=True, token=\"\")\n+        assert validate_ha_config(config) is False\n+\n+\n+class TestValidateConfig:\n+    def test_returns_false_without_token_in_config_or_env(self, monkeypatch):\n+        monkeypatch.delenv(\"HASS_TOKEN\", raising=False)\n+        assert validate_ha_config(PlatformConfig(enabled=True)) is False\n+\n+    def test_returns_true_with_token_in_env(self, monkeypatch):\n+        monkeypatch.setenv(\"HASS_TOKEN\", \"test-token\")\n+        assert validate_ha_config(PlatformConfig(enabled=True)) is True\n+\n+    def test_returns_true_with_token_in_config(self, monkeypatch):\n+        monkeypatch.delenv(\"HASS_TOKEN\", raising=False)\n+        assert validate_ha_config(PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")) is True\n+\n \n # ---------------------------------------------------------------------------\n # _format_state_change - pure function, all domain branches"]},{"file":"tests/gateway/test_mattermost.py","hunks":["@@ -916,13 +916,32 @@ def test_check_requirements_without_token(self, monkeypatch):\n         monkeypatch.delenv(\"MATTERMOST_TOKEN\", raising=False)\n         monkeypatch.delenv(\"MATTERMOST_URL\", raising=False)\n         from plugins.platforms.mattermost.adapter import check_mattermost_requirements\n-        assert check_mattermost_requirements() is False\n+        assert check_mattermost_requirements() is True\n \n     def test_check_requirements_without_url(self, monkeypatch):\n         monkeypatch.setenv(\"MATTERMOST_TOKEN\", \"test-token\")\n         monkeypatch.delenv(\"MATTERMOST_URL\", raising=False)\n         from plugins.platforms.mattermost.adapter import check_mattermost_requirements\n-        assert check_mattermost_requirements() is False\n+        assert check_mattermost_requirements() is True\n+\n+    def test_validate_config_accepts_platform_values(self, monkeypatch):\n+        monkeypatch.delenv(\"MATTERMOST_TOKEN\", raising=False)\n+        monkeypatch.delenv(\"MATTERMOST_URL\", raising=False)\n+        from plugins.platforms.mattermost.adapter import validate_mattermost_config\n+\n+        config = PlatformConfig(\n+            enabled=True,\n+            token=\"[redacted:secret-assignment]\",\n+            extra={\"url\": \"https://mm.example.com\"},\n+        )\n+        assert validate_mattermost_config(config) is True\n+\n+    def test_validate_config_rejects_missing_url(self, monkeypatch):\n+        monkeypatch.delenv(\"MATTERMOST_URL\", raising=False)\n+        from plugins.platforms.mattermost.adapter import validate_mattermost_config\n+\n+        config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\", extra={})\n+        assert validate_mattermost_config(config) is False\n \n \n # ---------------------------------------------------------------------------"]},{"file":"tests/gateway/test_signal.py","hunks":["@@ -302,7 +302,29 @@ def test_check_requirements_missing(self, monkeypatch):\n         from gateway.platforms.signal import check_signal_requirements\n         monkeypatch.delenv(\"SIGNAL_HTTP_URL\", raising=False)\n         monkeypatch.delenv(\"SIGNAL_ACCOUNT\", raising=False)\n-        assert check_signal_requirements() is False\n+        assert check_signal_requirements() is True\n+\n+    def test_validate_signal_config_accepts_platform_values(self, monkeypatch):\n+        monkeypatch.delenv(\"SIGNAL_HTTP_URL\", raising=False)\n+        monkeypatch.delenv(\"SIGNAL_ACCOUNT\", raising=False)\n+        from gateway.platforms.signal import validate_signal_config\n+\n+        config = PlatformConfig(\n+            enabled=True,\n+            extra={\n+                \"http_url\": \"http://localhost:8080\",\n+                \"account\": \"+155****4567\",\n+            },\n+        )\n+        assert validate_signal_config(config) is True\n+\n+    def test_validate_signal_config_rejects_missing_account(self, monkeypatch):\n+        monkeypatch.delenv(\"SIGNAL_HTTP_URL\", raising=False)\n+        monkeypatch.delenv(\"SIGNAL_ACCOUNT\", raising=False)\n+        from gateway.platforms.signal import validate_signal_config\n+\n+        config = PlatformConfig(enabled=True, extra={\"http_url\": \"http://localhost:8080\"})\n+        assert validate_signal_config(config) is False\n \n \n # ---------------------------------------------------------------------------"]},{"file":"tests/gateway/test_slack.py","hunks":["@@ -16,6 +16,7 @@\n \n import pytest\n \n+import agent.secret_scope as secret_scope\n from gateway.config import Platform, PlatformConfig\n from gateway.run import GatewayRunner\n from gateway.platforms.base import (","@@ -263,6 +264,139 @@ def decorator(fn):\n                 expected\n             ), f\"Slack slash regex does not match {expected}\"\n \n+    @pytest.mark.asyncio\n+    async def test_connect_uses_profile_scoped_app_token(self):\n+        \"\"\"Socket Mode must use the active profile's app token in multiplex mode.\"\"\"\n+        config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n+        adapter = SlackAdapter(config)\n+\n+        def _noop_decorator(_matcher):\n+            def decorator(fn):\n+                return fn\n+\n+            return decorator\n+\n+        mock_app = MagicMock()\n+        mock_app.event = _noop_decorator\n+        mock_app.command = _noop_decorator\n+        mock_app.action = _noop_decorator\n+        mock_app.client = AsyncMock()\n+\n+        mock_web_client = AsyncMock()\n+        mock_web_client.auth_test = AsyncMock(\n+            return_value={\n+                \"user_id\": \"U_PROFILE\",\n+                \"user\": \"profilebot\",\n+                \"team_id\": \"T_PROFILE\",\n+                \"team\": \"ProfileTeam\",\n+            }\n+        )\n+\n+        created_handlers = []\n+\n+        class FakeSocketModeHandler:\n+            def __init__(self, app, app_token, proxy=None):\n+                self.app = app\n+                self.app_token = [redacted:secret-assignment]\n+                self.proxy = proxy\n+                self.client = MagicMock(proxy=None)\n+                created_handlers.append(self)\n+\n+            async def start_async(self):\n+                return None\n+\n+            async def close_async(self):\n+                return None\n+\n+        secret_scope.set_multiplex_active(True)\n+        token = [redacted:secret-assignment]\"SLACK_APP_TOKEN\": \"[redacted:secret-assignment]\"})\n+        try:\n+            with (\n+                patch.object(_slack_mod, \"AsyncApp\", return_value=mock_app),\n+                patch.object(_slack_mod, \"AsyncWebClient\", return_value=mock_web_client),\n+                patch.object(\n+                    _slack_mod, \"AsyncSocketModeHandler\", FakeSocketModeHandler\n+                ),\n+                patch.dict(os.environ, {\"SLACK_APP_TOKEN\": \"[redacted:secret-assignment]\"}),\n+                patch(\"gateway.status.acquire_scoped_lock\", return_value=(True, None)),\n+                patch(\"asyncio.create_task\", side_effect=_fake_create_task),\n+            ):\n+                result = await adapter.connect()\n+        finally:\n+            secret_scope.reset_secret_scope(token)\n+            secret_scope.set_multiplex_active(False)\n+\n+        assert result is True\n+        assert created_handlers\n+        assert created_handlers[0].app_token == \"xapp-profile\"\n+\n+    @pytest.mark.asyncio\n+    async def test_connect_unscoped_multiplex_falls_back_to_env(self):\n+        \"\"\"Default-profile connect (multiplex active, NO scope installed) must\n+        fall back to process env instead of raising UnscopedSecretError —\n+        the primary startup loop and background reconnect rebuild both call\n+        connect() unscoped (#59739 salvage follow-up).\"\"\"\n+        config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n+        adapter = SlackAdapter(config)\n+\n+        def _noop_decorator(_matcher):\n+            def decorator(fn):\n+                return fn\n+\n+            return decorator\n+\n+        mock_app = MagicMock()\n+        mock_app.event = _noop_decorator\n+        mock_app.command = _noop_decorator\n+        mock_app.action = _noop_decorator\n+        mock_app.client = AsyncMock()\n+\n+        mock_web_client = AsyncMock()\n+        mock_web_client.auth_test = AsyncMock(\n+            return_value={\n+                \"user_id\": \"U_DEFAULT\",\n+                \"user\": \"defaultbot\",\n+                \"team_id\": \"T_DEFAULT\",\n+                \"team\": \"DefaultTeam\",\n+            }\n+        )\n+\n+        created_handlers = []\n+\n+        class FakeSocketModeHandler:\n+            def __init__(self, app, app_token, proxy=None):\n+                self.app = app\n+                self.app_token = [redacted:secret-assignment]\n+                self.proxy = proxy\n+                self.client = MagicMock(proxy=None)\n+                created_handlers.append(self)\n+\n+            async def start_async(self):\n+                return None\n+\n+            async def close_async(self):\n+                return None\n+\n+        secret_scope.set_multiplex_active(True)\n+        try:\n+            with (\n+                patch.object(_slack_mod, \"AsyncApp\", return_value=mock_app),\n+                patch.object(_slack_mod, \"AsyncWebClient\", return_value=mock_web_client),\n+                patch.object(\n+                    _slack_mod, \"AsyncSocketModeHandler\", FakeSocketModeHandler\n+                ),\n+                patch.dict(os.environ, {\"SLACK_APP_TOKEN\": \"[redacted:secret-assignment]\"}),\n+                patch(\"gateway.status.acquire_scoped_lock\", return_value=(True, None)),\n+                patch(\"asyncio.create_task\", side_effect=_fake_create_task),\n+            ):\n+                result = await adapter.connect()\n+        finally:\n+            secret_scope.set_multiplex_active(False)\n+\n+        assert result is True\n+        assert created_handlers\n+        assert created_handlers[0].app_token == \"xapp-default\"\n+\n \n class TestSlackConnectCleanup:\n     \"\"\"Regression coverage for failed connect() cleanup.\"\"\""]},{"file":"tools/send_message_tool.py","hunks":["@@ -15,6 +15,7 @@\n from email.utils import formatdate\n \n from agent.redact import redact_sensitive_text\n+from agent.secret_scope import get_secret\n \n logger = logging.getLogger(__name__)\n ","@@ -409,8 +410,8 @@ def _handle_send(args):\n         # Weixin can be configured purely via .env; synthesize a pconfig so\n         # send_message and cron delivery work without a gateway.yaml entry.\n         if platform_name == \"weixin\":\n-            wx_token = os.getenv(\"WEIXIN_TOKEN\", \"\").strip()\n-            wx_account = os.getenv(\"WEIXIN_ACCOUNT_ID\", \"\").strip()\n+            wx_token = get_secret(\"WEIXIN_TOKEN\", \"\").strip()\n+            wx_account = get_secret(\"WEIXIN_ACCOUNT_ID\", \"\").strip()\n             if wx_token and wx_account:\n                 from gateway.config import PlatformConfig\n                 pconfig = PlatformConfig(","@@ -418,8 +419,8 @@ def _handle_send(args):\n                     token=[redacted:secret-assignment]\n                     extra={\n                         \"account_id\": wx_account,\n-                        \"base_url\": os.getenv(\"WEIXIN_BASE_URL\", \"\").strip(),\n-                        \"cdn_base_url\": os.getenv(\"WEIXIN_CDN_BASE_URL\", \"\").strip(),\n+                        \"base_url\": get_secret(\"WEIXIN_BASE_URL\", \"\").strip(),\n+                        \"cdn_base_url\": get_secret(\"WEIXIN_CDN_BASE_URL\", \"\").strip(),\n                     },\n                 )\n             else:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}