mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
Plugin platforms (IRC, Teams, Google Chat) currently fail with `No live adapter for platform '<name>'` when a `deliver=<plugin>` cron job runs in a separate process from the gateway, even though the platforms are eligible cron targets via `cron_deliver_env_var` (added in #21306). Built-in platforms (Telegram, Discord, Slack, etc.) use direct REST helpers in `tools/send_message_tool.py` so cron can deliver without holding the gateway in the same process; plugin platforms historically depended on `_gateway_runner_ref()` which returns `None` out of process. This change adds an optional `standalone_sender_fn` field to `PlatformEntry` so plugins can register an ephemeral send path that opens its own connection, sends, and closes without needing the live adapter. The dispatch site in `_send_via_adapter` falls through to the hook when the gateway runner is unavailable, with a descriptive error when neither path applies. The hook is optional, so existing plugins are unaffected. Reference migrations land in the same change for IRC, Teams, and Google Chat, exercising the hook across stdlib (asyncio + IRC protocol), Bot Framework OAuth client_credentials, and Google service-account flows respectively. Security hardening on the new code paths: * IRC: control-character stripping on chat_id and message body to block CRLF command injection; bounded nick-collision retries; JOIN before PRIVMSG so channels with the default `+n` mode accept the delivery. * Teams: TEAMS_SERVICE_URL validated against an allowlist of known Bot Framework hosts (`smba.trafficmanager.net`, `smba.infra.gov.teams.microsoft.us`) to block SSRF; chat_id and tenant_id constrained to the documented Bot Framework character set; per-request timeouts so a slow STS endpoint cannot starve the activity POST. * Google Chat: chat_id and thread_id validated against strict resource-name regexes; service-account refresh wrapped in `asyncio.wait_for` so a hung token endpoint cannot stall the scheduler. Test coverage: 20 new tests covering happy path, missing-config errors, network failure modes, and each defensive validation. Existing tests unchanged. `bash scripts/run_tests.sh tests/tools/test_send_message_tool.py tests/gateway/test_irc_adapter.py tests/gateway/test_teams.py tests/gateway/test_google_chat.py` reports 341 passed, 0 regressions. Documentation: new "Out-of-process cron delivery" section in website/docs/developer-guide/adding-platform-adapters.md and an entry in gateway/platforms/ADDING_A_PLATFORM.md naming the hook.
244 lines
9 KiB
Python
244 lines
9 KiB
Python
"""
|
|
Platform Adapter Registry
|
|
|
|
Allows platform adapters (built-in and plugin) to self-register so the gateway
|
|
can discover and instantiate them without hardcoded if/elif chains.
|
|
|
|
Built-in adapters continue to use the existing if/elif in _create_adapter()
|
|
for now. Plugin adapters register here via PluginContext.register_platform()
|
|
and are looked up first -- if nothing is found the gateway falls through to
|
|
the legacy code path.
|
|
|
|
Usage (plugin side):
|
|
|
|
from gateway.platform_registry import platform_registry, PlatformEntry
|
|
|
|
platform_registry.register(PlatformEntry(
|
|
name="irc",
|
|
label="IRC",
|
|
adapter_factory=lambda cfg: IRCAdapter(cfg),
|
|
check_fn=check_requirements,
|
|
validate_config=lambda cfg: bool(cfg.extra.get("server")),
|
|
required_env=["IRC_SERVER"],
|
|
install_hint="pip install irc",
|
|
))
|
|
|
|
Usage (gateway side):
|
|
|
|
adapter = platform_registry.create_adapter("irc", platform_config)
|
|
"""
|
|
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Awaitable, Callable, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class PlatformEntry:
|
|
"""Metadata and factory for a single platform adapter."""
|
|
|
|
# Identifier used in config.yaml (e.g. "irc", "viber").
|
|
name: str
|
|
|
|
# Human-readable label (e.g. "IRC", "Viber").
|
|
label: str
|
|
|
|
# Factory callable: receives a PlatformConfig, returns an adapter instance.
|
|
# Using a factory instead of a bare class lets plugins do custom init
|
|
# (e.g. passing extra kwargs, wrapping in try/except).
|
|
adapter_factory: Callable[[Any], Any]
|
|
|
|
# Returns True when the platform's dependencies are available.
|
|
check_fn: Callable[[], bool]
|
|
|
|
# Optional: given a PlatformConfig, is it properly configured?
|
|
# If None, the registry skips config validation and lets the adapter
|
|
# fail at connect() time with a descriptive error.
|
|
validate_config: Optional[Callable[[Any], bool]] = None
|
|
|
|
# Optional: given a PlatformConfig, is the platform connected/enabled?
|
|
# Used by ``GatewayConfig.get_connected_platforms()`` and setup UI status.
|
|
# If None, falls back to ``validate_config`` or ``check_fn``.
|
|
is_connected: Optional[Callable[[Any], bool]] = None
|
|
|
|
# Env vars this platform needs (for ``hermes setup`` display).
|
|
required_env: list = field(default_factory=list)
|
|
|
|
# Hint shown when check_fn returns False.
|
|
install_hint: str = ""
|
|
|
|
# Optional setup function for interactive configuration.
|
|
# Signature: () -> None (prompts user, saves env vars).
|
|
# If None, falls back to _setup_standard_platform (needs token_var + vars)
|
|
# or a generic "set these env vars" display.
|
|
setup_fn: Optional[Callable[[], None]] = None
|
|
|
|
# "builtin" or "plugin"
|
|
source: str = "plugin"
|
|
|
|
# Name of the plugin manifest that registered this entry (empty for
|
|
# built-ins). Used by ``hermes gateway setup`` to auto-enable the
|
|
# owning plugin when the user configures its platform.
|
|
plugin_name: str = ""
|
|
|
|
# ── 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
|
|
|
|
# ── LLM guidance ──
|
|
# Platform hint injected into the system prompt (e.g. "You are on IRC.
|
|
# Do not use markdown."). Empty string = no hint.
|
|
platform_hint: str = ""
|
|
|
|
# ── Env-driven auto-configuration ──
|
|
# Optional: read env vars, return a dict of ``PlatformConfig.extra`` fields
|
|
# to seed when the platform is auto-enabled. Called during
|
|
# ``_apply_env_overrides`` BEFORE the adapter is constructed, so
|
|
# ``gateway status`` etc. can reflect env-only configuration without
|
|
# instantiating the adapter. Return ``None`` (or an empty dict) to skip.
|
|
# Signature: () -> Optional[dict[str, Any]]
|
|
env_enablement_fn: Optional[Callable[[], Optional[dict]]] = None
|
|
|
|
# Optional: home-channel env var name for cron/notification delivery
|
|
# (e.g. ``"IRC_HOME_CHANNEL"``). When set, ``cron.scheduler`` treats this
|
|
# platform as a valid ``deliver=<name>`` target and reads the env var to
|
|
# resolve the default chat/room ID. Empty = no cron home-channel support.
|
|
cron_deliver_env_var: str = ""
|
|
|
|
# ── Standalone (out-of-process) sending ──
|
|
# Optional: async coroutine that delivers a message without a live
|
|
# gateway adapter. Called by ``tools/send_message_tool._send_via_adapter``
|
|
# when ``cron`` runs in a separate process from the gateway and the
|
|
# in-process adapter weakref is therefore ``None``.
|
|
#
|
|
# Signature:
|
|
# async (pconfig, chat_id, message, *, thread_id=None,
|
|
# media_files=None, force_document=False) -> dict
|
|
#
|
|
# Returns ``{"success": True, "message_id": ...}`` on success or
|
|
# ``{"error": str}`` on failure. Plugin authors typically open an
|
|
# ephemeral connection / acquire a fresh OAuth token, send, and close.
|
|
# Without this hook, plugin platforms cannot serve as cron ``deliver=``
|
|
# targets when the gateway is not co-resident with the cron process.
|
|
standalone_sender_fn: Optional[Callable[..., Awaitable[dict]]] = None
|
|
|
|
|
|
class PlatformRegistry:
|
|
"""Central registry of platform adapters.
|
|
|
|
Thread-safe for reads (dict lookups are atomic under GIL).
|
|
Writes happen at startup during sequential discovery.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._entries: dict[str, PlatformEntry] = {}
|
|
|
|
def register(self, entry: PlatformEntry) -> None:
|
|
"""Register a platform adapter entry.
|
|
|
|
If an entry with the same name exists, it is replaced (last writer
|
|
wins -- this lets plugins override built-in adapters if desired).
|
|
"""
|
|
if entry.name in self._entries:
|
|
prev = self._entries[entry.name]
|
|
logger.info(
|
|
"Platform '%s' re-registered (was %s, now %s)",
|
|
entry.name,
|
|
prev.source,
|
|
entry.source,
|
|
)
|
|
self._entries[entry.name] = entry
|
|
logger.debug("Registered platform adapter: %s (%s)", entry.name, entry.source)
|
|
|
|
def unregister(self, name: str) -> bool:
|
|
"""Remove a platform entry. Returns True if it existed."""
|
|
return self._entries.pop(name, None) is not None
|
|
|
|
def get(self, name: str) -> Optional[PlatformEntry]:
|
|
"""Look up a platform entry by name."""
|
|
return self._entries.get(name)
|
|
|
|
def all_entries(self) -> list[PlatformEntry]:
|
|
"""Return all registered platform entries."""
|
|
return list(self._entries.values())
|
|
|
|
def plugin_entries(self) -> list[PlatformEntry]:
|
|
"""Return only plugin-registered platform entries."""
|
|
return [e for e in self._entries.values() if e.source == "plugin"]
|
|
|
|
def is_registered(self, name: str) -> bool:
|
|
return name in self._entries
|
|
|
|
def create_adapter(self, name: str, config: Any) -> Optional[Any]:
|
|
"""Create an adapter instance for the given platform name.
|
|
|
|
Returns None if:
|
|
- No entry registered for *name*
|
|
- check_fn() returns False (missing deps)
|
|
- validate_config() returns False (misconfigured)
|
|
- The factory raises an exception
|
|
"""
|
|
entry = self._entries.get(name)
|
|
if entry is None:
|
|
return None
|
|
|
|
if not entry.check_fn():
|
|
hint = f" ({entry.install_hint})" if entry.install_hint else ""
|
|
logger.warning(
|
|
"Platform '%s' requirements not met%s",
|
|
entry.label,
|
|
hint,
|
|
)
|
|
return None
|
|
|
|
if entry.validate_config is not None:
|
|
try:
|
|
if not entry.validate_config(config):
|
|
logger.warning(
|
|
"Platform '%s' config validation failed",
|
|
entry.label,
|
|
)
|
|
return None
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Platform '%s' config validation error: %s",
|
|
entry.label,
|
|
e,
|
|
)
|
|
return None
|
|
|
|
try:
|
|
adapter = entry.adapter_factory(config)
|
|
return adapter
|
|
except Exception as e:
|
|
logger.error(
|
|
"Failed to create adapter for platform '%s': %s",
|
|
entry.label,
|
|
e,
|
|
exc_info=True,
|
|
)
|
|
return None
|
|
|
|
|
|
# Module-level singleton
|
|
platform_registry = PlatformRegistry()
|