feat(plugins): bundled platform plugins auto-load by default

Platform plugins shipped in-repo under plugins/platforms/ should be
available out of the box — users shouldn't have to add 'irc-platform'
to plugins.enabled before they can pick IRC from the gateway setup menu.

Adds a new ``kind: platform`` plugin type that mirrors the existing
``kind: backend`` auto-load semantics:

- Bundled (shipped in the hermes-agent repo): auto-load unconditionally.
- User-installed (~/.hermes/plugins/): still opt-in via plugins.enabled
  so untrusted code doesn't silently run.

Changes:

* hermes_cli/plugins.py: add 'platform' to _VALID_PLUGIN_KINDS, document
  the new kind in the PluginManifest docstring, extend the bundled auto-
  load rule from 'backend only' to 'backend or platform'.

* plugins/platforms/irc/plugin.yaml: declare kind: platform.

* hermes_cli/gateway.py: remove the now-redundant
  _load_bundled_platform_plugins_for_enumeration() helper and the
  _enable_plugin_for_platform() helper. The setup menu's _all_platforms()
  just calls discover_plugins() and reads the registry — bundled
  platforms are already loaded at that point. Drops the 'needs_enable'
  flag and the 'plugin disabled — select to enable' status string.

* hermes_cli/setup.py: relax the "gateway is configured" detector used
  during OpenClaw migration. Switching to _platform_status() in an
  earlier commit tightened the check to require an exact "configured"
  match, dropping platforms whose status is "enabled, not paired",
  "partially configured", "configured + E2EE", etc. Now any non-"not
  configured" status counts — the user has already started setup there
  and we shouldn't force the section to rerun.

* tests/hermes_cli/test_setup_irc.py: drop the TestIRCPluginDisabledFlow
  class and test_configure_platform_enables_disabled_plugin_first — the
  no-longer-existent flow they were testing.

* tests/hermes_cli/test_setup_openclaw_migration.py: patch both
  setup.get_env_value and gateway.get_env_value in the 4 gateway-section
  tests that reach _platform_status() through the unified setup flow;
  switch WHATSAPP_ENABLED to the literal "true" in the registry-parity
  test so WhatsApp's value-shape validator matches.

Verified via fresh-install smoke (empty plugins.enabled, no env vars):
IRC plugin loads, Platform('irc') resolves, _all_platforms() lists IRC
with status 'not configured'. 160 targeted tests pass.
This commit is contained in:
Teknium 2026-04-29 21:02:16 -07:00
parent 71c8ca17dc
commit 4d363499db
6 changed files with 53 additions and 190 deletions

View file

