feat(plugins): requires_hermes manifest gate on main's manifest/loader siblings

Re-port of the PR's version gate onto the decomposed layout: the field and
parser live in plugins_manifest.py (with running_hermes_version /
version_satisfies helpers), the load-time skip in plugins_loader.py
before any import. Unsatisfied plugins record an error and never run
register(); one invariant test proves both halves.
This commit is contained in:
Teknium
2026-09-09 04:38:01 -07:00
parent d47adec28f
commit 4e312cf22d
3 changed files with 87 additions and 1 deletions
+8
View File
@@ -278,6 +278,14 @@ class PluginLoaderMixin:
if manifest.portable:
self._load_portable_plugin(manifest, loaded)
return
# requires_hermes gate: skip cleanly (no import, no traceback) on a version mismatch.
from hermes_cli.plugins_manifest import requires_hermes_error
reason = requires_hermes_error(manifest)
if reason:
loaded.error = reason
logger.warning("Plugin '%s' skipped: %s", plugin_key, reason)
self._plugins[plugin_key] = loaded
return
# After the compat-removal date an external plugin that still imports pre-decomposition paths is
# skipped with a clear reason instead of dying on ImportError mid-register (hermes_cli.plugin_compat).
from hermes_cli.plugin_compat import disable_reason
+56 -1
View File
@@ -6,8 +6,10 @@ Split out of :mod:`hermes_cli.plugins`; validation warns and never fails a load.
from __future__ import annotations
import hashlib
import importlib.metadata
import importlib.util
import logging
import re
from contextlib import suppress
from dataclasses import dataclass, field
from pathlib import Path
@@ -34,6 +36,7 @@ _KNOWN_MANIFEST_FIELDS: Set[str] = {
"pip_dependencies", "provides_browser_providers", "provides_web_providers",
"manifest_version", "api_version", "requires_plugins", "python_dependencies", "config_schema",
"license", "homepage", "tags", "capabilities", "emits", "listens", "hermes", "depends",
"requires_hermes",
}
# Highest manifest schema version this Hermes understands.
@@ -336,6 +339,9 @@ class PluginManifest:
# Path-derived registry key used by plugins.enabled/disabled and `hermes plugins list`: ``disk-cleanup``
# for a flat plugin, ``image_gen/openai`` for a category plugin. Empty -> name.
key: str = ""
# Hermes version requirement (``">=0.19"``, comma-separated clauses allowed). Unsatisfied plugins are
# recorded with an error and skipped before import — see ``requires_hermes_error``.
requires_hermes: str = ""
portable: bool = False
skill_namespace: str = ""
# Declared capability ids, normalized to KNOWN ids. Declaration is consent metadata, NOT a grant: live
@@ -364,6 +370,55 @@ class PluginManifest:
listens: List[str] = field(default_factory=list)
# ── requires_hermes version gate ─────────────────────────────────────────────
_VERSION_COMPARATOR_RE = re.compile(r"^\s*(>=|<=|==|!=|>|<)\s*(.+?)\s*$")
def running_hermes_version() -> str:
"""Installed ``hermes-agent`` distribution version, else ``hermes_cli.__version__`` (source checkout)."""
try:
return importlib.metadata.version("hermes-agent")
except Exception:
from hermes_cli import __version__
return __version__
def _version_tuple(v: str) -> Optional[tuple]:
"""``v1.2.3-rc1`` → ``(1, 2, 3)``; ``None`` when a segment is non-numeric."""
parts = re.split(r"[-+]", str(v).strip().lstrip("v"), 1)[0].split(".")
parts += ["0"] * (3 - len(parts))
try:
return tuple(int(x) for x in parts[:3])
except ValueError:
return None
def version_satisfies(spec: str, current: str) -> bool:
"""``>=``/``>``/``<=``/``<``/``==``/``!=`` clauses (comma = AND; bare version = ``>=``). Unparseable
versions are permissive — no PEP 440 dependency for a one-field manifest gate."""
cur = _version_tuple(current)
if cur is None:
return True
ops = {">=": cur.__ge__, "<=": cur.__le__, "==": cur.__eq__, "!=": cur.__ne__, ">": cur.__gt__, "<": cur.__lt__}
for clause in filter(None, (c.strip() for c in spec.split(","))):
m = _VERSION_COMPARATOR_RE.match(clause)
op, target = (m.group(1), m.group(2)) if m else (">=", clause)
tgt = _version_tuple(target)
if tgt is not None and not ops[op](tgt):
return False
return True
def requires_hermes_error(manifest: "PluginManifest") -> Optional[str]:
"""Load-blocking reason when the manifest's ``requires_hermes`` rejects the running version."""
if not manifest.requires_hermes:
return None
current = running_hermes_version()
if version_satisfies(manifest.requires_hermes, current):
return None
return f"requires hermes {manifest.requires_hermes}, running {current}"
def portable_plugin_manifest(child: Path, source: str, prefix: str) -> PluginManifest:
"""Build the manifest for a portable Agent Plugin directory (``plugin.json``); diagnostics warn."""
from hermes_cli.agent_plugins import read_agent_plugin_manifest
@@ -420,7 +475,7 @@ def parse_manifest_file(
requires_env=data.get("requires_env", []),
provides_tools=data.get("provides_tools", []),
provides_hooks=data.get("provides_hooks", []), source=source, path=str(plugin_dir),
kind=kind, key=key,
kind=kind, key=key, requires_hermes=str(data.get("requires_hermes") or "").strip(),
capabilities=_parse_declared_capabilities(data.get("capabilities"), name),
**_parse_manifest_v2_fields(data, key), emits=data.get("emits") or [],
listens=data.get("listens") or [],
@@ -409,3 +409,26 @@ class TestCtxHasPlugin:
finally:
if hasattr(sys, "_m2_probe"):
del sys._m2_probe
class TestRequiresHermes:
def test_unsatisfied_requires_hermes_skips_without_importing(self, hermes_home, monkeypatch):
"""A too-new ``requires_hermes`` records an error and never runs register(); a satisfied one loads."""
import sys
from hermes_cli import plugins_manifest
monkeypatch.setattr(plugins_manifest, "running_hermes_version", lambda: "1.2.3")
_write_plugin(hermes_home / "plugins", "future", manifest_extra={"requires_hermes": ">=99.0"},
register_body="import sys; sys._rh_future = True")
_write_plugin(hermes_home / "plugins", "current", manifest_extra={"requires_hermes": ">=1.2,<2"},
register_body="import sys; sys._rh_current = True")
_enable(hermes_home, ["future", "current"])
try:
mgr = PluginManager()
mgr.discover_and_load()
assert not hasattr(sys, "_rh_future")
assert "requires hermes >=99.0" in (mgr._plugins["future"].error or "")
assert getattr(sys, "_rh_current", False) is True
finally:
for attr in ("_rh_future", "_rh_current"):
if hasattr(sys, attr):
delattr(sys, attr)