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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user