@ -2761,106 +2761,27 @@ _PLATFORMS = [
],
},
]
def _load_bundled_platform_plugins_for_enumeration() -> set[str]:
"""Force-load bundled platform plugins so they appear in setup menus.
Platform plugins under ``plugins/platforms/`` are opt-in via
``plugins.enabled`` like every other plugin, but we want them listed in
``hermes gateway setup`` even when disabled so users can discover and
enable them inline. ``register()`` on a platform plugin only populates
the registry no adapters run, no network I/O so loading it here is
side-effect-free for the short-lived setup process.
**Contract:** Platform plugin ``register()`` functions MUST NOT register
tools, hooks, or start background threads. They should only call
``ctx.register_platform()`` to populate the platform registry. Violating
this contract will cause side effects (tool registration, hook firing)
during setup menu rendering even when the plugin is disabled.
Returns the set of plugin names that were force-loaded (i.e. plugins
not in ``plugins.enabled``), so the caller can display a hint and
auto-enable them on selection.
"""
try:
import yaml as _yaml
except ImportError:
return set()
from hermes_cli.plugins import (
get_bundled_plugins_dir,
get_plugin_manager,
PluginManifest,
)
manager = get_plugin_manager()
platforms_dir = get_bundled_plugins_dir() / "platforms"
if not platforms_dir.is_dir():
return set()
disabled_plugin_names: set[str] = set()
for child in sorted(platforms_dir.iterdir()):
if not child.is_dir():
continue
manifest_file = child / "plugin.yaml"
if not manifest_file.exists():
manifest_file = child / "plugin.yml"
if not manifest_file.exists():
continue
try:
data = _yaml.safe_load(manifest_file.read_text()) or {}
except Exception as e:
logger.debug("failed to parse %s: %s", manifest_file, e)
continue
plugin_name = data.get("name", child.name)
existing = manager._plugins.get(plugin_name)
if existing is not None and existing.enabled:
continue # already loaded by normal discovery
manifest = PluginManifest(
name=plugin_name,
version=str(data.get("version", "")),
description=data.get("description", ""),
author=data.get("author", ""),
requires_env=data.get("requires_env", []),
provides_tools=data.get("provides_tools", []),
provides_hooks=data.get("provides_hooks", []),
source="bundled",
path=str(child),
)
try:
manager._load_plugin(manifest)
except Exception as e:
logger.debug("failed to force-load %s: %s", plugin_name, e)
continue
disabled_plugin_names.add(plugin_name)
return disabled_plugin_names
def _all_platforms() -> list[dict]:
"""Return the full list of platforms for setup menus.
Combines the built-in ``_PLATFORMS`` with plugin platforms registered via
``platform_registry``. Plugins are discovered on first call so platforms
like IRC appear in ``hermes setup gateway`` without needing the gateway
to be running. Built-ins keep their dict shape; plugin entries are
adapted to the same shape with ``_registry_entry`` holding the source.
``platform_registry``. Plugins are discovered on first call so bundled
platforms (like IRC, which auto-load via ``kind: platform``) appear in
``hermes setup gateway`` without needing the gateway to be running.
Built-ins keep their dict shape; plugin entries are adapted to the same
shape with ``_registry_entry`` holding the source.
"""
# Populate the registry so plugin platforms are visible. Idempotent.
# Bundled platform plugins (``kind: platform``) auto-load unconditionally,
# so every shipped messaging channel appears in the setup menu by default.
# User-installed platform plugins under ~/.hermes/plugins/ still require
# opt-in via ``plugins.enabled`` (untrusted code).
try:
from hermes_cli.plugins import discover_plugins
discover_plugins()
except Exception as e:
logger.debug("plugin discovery failed during platform enumeration: %s", e)
# Also surface bundled platform plugins that aren't in `plugins.enabled`
# so the setup menu can offer to enable them.
disabled_plugin_names = _load_bundled_platform_plugins_for_enumeration()
platforms = [dict(p) for p in _PLATFORMS]
by_key = {p["key"]: p for p in platforms}
@ -2872,7 +2793,6 @@ def _all_platforms() -> list[dict]:
for entry in platform_registry.all_entries():
if entry.name in by_key:
continue # built-in already covers it
needs_enable = bool(entry.plugin_name) and entry.plugin_name in disabled_plugin_names
platforms.append({
"key": entry.name,
"label": entry.label,
@ -2880,7 +2800,6 @@ def _all_platforms() -> list[dict]:
"token_var": entry.required_env[0] if entry.required_env else "",
"install_hint": entry.install_hint,
"_registry_entry": entry,
"needs_enable": needs_enable,
})
return platforms
@ -2908,8 +2827,6 @@ def _platform_status(platform: dict) -> str:
configured = bool(entry.check_fn())
except Exception:
configured = False
if platform.get("needs_enable") and not configured:
return "plugin disabled — select to enable"
return "configured" if configured else "not configured"
token_var = platform.get("token_var", "")
@ -3895,27 +3812,6 @@ def _builtin_setup_fn(key: str):
"wecom": _setup_wecom,
"qqbot": _setup_qqbot,
}.get(key)
def _enable_plugin_for_platform(plugin_name: str, platform_label: str) -> None:
"""Add *plugin_name* to ``plugins.enabled`` so it loads on next run."""
try:
from hermes_cli.plugins_cmd import _get_enabled_set, _save_enabled_set
except Exception as e:
logger.debug("cannot enable plugin %s: %s", plugin_name, e)
return
enabled = _get_enabled_set()
if plugin_name in enabled:
return
enabled.add(plugin_name)
_save_enabled_set(enabled)
print()
print_success(
f"Enabled plugin '{plugin_name}' for {platform_label}. "
"Takes effect on next session."
)
def _configure_platform(platform: dict) -> None:
"""Run the interactive setup flow for a single platform.
@ -3925,12 +3821,11 @@ def _configure_platform(platform: dict) -> None:
3. ``_setup_standard_platform`` when the entry has a ``vars`` schema.
4. Env-var hint fallback for plugins that offer no setup helper.
If the platform is owned by a plugin that isn't in ``plugins.enabled``,
the plugin is added to the allow-list before setup runs.
Bundled platform plugins (e.g. IRC) auto-load, so no plugin enable step
is needed here. User-installed platform plugins under ~/.hermes/plugins/
must already be in ``plugins.enabled`` before they appear in this menu.
"""
entry = platform.get("_registry_entry")
if platform.get("needs_enable") and entry is not None and entry.plugin_name:
_enable_plugin_for_platform(entry.plugin_name, entry.label)
if entry is not None and entry.setup_fn is not None:
entry.setup_fn()
@ -4478,4 +4373,4 @@ def _gateway_command_inner(args):
if not supports_systemd_services() and not is_macos():
print("Legacy unit migration only applies to systemd-based Linux hosts.")
return
remove_legacy_hermes_units(interactive=not yes, dry_run=dry_run)
remove_legacy_hermes_units(interactive=not yes, dry_run=dry_run)

