diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index 166635df59a2..884a60948c9c 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -28,6 +28,21 @@ from gateway.whatsapp_identity import ( ) +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 @@ class GatewayAuthorizationMixin: # 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 @@ class GatewayAuthorizationMixin: 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 @@ class GatewayAuthorizationMixin: 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 diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index d6d6449469ea..decc104b713b 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -1131,6 +1131,8 @@ class WebhookAdapter(BasePlatformAdapter): # 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 @@ class WebhookAdapter(BasePlatformAdapter): 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, diff --git a/gateway/run.py b/gateway/run.py index 1fefb8a9724f..5b6a34370595 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8877,14 +8877,31 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew 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 @@ -11813,7 +11830,39 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew 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 --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 69854c4a52ac..02c4cd468020 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -972,7 +972,14 @@ class SlackAdapter(BasePlatformAdapter): return False raw_token = self.config.token - app_token = os.getenv("SLACK_APP_TOKEN") + # Multiplex: profile secrets live in secret_scope, not process os.environ. + # Prefer get_secret; fall back to os.getenv for single-profile / tests. + try: + from agent.secret_scope import get_secret + + app_token = get_secret("SLACK_APP_TOKEN") or os.getenv("SLACK_APP_TOKEN") + except Exception: + app_token = os.getenv("SLACK_APP_TOKEN") if not raw_token: logger.error("[Slack] SLACK_BOT_TOKEN not set") @@ -3986,18 +3993,32 @@ class SlackAdapter(BasePlatformAdapter): 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."""