feat: complete plugin platform parity — all 12 integration points

Extends the platform plugin interface from Phase 1 to cover every
touchpoint where built-in platforms have hardcoded behavior.

- allowed_users_env / allow_all_env: per-platform auth env vars
- max_message_length: smart-chunking for send_message tool
- pii_safe: session PII redaction flag
- emoji: CLI/gateway display
- allow_update_command: /update access control

send_message tool (tools/send_message_tool.py):
- Replaced hardcoded platform_map dict with Platform() call
- Added _send_via_adapter() for plugin platforms — routes through
  live gateway adapter when available
- Registry-aware max message length for smart chunking

Cron delivery (cron/scheduler.py):
- Replaced hardcoded 15-entry platform_map with Platform() call
- Plugin platforms now work as cron delivery targets

User authorization (gateway/run.py _is_user_authorized):
- Registry fallback: checks PlatformEntry.allowed_users_env and
  allow_all_env when platform not in hardcoded maps
- Plugin platforms get per-platform auth support

_UPDATE_ALLOWED_PLATFORMS: checks registry allow_update_command flag
Channel directory: includes plugin platforms in session enumeration
Orphaned config warning: descriptive message when plugin platform is
  in config but no plugin registered it
Gateway weakref: _gateway_runner_ref for cross-module adapter access

hermes status: shows plugin platforms with (plugin) tag
hermes gateway setup: plugin platforms appear in menu with setup hints
hermes_cli/platforms.py: get_all_platforms() merges with registry,
  platform_label() falls back to registry for plugin names

- 8 new tests (extended fields, cron resolution, platforms merge)
- Updated 3 tests for new Platform() based resolution
- 2829 passed, 24 pre-existing failures, zero new failures
This commit is contained in:
Teknium
2026-04-11 15:10:03 -07:00
committed by Teknium
parent 8f144fe36b
commit 2e20f6ae2d
11 changed files with 376 additions and 86 deletions

View File

@@ -86,6 +86,16 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
continue
platforms[plat_name] = _build_from_sessions(plat_name)
# Include plugin-registered platforms (dynamic enum members aren't in
# Platform.__members__, so the loop above misses them).
try:
from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries():
if entry.name not in _SKIP_SESSION_DISCOVERY and entry.name not in platforms:
platforms[entry.name] = _build_from_sessions(entry.name)
except Exception:
pass
directory = {
"updated_at": datetime.now().isoformat(),
"platforms": platforms,

View File

@@ -67,6 +67,28 @@ class PlatformEntry:
# "builtin" or "plugin"
source: str = "plugin"
# ── Auth env var names (for _is_user_authorized integration) ──
# E.g. "IRC_ALLOWED_USERS" — checked for comma-separated user IDs.
allowed_users_env: str = ""
# E.g. "IRC_ALLOW_ALL_USERS" — if truthy, all users authorized.
allow_all_env: str = ""
# ── Message limits ──
# Max message length for smart-chunking. 0 = no limit.
max_message_length: int = 0
# ── Privacy ──
# If True, session descriptions redact PII (phone numbers, etc.)
pii_safe: bool = False
# ── Display ──
# Emoji for CLI/gateway display (e.g. "💬")
emoji: str = "🔌"
# Whether this platform should appear in _UPDATE_ALLOWED_PLATFORMS
# (allows /update command from this platform).
allow_update_command: bool = True
class PlatformRegistry:
"""Central registry of platform adapters.

View File

@@ -782,6 +782,13 @@ def _format_gateway_process_notification(evt: dict) -> "str | None":
return None
# Module-level weak reference to the active GatewayRunner instance.
# Used by tools (e.g. send_message) that need to route through a live
# adapter for plugin platforms. Set in GatewayRunner.__init__().
import weakref as _weakref
_gateway_runner_ref: _weakref.ref = lambda: None
class GatewayRunner:
"""
Main gateway controller.
@@ -806,9 +813,11 @@ class GatewayRunner:
_session_reasoning_overrides: Dict[str, Dict[str, Any]] = {}
def __init__(self, config: Optional[GatewayConfig] = None):
global _gateway_runner_ref
self.config = config or load_gateway_config()
self.adapters: Dict[Platform, BasePlatformAdapter] = {}
self._warn_if_docker_media_delivery_is_risky()
_gateway_runner_ref = _weakref.ref(self)
# Load ephemeral config from config.yaml / env vars.
# Both are injected at API-call time only and never persisted.
@@ -2483,7 +2492,17 @@ class GatewayRunner:
adapter = self._create_adapter(platform, platform_config)
if not adapter:
logger.warning("No adapter available for %s", platform.value)
# Distinguish between missing builtin deps and missing plugin
_pval = platform.value
_builtin_names = {m.value for m in Platform.__members__.values()}
if _pval not in _builtin_names:
logger.warning(
"No adapter for '%s' — is the plugin installed? "
"(platform is enabled in config.yaml but no plugin registered it)",
_pval,
)
else:
logger.warning("No adapter available for %s", _pval)
continue
# Set up message + fatal error handlers
@@ -3462,6 +3481,19 @@ class GatewayRunner:
Platform.YUANBAO: "YUANBAO_ALLOW_ALL_USERS",
}
# Plugin platforms: check the registry for auth env var names
if source.platform not in platform_env_map:
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(source.platform.value)
if entry:
if entry.allowed_users_env:
platform_env_map[source.platform] = entry.allowed_users_env
if entry.allow_all_env:
platform_allow_all_map[source.platform] = entry.allow_all_env
except Exception:
pass
# 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"):
@@ -8761,8 +8793,16 @@ class GatewayRunner:
# Block non-messaging platforms (API server, webhooks, ACP)
platform = event.source.platform
if platform not in self._UPDATE_ALLOWED_PLATFORMS:
return "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal."
_allowed = self._UPDATE_ALLOWED_PLATFORMS
# Plugin platforms with allow_update_command=True are also allowed
if platform not in _allowed:
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(platform.value)
if not entry or not entry.allow_update_command:
return "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal."
except Exception:
return "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal."
if is_managed():
return f"{format_managed_message('update Hermes Agent')}"