View file

@ -170,7 +170,7 @@ def _get_enabled_plugins() -> Optional[set]:
# Data classes
# ---------------------------------------------------------------------------
_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive"}
_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform"}
@dataclass
@ -196,6 +196,11 @@ class PluginManifest:
# Selection via ``<category>.provider`` config key; the
# category's own discovery system handles loading and the
# general scanner skips these.
# ``platform``: gateway messaging platform adapter (e.g. IRC). Bundled
# platform plugins auto-load so every shipped platform is
# available out of the box; user-installed platform plugins
# in ~/.hermes/plugins/ still gated by ``plugins.enabled``
# (untrusted code).
kind: str = "standalone"
# Registry key — path-derived, used by ``plugins.enabled``/``disabled``
# lookups and by ``hermes plugins list``. For a flat plugin at
@ -705,7 +710,11 @@ class PluginManager:
# just work. Selection among them (e.g. which image_gen backend
# services calls) is driven by ``<category>.provider`` config,
# enforced by the tool wrapper.
if manifest.kind == "backend" and manifest.source == "bundled":
#
# Bundled platform plugins (gateway adapters like IRC) auto-load
# for the same reason: every platform Hermes ships must be
# available out of the box without the user having to opt in.
if manifest.source == "bundled" and manifest.kind in ("backend", "platform"):
self._load_plugin(manifest)
continue

View file

@ -2638,10 +2638,14 @@ def _get_section_config_summary(config: dict, section_key: str) -> Optional[str]
elif section_key == "gateway":
from hermes_cli.gateway import _all_platforms, _platform_status
# Count any non-empty status other than the "not configured" sentinel —
# platforms like WhatsApp ("enabled, not paired"), Matrix ("configured
# + E2EE"), and Signal ("partially configured") all indicate the user
# has already started setup and we shouldn't force the section to rerun.
configured = [
_gateway_platform_short_label(plat["label"])
for plat in _all_platforms()
if _platform_status(plat) == "configured"
if _platform_status(plat) and _platform_status(plat) != "not configured"
]
if configured:
return ", ".join(configured)

View file

@ -1,4 +1,5 @@
name: irc-platform
kind: platform
version: 1.0.0
description: >
IRC gateway adapter for Hermes Agent.

View file

