fix(gateway): multiplex secret_scope for authz, Slack, webhooks

Secondary profiles under gateway multiplex keep tokens/allowlists in
profile secret_scope, not process os.environ. Auth and Slack were still
reading os.getenv, so Slack on a secondary profile failed allowlist and
socket mode. Webhook deliver also only looked at default adapters.

- Prefer get_secret for allowlists / allow-all flags (authz_mixin)
- Slack app token + allowlist via secret_scope with getenv fallback
- Wrap secondary profile message handlers in _profile_runtime_scope
  before auth runs
- Resolve home-channel env from secret_scope / PlatformConfig
- Webhook deliver falls back to _profile_adapters for target platform
- Template key event_type for webhook prompts
This commit is contained in:
Agung Subastian 2026-07-14 22:32:33 +08:00 committed by Teknium
parent c82a196ea9
commit 8fc989b416
4 changed files with 110 additions and 12 deletions

View file

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

View file

@ -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,

View file

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

View file

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