From af9336d575ef680b49cf56f9ef6031968e6f5ce1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 7 May 2026 06:33:23 -0700 Subject: [PATCH] feat(gateway): generic plugin hooks for env enablement + cron delivery Widen the platform-plugin surface so plugins can self-configure from env vars and opt into cron home-channel delivery without editing core files. Closes the scope gap that forced every new platform (Google Chat, Teams, IRC, future) to either touch gateway/config.py, cron/scheduler.py, and hermes_cli/config.py or live without env-only setup. Changes: - gateway/platform_registry.py: two new optional PlatformEntry fields. - env_enablement_fn: () -> Optional[dict]. Called during _apply_env_overrides BEFORE the adapter is constructed. Returned dict fields are merged into PlatformConfig.extra; the special 'home_channel' key (if present) becomes a proper HomeChannel dataclass on the PlatformConfig. - cron_deliver_env_var: name of the *_HOME_CHANNEL env var. When set, the plugin platform is a valid cron deliver= target and cron reads the env var to resolve the default chat/room ID. - gateway/config.py: the existing plugin-platform enable pass at the bottom of _apply_env_overrides now calls env_enablement_fn and seeds extras/home_channel. No effect on plugins that don't set the new field. - cron/scheduler.py: _is_known_delivery_platform and _resolve_home_env_var fall through to the registry when the platform isn't in the hardcoded built-in sets. New _iter_home_target_platforms helper iterates built-ins + plugin platforms for the deliver=origin fallback. - gateway/run.py: _home_target_env_var now consults the new resolver so plugin-defined home channels work for non-cron call sites too. - hermes_cli/config.py: new _inject_platform_plugin_env_vars() sibling of _inject_profile_env_vars(). Scans plugins/platforms/*/plugin.yaml at import time and contributes entries to OPTIONAL_ENV_VARS so 'hermes config' UI discovers them. Supports bare-string and rich-dict requires_env entries plus a new optional_env list for non-required vars (home channels, allowlists). All additions are strictly opt-in. Existing plugins (IRC, Teams, image_gen, memory) see zero behavior change until they adopt the new fields. --- cron/scheduler.py | 71 ++++++++++++++++++++++++-- gateway/config.py | 31 +++++++++++- gateway/platform_registry.py | 15 ++++++ gateway/run.py | 17 ++++--- hermes_cli/config.py | 97 ++++++++++++++++++++++++++++++++++++ 5 files changed, 220 insertions(+), 11 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index c17c1fa46f..756771d0f0 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -152,9 +152,54 @@ def _resolve_origin(job: dict) -> Optional[dict]: return None +def _plugin_cron_env_var(platform_name: str) -> str: + """Return the cron home-channel env var registered by a plugin platform. + + Falls through the platform registry so plugins that set + ``cron_deliver_env_var`` on their ``PlatformEntry`` get cron delivery + support without editing this module. + """ + try: + from hermes_cli.plugins import discover_plugins + discover_plugins() # idempotent + from gateway.platform_registry import platform_registry + entry = platform_registry.get(platform_name.lower()) + if entry and entry.cron_deliver_env_var: + return entry.cron_deliver_env_var + except Exception: + pass + return "" + + +def _is_known_delivery_platform(platform_name: str) -> bool: + """Whether ``platform_name`` is a valid cron delivery target. + + Hardcoded built-ins in ``_KNOWN_DELIVERY_PLATFORMS`` are checked first; + plugin platforms registered via ``PlatformEntry`` are accepted if they + provide a ``cron_deliver_env_var``. + """ + name = platform_name.lower() + if name in _KNOWN_DELIVERY_PLATFORMS: + return True + return bool(_plugin_cron_env_var(name)) + + +def _resolve_home_env_var(platform_name: str) -> str: + """Return the env var name for a platform's cron home channel. + + Built-in platforms are in ``_HOME_TARGET_ENV_VARS``; plugin platforms are + resolved from the platform registry. + """ + name = platform_name.lower() + env_var = _HOME_TARGET_ENV_VARS.get(name) + if env_var: + return env_var + return _plugin_cron_env_var(name) + + def _get_home_target_chat_id(platform_name: str) -> str: """Return the configured home target chat/room ID for a delivery platform.""" - env_var = _HOME_TARGET_ENV_VARS.get(platform_name.lower()) + env_var = _resolve_home_env_var(platform_name) if not env_var: return "" value = os.getenv(env_var, "") @@ -167,7 +212,7 @@ def _get_home_target_chat_id(platform_name: str) -> str: def _get_home_target_thread_id(platform_name: str) -> Optional[str]: """Return the optional thread/topic ID for a platform home target.""" - env_var = _HOME_TARGET_ENV_VARS.get(platform_name.lower()) + env_var = _resolve_home_env_var(platform_name) if not env_var: return None value = os.getenv(f"{env_var}_THREAD_ID", "").strip() @@ -178,6 +223,24 @@ def _get_home_target_thread_id(platform_name: str) -> Optional[str]: return value or None +def _iter_home_target_platforms(): + """Iterate built-in + plugin platform names that expose a home channel. + + Used by the ``deliver=origin`` fallback when the job has no origin. + """ + for name in _HOME_TARGET_ENV_VARS: + yield name + try: + from hermes_cli.plugins import discover_plugins + discover_plugins() # idempotent + from gateway.platform_registry import platform_registry + for entry in platform_registry.plugin_entries(): + if entry.cron_deliver_env_var and entry.name not in _HOME_TARGET_ENV_VARS: + yield entry.name + except Exception: + pass + + def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[dict]: """Resolve one concrete auto-delivery target for a cron job.""" @@ -195,7 +258,7 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d } # Origin missing (e.g. job created via API/script) — try each # platform's home channel as a fallback instead of silently dropping. - for platform_name in _HOME_TARGET_ENV_VARS: + for platform_name in _iter_home_target_platforms(): chat_id = _get_home_target_chat_id(platform_name) if chat_id: logger.info( @@ -251,7 +314,7 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d "thread_id": origin.get("thread_id"), } - if platform_name.lower() not in _KNOWN_DELIVERY_PLATFORMS: + if not _is_known_delivery_platform(platform_name): return None chat_id = _get_home_target_chat_id(platform_name) if not chat_id: diff --git a/gateway/config.py b/gateway/config.py index a30bf8a19e..6df6b5f4a5 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -1664,7 +1664,10 @@ def _apply_env_overrides(config: GatewayConfig) -> None: # Registry-driven enable for plugin platforms. Built-ins have explicit # blocks above; plugins expose check_fn() which is the single source of # truth for "are my env vars set?". When it returns True, ensure the - # platform is enabled so start() will create its adapter. + # platform is enabled so start() will create its adapter. Plugins that + # need to seed ``PlatformConfig.extra`` from env vars (e.g. Google Chat's + # project_id / subscription_name) can supply ``env_enablement_fn`` on + # their PlatformEntry — called here BEFORE adapter construction. try: from hermes_cli.plugins import discover_plugins discover_plugins() # idempotent @@ -1680,5 +1683,31 @@ def _apply_env_overrides(config: GatewayConfig) -> None: if platform not in config.platforms: config.platforms[platform] = PlatformConfig() config.platforms[platform].enabled = True + # Seed extras from env if the plugin opted in. + if entry.env_enablement_fn is not None: + try: + seed = entry.env_enablement_fn() + except Exception as e: + logger.debug( + "env_enablement_fn for %s raised: %s", entry.name, e + ) + seed = None + if isinstance(seed, dict) and seed: + # Extract the home_channel dict (if provided) so we wire it + # up as a proper HomeChannel dataclass. Everything else is + # merged into ``extra``. + home = seed.pop("home_channel", None) + config.platforms[platform].extra.update(seed) + if isinstance(home, dict) and home.get("chat_id"): + config.platforms[platform].home_channel = HomeChannel( + platform=platform, + chat_id=str(home["chat_id"]), + name=str(home.get("name") or "Home"), + thread_id=( + str(home["thread_id"]) + if home.get("thread_id") + else None + ), + ) except Exception as e: logger.debug("Plugin platform enable pass failed: %s", e) diff --git a/gateway/platform_registry.py b/gateway/platform_registry.py index 11303466da..a52f659692 100644 --- a/gateway/platform_registry.py +++ b/gateway/platform_registry.py @@ -110,6 +110,21 @@ class PlatformEntry: # 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=`` target and reads the env var to + # resolve the default chat/room ID. Empty = no cron home-channel support. + cron_deliver_env_var: str = "" + class PlatformRegistry: """Central registry of platform adapters. diff --git a/gateway/run.py b/gateway/run.py index f96d77b3c0..24ed660895 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -258,13 +258,18 @@ def _ensure_ssl_certs() -> None: return def _home_target_env_var(platform_name: str) -> str: - """Return the configured home-target env var for a platform.""" - from cron.scheduler import _HOME_TARGET_ENV_VARS + """Return the configured home-target env var for a platform. - return _HOME_TARGET_ENV_VARS.get( - platform_name.lower(), - f"{platform_name.upper()}_HOME_CHANNEL", - ) + Consults built-in ``_HOME_TARGET_ENV_VARS`` first, then the plugin + registry via ``cron.scheduler._resolve_home_env_var``, then falls back + to ``_HOME_CHANNEL`` for unknown names. + """ + from cron.scheduler import _resolve_home_env_var + + resolved = _resolve_home_env_var(platform_name) + if resolved: + return resolved + return f"{platform_name.upper()}_HOME_CHANNEL" def _home_thread_env_var(platform_name: str) -> str: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7b484c96b6..cdb53fd080 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -4961,3 +4961,100 @@ def _inject_profile_env_vars() -> None: # Eagerly inject so that OPTIONAL_ENV_VARS is fully populated at import time. _inject_profile_env_vars() + + +# ── Platform-plugin env var injection ──────────────────────────────────────── +# Bundled platform plugins under ``plugins/platforms/*/plugin.yaml`` declare +# their required env vars via ``requires_env``. This mirror of +# ``_inject_profile_env_vars`` surfaces them in ``hermes config`` UI so users +# can configure Teams / IRC / Google Chat without the core repo ever needing +# to know they exist. +# +# Each ``requires_env`` entry may be a bare string (name only) or a dict: +# +# requires_env: +# - TEAMS_CLIENT_ID # minimal +# - name: TEAMS_CLIENT_SECRET # rich +# description: "Teams bot client secret" +# url: "https://portal.azure.com/" +# password: true +# prompt: "Teams client secret" +# +# An optional ``optional_env`` block surfaces non-required vars the same way +# (e.g. allowlist, home channel). + +_platform_plugin_env_vars_injected = False + + +def _inject_platform_plugin_env_vars() -> None: + """Populate OPTIONAL_ENV_VARS from bundled platform plugin manifests. + + Called once at module load time. Idempotent — repeated calls are no-ops. + Failures are swallowed so a malformed plugin.yaml can't break CLI import. + """ + global _platform_plugin_env_vars_injected + if _platform_plugin_env_vars_injected: + return + _platform_plugin_env_vars_injected = True + try: + import yaml # type: ignore + + # Resolve the bundled plugins dir from this file's location so the + # injector works regardless of CWD. + repo_root = Path(__file__).resolve().parents[1] + platforms_dir = repo_root / "plugins" / "platforms" + if not platforms_dir.is_dir(): + return + for child in platforms_dir.iterdir(): + if not child.is_dir(): + continue + manifest_path = child / "plugin.yaml" + if not manifest_path.exists(): + manifest_path = child / "plugin.yml" + if not manifest_path.exists(): + continue + try: + with open(manifest_path, "r", encoding="utf-8") as f: + manifest = yaml.safe_load(f) or {} + except Exception: + continue + label = manifest.get("label") or manifest.get("name") or child.name + # Merge required + optional env var declarations. + entries = list(manifest.get("requires_env") or []) + entries.extend(manifest.get("optional_env") or []) + for entry in entries: + if isinstance(entry, str): + name = entry + meta: dict = {} + elif isinstance(entry, dict) and entry.get("name"): + name = entry["name"] + meta = entry + else: + continue + if name in OPTIONAL_ENV_VARS: + continue # hardcoded entry wins (back-compat) + # Heuristic: anything named *TOKEN, *SECRET, *KEY, *PASSWORD + # is a password field unless explicitly overridden. + name_upper = name.upper() + is_secret = bool(meta.get("password") or meta.get("secret")) + if not is_secret and not meta.get("password") is False: + is_secret = any( + name_upper.endswith(suf) + for suf in ("_TOKEN", "_SECRET", "_KEY", "_PASSWORD", "_JSON") + ) + OPTIONAL_ENV_VARS[name] = { + "description": ( + meta.get("description") + or f"{label} configuration" + ), + "prompt": meta.get("prompt") or name, + "url": meta.get("url") or None, + "password": is_secret, + "category": meta.get("category") or "messaging", + } + except Exception: + pass + + +# Eagerly inject so that platform plugin env vars show up in the setup wizard. +_inject_platform_plugin_env_vars()