@ -17,7 +17,6 @@ def _register_irc_platform(**overrides):
Tests run outside the normal plugin-discovery path, so we inject the entry
directly into the singleton registry and yield its dict shape.
"""
needs_enable = overrides.pop("needs_enable", False)
defaults = dict(
name="irc",
label="IRC",
@ -47,7 +46,6 @@ def _register_irc_platform(**overrides):
"token_var": entry.required_env[0] if entry.required_env else "",
"install_hint": entry.install_hint,
"_registry_entry": entry,
"needs_enable": needs_enable,
}
@ -126,42 +124,6 @@ class TestIRCFreshInstallDiscovery:
_unregister_irc_platform()
# ── Plugin-disabled flow ────────────────────────────────────────────────────
class TestIRCPluginDisabledFlow:
"""When the IRC plugin is disabled, setup offers to enable it."""
def test_disabled_plugin_shows_enable_prompt(self, monkeypatch):
"""A disabled plugin platform surfaces 'plugin disabled — select to enable'."""
import hermes_cli.gateway as gateway_mod
plat = _register_irc_platform(needs_enable=True)
try:
for key in ("IRC_SERVER", "IRC_CHANNEL", "IRC_NICKNAME"):
monkeypatch.delenv(key, raising=False)
status = gateway_mod._platform_status(plat)
assert "plugin disabled" in status.lower()
assert "select to enable" in status.lower()
finally:
_unregister_irc_platform()
def test_disabled_but_already_configured_shows_configured(self, monkeypatch):
"""If the plugin is disabled but env vars are already present, show 'configured'."""
import hermes_cli.gateway as gateway_mod
plat = _register_irc_platform(needs_enable=True)
try:
monkeypatch.setenv("IRC_SERVER", "irc.libera.chat")
monkeypatch.setenv("IRC_CHANNEL", "#hermes")
status = gateway_mod._platform_status(plat)
assert status == "configured"
finally:
_unregister_irc_platform()
# ── Interactive setup dispatch ──────────────────────────────────────────────
@ -188,32 +150,6 @@ class TestIRCInteractiveSetup:
out = capsys.readouterr().out
assert "IRC setup complete!" in out
def test_configure_platform_enables_disabled_plugin_first(self, monkeypatch, capsys, tmp_path):
"""If the plugin is disabled, _configure_platform enables it before running setup."""
import hermes_cli.gateway as gateway_mod
from hermes_cli.config import save_config, load_config
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# Ensure plugins.enabled exists but does NOT include irc_platform
cfg = load_config()
cfg.setdefault("plugins", {})["enabled"] = ["some_other_plugin"]
save_config(cfg)
calls = []
def fake_setup():
calls.append("setup_called")
plat = _register_irc_platform(setup_fn=fake_setup, needs_enable=True)
try:
gateway_mod._configure_platform(plat)
finally:
_unregister_irc_platform()
assert "setup_called" in calls
# Plugin should now be enabled
reloaded = load_config()
assert "irc_platform" in reloaded.get("plugins", {}).get("enabled", [])
def test_configure_platform_fallback_when_no_setup_fn(self, monkeypatch, capsys):
"""A plugin with no setup_fn falls back to env-var instructions."""

View file

@ -419,7 +419,12 @@ class TestGetSectionConfigSummary:
return "disc456"
return ""
with patch.object(setup_mod, "get_env_value", side_effect=env_side):
# Also patch gateway module's binding since _platform_status()
# reads from hermes_cli.gateway.get_env_value after the setup
# flows were unified via platform_registry.
import hermes_cli.gateway as gateway_mod
with patch.object(setup_mod, "get_env_value", side_effect=env_side), \
patch.object(gateway_mod, "get_env_value", side_effect=env_side):
result = setup_mod._get_section_config_summary({}, "gateway")
assert "Telegram" in result
assert "Discord" in result
@ -471,7 +476,9 @@ class TestGetSectionConfigSummary:
def env_side(key):
return "true" if key == "WHATSAPP_ENABLED" else ""
with patch.object(setup_mod, "get_env_value", side_effect=env_side):
import hermes_cli.gateway as gateway_mod
with patch.object(setup_mod, "get_env_value", side_effect=env_side), \
patch.object(gateway_mod, "get_env_value", side_effect=env_side):
result = setup_mod._get_section_config_summary({}, "gateway")
assert result is not None
assert "WhatsApp" in result
@ -481,7 +488,9 @@ class TestGetSectionConfigSummary:
def env_side(key):
return "http://signal.local" if key == "SIGNAL_HTTP_URL" else ""
with patch.object(setup_mod, "get_env_value", side_effect=env_side):
import hermes_cli.gateway as gateway_mod
with patch.object(setup_mod, "get_env_value", side_effect=env_side), \
patch.object(gateway_mod, "get_env_value", side_effect=env_side):
result = setup_mod._get_section_config_summary({}, "gateway")
assert result is not None
assert "Signal" in result
@ -539,9 +548,18 @@ class TestGetSectionConfigSummary:
env_var = plat.get("token_var")
if not env_var:
continue
# Some platforms require a specific value shape (e.g. WhatsApp
# needs the literal "true"). Use a sentinel that satisfies every
# real validator _platform_status() currently checks.
def env_side(key, _target=env_var):
return "x" if key == _target else ""
with patch.object(setup_mod, "get_env_value", side_effect=env_side):
if key != _target:
return ""
if _target == "WHATSAPP_ENABLED":
return "true"
return "x"
import hermes_cli.gateway as gateway_mod
with patch.object(setup_mod, "get_env_value", side_effect=env_side), \
patch.object(gateway_mod, "get_env_value", side_effect=env_side):
result = setup_mod._get_section_config_summary({}, "gateway")
expected = setup_mod._gateway_platform_short_label(label)
assert result is not None, f"{label} ({env_var}) not recognised"