feat(plugins): enforce the catalog's requires_hermes at install and update

The catalog entry's requires_hermes was display-only: `hermes plugins
install <name>` cloned and published a pin the catalog says needs a newer
Hermes, and `hermes plugins update` re-pinned an installed plugin onto it.
Only the plugin's own plugin.yaml floor was checked, and only at load.

- install_catalog_entry (the one seam every install by catalog name goes
  through: CLI, dashboard/Desktop/TUI plugins.manage, `hermes setup`'s
  importer step, the left-core and memory-provider auto-installs) refuses
  with "<plugin> needs Hermes <spec>; this is <version>. Update Hermes
  (`hermes update`) and install again." before any clone. The connector
  card's pre-check uses the same function.
- repin_catalog_plugin (every catalog update) keeps the installed pin and
  says the update is available after updating Hermes; `hermes plugins
  update` with no name lists such a plugin as held instead of applying it,
  and check-updates marks it.
- `hermes plugins search` / `info` still list incompatible entries,
  marked as needing a newer Hermes.

Portable packages (plugin.json) have no version field, so the catalog
entry's requires_hermes is their only floor; the gate is kind-agnostic and
covers them (tested), and the agent_plugins comment and docs say so.

The verdict is plugins_manifest.requires_hermes_error itself (same
running_hermes_version + version_satisfies), so a dev checkout, canary
build or tagless checkout is judged exactly as the plugin.yaml gate judges
it. No override flag.
This commit is contained in:
Teknium committed 2026-10-23 02:22:46 -07:00
1 parent e571027310
commit 0255dedd1a
12 files changed
+228 -18

No files matched your search

+3 -1
View File
@@ -111,7 +111,9 @@ def _server_declarations(
raise AgentPluginError(f"server declaration '{name}' must be an object")
# A key added by a newer Hermes must not make the package uninstallable here: the older
# release ignores it with a diagnostic, like unknown top-level fields. A key whose absence
# would be unsafe needs the catalog's requires_hermes, not a hard failure on every release.
# would be unsafe needs the catalog entry's requires_hermes, not a hard failure on every release:
# plugin.json has no version floor, and install/update by catalog name enforce the entry's
# (plugins_cmd_catalog.refuse_incompatible_catalog_entry / repin_catalog_plugin).
unknown = set(raw) - _SERVER_DECLARATION_KEYS
if unknown:
if diagnostics is not None:
+41 -3
View File
@@ -250,6 +250,29 @@ def _refuse_unsupported_catalog_platform(entry: PluginCatalogEntry) -> None:
)
def catalog_hermes_error(entry: PluginCatalogEntry) -> Optional[str]:
"""``<name> needs Hermes <spec>; this is <version>.`` when the entry's ``requires_hermes`` rejects the
running Hermes, else ``None``. The verdict IS the plugin.yaml gate's
(:func:`~hermes_cli.plugins_manifest.requires_hermes_error`): same version source, same comparison, so
a dev checkout or canary build is judged the same by both, and an unknown version never blocks."""
from hermes_cli.plugins_manifest import requires_hermes_error, running_hermes_version
if not requires_hermes_error({"requires_hermes": entry.requires_hermes}):
return None
return f"{entry.name} needs Hermes {entry.requires_hermes}; this is {running_hermes_version()}."
def refuse_incompatible_catalog_entry(entry: PluginCatalogEntry) -> None:
"""``PluginOperationError`` when the catalog says *entry* cannot run here: a platform it does not list,
or a ``requires_hermes`` the running version fails. Every install by catalog name passes through here."""
from hermes_cli.config import recommended_update_command
from hermes_cli.plugins_cmd import PluginOperationError
_refuse_unsupported_catalog_platform(entry)
reason = catalog_hermes_error(entry)
if reason:
raise PluginOperationError(f"{reason} Update Hermes (`{recommended_update_command()}`) and install again.",
failure_class="incompatible")
def install_catalog_entry(entry: PluginCatalogEntry, *, force: bool, ref: Optional[str] = None,
allow_removed: bool = False, scan_decision_cb=None, python_deps: bool = True,
assume_deps_consent: bool = False, before_swap=None) -> tuple:
@@ -259,7 +282,7 @@ def install_catalog_entry(entry: PluginCatalogEntry, *, force: bool, ref: Option
from hermes_cli.plugins_cmd import _install_plugin_core
if not allow_removed:
raise_if_removed(entry.name, entry.repo)
_refuse_unsupported_catalog_platform(entry)
refuse_incompatible_catalog_entry(entry)
target, manifest, installed_name = _install_plugin_core(
entry.install_identifier, force=force, ref=ref or entry.sha, scan_decision_cb=scan_decision_cb,
reviewed_pin=entry.sha, python_deps=python_deps, assume_deps_consent=assume_deps_consent,
@@ -581,6 +604,14 @@ def repin_catalog_plugin(
refuse_if_installed_removed(catalog_name, target)
if at_catalog_pin(sidecar, entry.sha):
return RepinResult(entry.sha, False, target.name, [])
held = catalog_hermes_error(entry)
if held:
# The pin moved past what this Hermes can run: keep the installed tree, name the way forward.
from hermes_cli.config import recommended_update_command
raise PluginOperationError(
f"Update of {catalog_name} to {entry.sha[:8]} not applied: {held} {catalog_name} stays at "
f"{str(sidecar.get('sha') or '')[:8] or 'its installed version'}; the update is available after "
f"you update Hermes (`{recommended_update_command()}`).", failure_class="incompatible")
local, modified = _local_changes(target)
old_sha8 = str(sidecar.get("sha") or "old")[:8]
@@ -718,7 +749,8 @@ def _render_entries(entries: list[PluginCatalogEntry], console) -> None:
for e in sorted(entries, key=lambda e: (e.category, rank.get(e.tier, len(rank)), e.name)):
tier = f"[{style.get(e.tier, 'magenta')}]{e.tier}[/{style.get(e.tier, 'magenta')}]"
desc = e.description if len(e.description) <= 60 else e.description[:57] + "..."
table.add_row(e.name, e.category, tier, desc, pin_label(e), _capability_counts(e))
name = e.name + (f"\n[yellow]needs Hermes {e.requires_hermes}[/yellow]" if catalog_hermes_error(e) else "")
table.add_row(name, e.category, tier, desc, pin_label(e), _capability_counts(e))
console.print()
console.print(table)
console.print()
@@ -730,7 +762,8 @@ def cmd_search(term: str = "", *, json_output: bool = False) -> None:
from hermes_cli.plugins_cmd import _console
matches = filter_entries(load_catalog_live(), term)
if json_output:
print(json.dumps({"query": term, "results": [e.to_dict() for e in matches]}, indent=2))
print(json.dumps({"query": term, "results": [
{**e.to_dict(), "needs_newer_hermes": catalog_hermes_error(e)} for e in matches]}, indent=2))
return
console = _console()
if not matches:
@@ -771,6 +804,11 @@ def cmd_info(name: str) -> None:
console.print(f"[red bold]✗ REMOVED from catalog: {removed.reason or 'no reason recorded'}"
f"{f' ({removed.date})' if removed.date else ''}[/red bold]")
console.print()
held = catalog_hermes_error(entry)
if held:
from hermes_cli.config import recommended_update_command
console.print(f"[yellow]⚠ {held} Update Hermes (`{recommended_update_command()}`) to install it.[/yellow]")
console.print()
console.print(f"[dim]Install:[/dim] hermes plugins install {entry.name}")
console.print()
+3 -1
View File
@@ -269,7 +269,7 @@ def cmd_check_updates(args: Any | None = None) -> None:
console = Console()
plugins_dir = _pc()._plugins_dir()
from hermes_cli.plugins_updates import run_checks
from hermes_cli.plugins_updates import held_for_newer_hermes, run_checks
from hermes_cli.plugins_cadence import record_check_results, snapshot_installs
@@ -290,6 +290,8 @@ def cmd_check_updates(args: Any | None = None) -> None:
for r in results:
if r.needs_fixing:
status = f"[red]needs fixing[/red]\n[dim]{r.needs_fixing}[/dim]"
elif r.update_available is True and (held := held_for_newer_hermes(r)):
status = f"[yellow]update available after updating Hermes[/yellow]\n[dim]{held}[/dim]"
elif r.update_available is True:
status = "[green]update available[/green]"
elif r.update_available is False:
+3
View File
@@ -39,10 +39,13 @@ class UpdatePlan:
def plan_update_all(results: list[Any]) -> UpdatePlan:
"""Split ``run_checks`` rows into the plugins ``update`` can move and the ones it cannot (with why)."""
from hermes_cli.plugins_updates import held_for_newer_hermes
plan = UpdatePlan()
for row in results:
if row.needs_fixing:
plan.skipped.append((row, f"needs fixing: {row.needs_fixing}"))
elif row.update_available is True and (held := held_for_newer_hermes(row)):
plan.skipped.append((row, f"update available after updating Hermes ({held})"))
elif row.update_available is True and row.klass in _UPDATE_KIND:
plan.movable.append(row)
elif row.update_available is True:
+1 -1
View File
@@ -480,7 +480,7 @@ def version_satisfies(spec: str, current: str) -> bool:
return True
def requires_hermes_error(manifest: PluginManifest) -> Optional[str]:
def requires_hermes_error(manifest: PluginManifest | Mapping[str, Any]) -> Optional[str]:
"""Load-blocking reason when the manifest's ``requires_hermes`` rejects the running version."""
spec = manifest.get("requires_hermes", "") if isinstance(manifest, Mapping) else manifest.requires_hermes
if not spec:
+14 -1
View File
@@ -51,7 +51,7 @@ class CheckResult:
latest: Optional[str] = None
update_available: Optional[bool] = None # None = unknown/uncheckable
needs_fixing: Optional[str] = None # mismatch reason when set
min_hermes: Optional[str] = None # feed's version floor, if any
min_hermes: Optional[str] = None # feed floor / catalog requires_hermes, if any
reason: str = ""
def to_json(self) -> dict:
@@ -130,6 +130,18 @@ def check_local_provenance(prov: Provenance) -> CheckResult:
return result
def held_for_newer_hermes(result: CheckResult) -> Optional[str]:
"""``needs Hermes <spec>; this is <version>`` when the update's ``min_hermes`` (a feed floor or the
catalog entry's ``requires_hermes``) rejects the running Hermes, else ``None``. The update path refuses
such an update, so listings name it as waiting on a Hermes update instead of offering it."""
if not result.min_hermes:
return None
from hermes_cli.plugins_manifest import requires_hermes_error, running_hermes_version
if not requires_hermes_error({"requires_hermes": result.min_hermes}):
return None
return f"needs Hermes {result.min_hermes}; this is {running_hermes_version()}"
def check_provenanced(
prov: Provenance,
*,
@@ -158,6 +170,7 @@ def check_provenanced(
return result
result.latest = entry.sha
result.update_available = result.current != entry.sha
result.min_hermes = entry.requires_hermes or None
return result
result = check_local_provenance(prov)
if result.reason or result.needs_fixing:
+11 -4
View File
@@ -114,9 +114,16 @@ meaningful:
reporting are opt-in. Reviewers summarise these as disclosure lines on the
entry PR; undisclosed behaviour found in review is a request for changes.
14. **Compatibility metadata is truthful.** `requires_hermes` is a SemVer floor
(`">=0.21.5"`), never a CalVer date, and never newer than the current release
(the loader skips the plugin otherwise). `version` matches the pinned code,
and Python dependencies resolve under Hermes's core constraints
(`">=0.21.5"`), never a CalVer date, and never newer than the current release.
The entry's `requires_hermes` is enforced: an install by catalog name refuses
on a Hermes it rejects, and `hermes plugins update` keeps an installed plugin
at its current pin until Hermes is updated. Declare the same floor in
`plugin.yaml` as well, because older Hermes versions enforce only that one
(the loader skips the plugin when it does not match). A portable package
(`plugin.json`) has no version field of its own, so the catalog entry's
`requires_hermes` is its only floor: set one when the package needs a recent
Hermes (a server declaration key an older release ignores). `version` matches the
pinned code, and Python dependencies resolve under Hermes's core constraints
(`hermes plugins validate --install-deps` is what CI runs).
15. **No skins or forks of bundled plugins.** A change to a bundled plugin is a
PR against `hermes-agent`, not a competing listing, and vendor-lookalike skins
@@ -168,7 +175,7 @@ tier: community # official | verified | community (default community
# official and verified are set by Nous maintainers only
category: memory # desktop | memory | platform | web | tools | voice | automation | models | general
# (default desktop) — the shelf the entry sits on at /docs/plugins
requires_hermes: ">=0.19" # optional
requires_hermes: ">=0.19" # optional; enforced at install and update (also declare it in plugin.yaml)
docs_url: "" # optional
version: "1.4.0" # optional human label for the sha (quote it); shown as "1.4.0 @ abcd1234"
image: "" # optional https image on a GitHub host, 2:1 (e.g. 1200x600), e.g.
@@ -0,0 +1,135 @@
"""The catalog entry's ``requires_hermes`` is enforced, not just displayed: an install by catalog name
refuses on a Hermes the spec rejects (CLI, dashboard/Desktop, TUI RPC and the left-core auto-install all
go through one seam), and ``update`` keeps the installed pin rather than moving to one that needs a newer
Hermes. The running version comes from the same source the plugin.yaml gate reads. Real git, file://
repos, a seeded catalog, temp HERMES_HOME."""
from __future__ import annotations
import json
import shutil
import subprocess as sp
import pytest
from hermes_cli import left_core_migration as lcm
from hermes_cli import plugin_catalog as pc_cat
from hermes_cli import plugins_cmd as pc
from hermes_cli import plugins_manifest
from tests.hermes_cli.test_plugins_cmd_catalog import _commit, _head, world # world: the seeded catalog + plugins dir
from tests.pm._fixtures import client, isolated_python # fixtures world needs
pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git not available")
REFUSAL = "cat-plugin needs Hermes >=0.31; this is 0.30.0."
@pytest.fixture
def gated(request, monkeypatch):
"""*world* whose catalog entry carries ``state["requires"]`` as its requires_hermes, on Hermes 0.30.0.
Console output is captured in ``printed``."""
base = request.getfixturevalue("world")
state = base["state"]
state["requires"] = ""
def _entries():
return [pc_cat.PluginCatalogEntry(name="cat-plugin", repo=state.get("repo_uri") or base["repo"].as_uri(),
sha=state["pin"],
description="d", maintainer="t", requires_hermes=state["requires"])]
monkeypatch.setattr(pc_cat, "load_catalog", lambda catalog_dir=None: _entries())
state["running"] = "0.30.0"
monkeypatch.setattr(plugins_manifest, "running_hermes_version", lambda: state["running"])
printed: list[str] = []
console = type("C", (), {"print": lambda _self, *a, **_k: printed.append(" ".join(map(str, a)))})()
monkeypatch.setattr(pc, "_console", lambda: console)
return {**base, "printed": printed}
def _rpc(params: dict) -> dict:
from tui_gateway import server
return server.handle_request({"id": "1", "method": "plugins.manage", "params": params})
def test_install_by_catalog_name_refused_on_every_surface_when_hermes_is_too_old(gated):
gated["state"]["requires"] = ">=0.31"
with pytest.raises(SystemExit): # `hermes plugins install <name>` (and `hermes setup`'s importer step)
pc.cmd_install("cat-plugin", enable=False)
assert any(REFUSAL in line and "and install again" in line for line in gated["printed"])
dashboard = pc.dashboard_install_plugin("", force=False, enable=True, catalog_name="cat-plugin")
assert dashboard["ok"] is False and REFUSAL in dashboard["error"]
rpc = _rpc({"action": "install", "catalog_name": "cat-plugin"}) # Desktop / TUI
assert REFUSAL in rpc["error"]["message"]
said: list[str] = []
feature = lcm.LeftCoreFeature(plugin="cat-plugin", label="Cat", in_use=lambda *_a, **_k: True, unchanged="x")
assert lcm._install_one(gated["plugins_dir"].parent, feature,
install=lcm._install_into(gated["plugins_dir"].parent), say=said.append) is False
assert REFUSAL.rstrip(".") in said[0]
assert not (gated["plugins_dir"] / "cat-plugin").exists()
@pytest.mark.parametrize(("running", "spec"), [("0.30.0", ">=0.30"), ("unknown", ">=99.0")])
def test_install_proceeds_when_hermes_satisfies_the_spec_or_its_version_is_unknown(gated, running, spec):
# "unknown" is a tagless checkout: the plugin.yaml gate lets it through, so the catalog gate must too.
gated["state"].update(running=running, requires=spec)
assert plugins_manifest.requires_hermes_error({"requires_hermes": spec}) is None
result = pc.dashboard_install_plugin("", force=False, enable=False, catalog_name="cat-plugin")
assert result["ok"] is True, result
assert _head(gated["plugins_dir"] / "cat-plugin") == gated["sha1"]
def test_update_keeps_the_installed_pin_when_the_new_pin_needs_a_newer_hermes(gated):
from hermes_cli.plugins_cmd_update_all import plan_update_all
from hermes_cli.plugins_updates import run_checks
assert pc.dashboard_install_plugin("", force=False, enable=False, catalog_name="cat-plugin")["ok"] is True
target = gated["plugins_dir"] / "cat-plugin"
gated["state"].update(pin=gated["sha2"], requires=">=0.31")
with pytest.raises(SystemExit): # `hermes plugins update cat-plugin`
pc.cmd_update("cat-plugin", interactive=False)
assert any(REFUSAL in line and "after you update Hermes" in line for line in gated["printed"])
dashboard = pc.dashboard_update_user_plugin("cat-plugin")
assert dashboard["ok"] is False and REFUSAL in dashboard["error"]
assert REFUSAL in _rpc({"action": "update", "name": "cat-plugin"})["error"]["message"]
# `hermes plugins update` (all) lists it as held, not as an update it will apply.
plan = plan_update_all(run_checks(gated["plugins_dir"], include_pip=False))
assert not plan.movable
assert [(row.name, "after updating Hermes" in why) for row, why in plan.skipped] == [("cat-plugin", True)]
assert _head(target) == gated["sha1"]
gated["state"]["running"] = "0.31.0"
assert pc.dashboard_update_user_plugin("cat-plugin")["unchanged"] is False
assert _head(target) == gated["sha2"]
def test_portable_catalog_entry_gets_its_hermes_floor_from_the_catalog(gated, tmp_path):
# A portable package (plugin.json) has no requires_hermes of its own: the catalog entry is its only floor.
from hermes_cli.agent_plugins import PLUGIN_SCHEMA_V1
repo = tmp_path / "portable-repo"
repo.mkdir()
sp.run(["git", "init", "-q"], cwd=repo, check=True)
shas = []
for version in ("1.0.0", "2.0.0"):
(repo / "plugin.json").write_text(json.dumps(
{"$schema": PLUGIN_SCHEMA_V1, "name": "cat-plugin", "version": version}), encoding="utf-8")
shas.append(_commit(repo, version))
gated["state"].update(repo_uri=repo.as_uri(), pin=shas[0], requires=">=0.31")
refused = pc.dashboard_install_plugin("", force=False, enable=False, catalog_name="cat-plugin")
assert refused["ok"] is False and REFUSAL in refused["error"]
assert not (gated["plugins_dir"] / "cat-plugin").exists()
gated["state"]["requires"] = ""
assert pc.dashboard_install_plugin("", force=False, enable=False, catalog_name="cat-plugin")["ok"] is True
gated["state"].update(pin=shas[1], requires=">=0.31")
held = pc.dashboard_update_user_plugin("cat-plugin")
assert held["ok"] is False and REFUSAL in held["error"]
assert _head(gated["plugins_dir"] / "cat-plugin") == shas[0]
+2 -2
View File
@@ -85,10 +85,10 @@ class HostInstaller:
def refuse(self, entry: Any) -> None:
"""Raise with the installer's own text when the catalog would refuse this entry here."""
from hermes_cli.plugins_cmd_catalog import _refuse_unsupported_catalog_platform, raise_if_removed
from hermes_cli.plugins_cmd_catalog import raise_if_removed, refuse_incompatible_catalog_entry
raise_if_removed(entry.name, entry.repo)
_refuse_unsupported_catalog_platform(entry)
refuse_incompatible_catalog_entry(entry)
def install_plugin(self, name: str, *, force: bool, enable: bool, ref: Optional[str],
on_step: Callable[[InstallPhase], None]) -> dict[str, Any]:
@@ -172,9 +172,16 @@ the gallery. There is no separate listing to maintain.
reporting are opt-in. Reviewers summarise these as disclosure lines on the
entry PR; undisclosed behaviour found in review is a request for changes.
14. **Compatibility metadata is truthful.** `requires_hermes` is a SemVer floor
(`">=0.21.5"`), never a CalVer date, and never newer than the current release
(the loader skips the plugin otherwise). `version` matches the pinned code,
and Python dependencies resolve under Hermes's core constraints
(`">=0.21.5"`), never a CalVer date, and never newer than the current release.
The entry's `requires_hermes` is enforced: an install by catalog name refuses
on a Hermes it rejects, and `hermes plugins update` keeps an installed plugin
at its current pin until Hermes is updated. Declare the same floor in
`plugin.yaml` as well, because older Hermes versions enforce only that one
(the loader skips the plugin when it does not match). A portable package
(`plugin.json`) has no version field of its own, so the catalog entry's
`requires_hermes` is its only floor: set one when the package needs a recent
Hermes (a server declaration key an older release ignores). `version` matches the
pinned code, and Python dependencies resolve under Hermes's core constraints
(`hermes plugins validate --install-deps` is what CI runs).
15. **No skins or forks of bundled plugins.** A change to a bundled plugin is a
PR against `hermes-agent`, not a competing listing, and vendor-lookalike skins
@@ -210,7 +210,10 @@ A server entry key this Hermes does not know (one a newer release added) is
ignored with a warning, which `hermes plugins validate` lists, and the package
still installs. Releases from before this rule reject the package instead, and a
release that ignores a key may behave less safely than intended, so declare a new
key together with a catalog `requires_hermes` floor.
key together with a catalog `requires_hermes` floor. `plugin.json` has no version
field, so that catalog floor is a portable package's only one: an install by
catalog name refuses on an older Hermes, and `hermes plugins update` keeps the
installed pin until Hermes is updated.
An explicit per-server MCP call budget also reaches Hermes' outer tool executor. A call
whose budget exceeds the shared concurrent-batch deadline becomes a sequential
@@ -53,7 +53,7 @@ directory of the hermes-agent repository, declaring:
| `category` | Browse shelf: `desktop` (default), `memory`, `platform`, `web`, `tools`, `voice`, `automation`, `models` or `general` |
| `maintainer` | Who owns the plugin |
| `capabilities` | Declared tools, hooks, middleware, and required env vars |
| `requires_hermes` | Minimum Hermes version, e.g. `>=0.19` (optional) |
| `requires_hermes` | Minimum Hermes version, e.g. `>=0.19` (optional). Enforced: installing on an older Hermes is refused with the version it needs, and `hermes plugins update` keeps the installed version until you update Hermes. `search` and `info` still list the entry, marked as needing a newer Hermes |
| `platforms` | OS restrictions, empty = all (optional) |
| `title` | Human name shown on cards, e.g. `NVIDIA App` (optional; defaults to `name`) |
| `onboarding` | `true` offers the plugin on the desktop onboarding card, beside the hosted connectors, on the platforms it lists. Curated: official or verified entries only (optional, default `false`) |