refactor(plugins): kill-list annotation takes the pre-resolved list directly; trim to two invariants

Follow-up to the salvaged #113687 / #113682 commits:

- `removed_annotation(name, dir_path, removed_entries=None)` resolves the kill list once itself
  when no list is passed and matches against it; the `removed_annotation_batch()` wrapper and its
  name-keyed dict are gone (a hub row keyed by `name` could shadow a same-named nested plugin).
  Both surfaces (`plugins list`, `_merged_plugins_hub`) call `resolved_removed_entries()` once
  and pass the list per row.
- The negative cache is one module float deadline instead of a URL-keyed dict plus two helpers;
  the duplicated stale-cache read is one `_stale_live_cache()`.
- Tests trimmed to two invariants: failure memory honours the TTL and still serves a stale copy;
  hub rebuild + `plugins list` each cost one network attempt and still annotate an in-tree removal.
  The #113682 route test's fake gains the new third argument.
- Docs: the live-refresh section says a failed fetch is remembered for a minute.
This commit is contained in:
teknium1
2026-09-18 00:44:58 -07:00
committed by Teknium
parent 6165b6ebd5
commit a89ef71b10
7 changed files with 105 additions and 288 deletions

View File

@@ -272,21 +272,18 @@ def _live_cache_path() -> Path:
return get_hermes_home() / "cache" / "plugin-catalog.json"
# Last failed live fetch: without it, a dead catalog host costs one full request
# timeout PER CALLER (the plugins hub alone asks once per installed plugin), so
# the dashboard event loop stalls for minutes. A remembered failure keeps those
# callers on the in-tree copy until the TTL lets one fresh attempt through.
_live_fetch_failed_until: Dict[str, float] = {}
# Wall-clock deadline of the last failed live fetch. Without it a dead catalog host costs one
# full request timeout PER CALL (the plugins hub and ``plugins list`` used to ask once per
# installed plugin), so the dashboard event loop stalled for minutes.
_live_fetch_failed_until = 0.0
def _live_fetch_failure_recent() -> bool:
failed_until = _live_fetch_failed_until.get(LIVE_CATALOG_URL)
return failed_until is not None and time.time() < failed_until
def _remember_live_fetch_failure() -> None:
failed_until = time.time() + LIVE_CATALOG_FAILURE_TTL_SECONDS
_live_fetch_failed_until[LIVE_CATALOG_URL] = failed_until
def _stale_live_cache(cache: Path) -> Optional[Dict[str, Any]]:
"""A previously fetched copy still beats the in-tree one when the network is down."""
try:
return json.loads(cache.read_text(encoding="utf-8")) if cache.is_file() else None
except Exception:
return None
def fetch_live_catalog(*, force: bool = False) -> Optional[Dict[str, Any]]:
@@ -294,21 +291,16 @@ def fetch_live_catalog(*, force: bool = False) -> Optional[Dict[str, Any]]:
``HERMES_HOME/cache`` for :data:`LIVE_CATALOG_TTL_SECONDS`. ``None`` on ANY failure — callers fall
back to the in-tree catalog. A failed network attempt is remembered for
:data:`LIVE_CATALOG_FAILURE_TTL_SECONDS` so a dead host costs one timeout per TTL window, not one
per caller (``force`` bypasses both caches)."""
if not force and _live_fetch_failure_recent():
try: # stale cache still beats the in-tree copy when the network is down
cache = _live_cache_path()
if cache.is_file():
return json.loads(cache.read_text(encoding="utf-8"))
except Exception:
pass
return None
per call (``force`` bypasses both caches)."""
global _live_fetch_failed_until
cache = _live_cache_path()
try:
if not force and cache.is_file() and time.time() - cache.stat().st_mtime < LIVE_CATALOG_TTL_SECONDS:
return json.loads(cache.read_text(encoding="utf-8"))
except Exception as exc:
logger.debug("Plugin catalog: unreadable live cache %s: %s", cache, exc)
if not force and time.time() < _live_fetch_failed_until:
return _stale_live_cache(cache)
try:
import httpx
from hermes_constants import mkdir_under_hermes_home
@@ -325,11 +317,8 @@ def fetch_live_catalog(*, force: bool = False) -> Optional[Dict[str, Any]]:
return data
except Exception as exc:
logger.debug("Plugin catalog: live fetch failed: %s", exc)
_remember_live_fetch_failure()
try: # stale cache still beats the in-tree copy when the network is down
return json.loads(cache.read_text(encoding="utf-8")) if cache.is_file() else None
except Exception:
return None
_live_fetch_failed_until = time.time() + LIVE_CATALOG_FAILURE_TTL_SECONDS
return _stale_live_cache(cache)
def load_catalog_live() -> List[PluginCatalogEntry]:

View File

@@ -1434,16 +1434,11 @@ def cmd_list(args: Any | None = None) -> None:
pins = _read_install_metadata()
# One kill-list resolution for the whole listing: resolving per row costs a live-catalog
# fetch per installed plugin when the catalog host is slow or unreachable.
resolved_removed = catalog.resolved_removed_entries()
removed_entries = catalog.resolved_removed_entries()
rows = [
(
name,
_plugin_status(name, enabled, disabled, key=key),
str(version),
description,
catalog.catalog_annotation(_dir) or _pin_annotation(name, pins) or source,
catalog.removed_annotation(name, _dir, removed_entries=resolved_removed),
)
(name, _plugin_status(name, enabled, disabled, key=key), str(version), description,
catalog.catalog_annotation(_dir) or _pin_annotation(name, pins) or source,
catalog.removed_annotation(name, _dir, removed_entries))
for name, version, description, source, _dir, key in entries
]

View File

@@ -15,10 +15,10 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from hermes_cli.plugin_catalog import (
PluginCatalogEntry, entry_capability_summary, filter_entries, find_removed, get_live_catalog_entry,
load_catalog_live, load_removed_list, _NAME_RE,
PluginCatalogEntry, RemovedEntry, entry_capability_summary, filter_entries, find_removed,
get_live_catalog_entry, load_catalog_live, load_removed_list, match_removed, resolved_removed_entries,
_NAME_RE,
)
from hermes_cli.plugin_catalog import match_removed, resolved_removed_entries
logger = logging.getLogger(__name__)
@@ -94,47 +94,23 @@ def catalog_annotation(dir_path) -> Optional[str]:
return f"catalog:{sidecar.get('tier') or 'community'}@{str(sidecar.get('sha') or '')[:8]}"
def removed_annotation(name: str, dir_path, *, removed_entries=None) -> Optional[str]:
def removed_annotation(name: str, dir_path, removed_entries: Optional[List[RemovedEntry]] = None) -> Optional[str]:
"""Kill-list reason when an INSTALLED plugin matches by name, catalog name or repo, else ``None``.
``removed_entries`` reuses one pre-resolved kill list (``resolved_removed_entries()``) across
many rows; resolving per row costs a live-catalog fetch per installed plugin.
Callers annotating many rows (``plugins list``, the dashboard hub) resolve the kill list once
with :func:`plugin_catalog.resolved_removed_entries` and pass it as ``removed_entries``:
resolving per row cost one live-catalog fetch — one network timeout, offline — per plugin.
"""
if removed_entries is not None:
return removed_annotation_batch([(name, dir_path)], removed_entries).get(name)
if removed_entries is None:
removed_entries = resolved_removed_entries()
sidecar = read_catalog_sidecar(dir_path) or {}
for candidate in (name, sidecar.get("catalog_name"), sidecar.get("repo")):
removed = find_removed(str(candidate)) if candidate else None
removed = match_removed(str(candidate), removed_entries) if candidate else None
if removed is not None:
return removed.reason or "no reason recorded"
return None
def removed_annotation_batch(
names_and_dirs, removed_entries
) -> Dict[str, Optional[str]]:
"""``removed_annotation`` for many plugins against ONE pre-resolved kill list.
The dashboard plugins hub annotates every installed plugin on every rebuild; resolving the kill
list per plugin turns the hub into one live-catalog fetch per row. Callers resolve the list once
(:func:`plugin_catalog.resolved_removed_entries`) and pass it here.
"""
annotations: Dict[str, Optional[str]] = {}
for name, dir_path in names_and_dirs:
sidecar = read_catalog_sidecar(dir_path) or {}
candidates = (name, sidecar.get("catalog_name"), sidecar.get("repo"))
for candidate in candidates:
if not candidate:
continue
removed = match_removed(str(candidate), removed_entries)
if removed is not None:
annotations[name] = removed.reason or "no reason recorded"
break
else:
annotations[name] = None
return annotations
# ── Catalog-aware install / update ───────────────────────────────────────────
def install_catalog_entry(entry: PluginCatalogEntry, *, force: bool, ref: Optional[str] = None,

View File

@@ -674,7 +674,7 @@ def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]:
_get_enabled_set,
_read_manifest as _read_plugin_manifest_at,
)
from hermes_cli.plugins_cmd_catalog import removed_annotation_batch
from hermes_cli.plugins_cmd_catalog import removed_annotation
from hermes_cli.plugin_catalog import resolved_removed_entries
dashboard_list = _get_dashboard_plugins()
@@ -685,15 +685,11 @@ def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]:
plugins_root_resolved = (get_hermes_home() / "plugins").resolve()
rows: List[Dict[str, Any]] = []
discovered = _discover_all_plugins()
# One kill-list resolution for the whole rebuild: resolving per row costs a live-catalog
# fetch per installed plugin when the catalog host is slow or unreachable.
removed_annotations = removed_annotation_batch(
((name, dir_str) for name, _v, _d, _s, dir_str, _k in discovered),
resolved_removed_entries(),
)
removed_entries = resolved_removed_entries()
for name, version, description, source, dir_str, key in discovered:
for name, version, description, source, dir_str, key in _discover_all_plugins():
# Both the path-derived key (nested category plugins) and the bare manifest name
# count for enabled/disabled state, matching the runtime loader's back-compat lookup.
aliases = {name, key} if key else {name}
@@ -725,7 +721,7 @@ def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]:
"auth_required": auth_required,
"auth_command": auth_command,
"user_hidden": name in hidden_plugins,
"removed_reason": removed_annotations.get(name),
"removed_reason": removed_annotation(name, dir_str, removed_entries),
})
agent_names = {r["name"] for r in rows}

View File

@@ -1,48 +1,38 @@
"""Plugins hub must not multiply live-catalog fetches (issue #113677 fix contract).
"""Live plugin-catalog lookups must not multiply per installed plugin.
A hub rebuild annotates every installed plugin with the kill-list reason; resolving the kill list
per row cost one synchronous catalog HTTPS request per candidate, so a slow/unreachable catalog
host stalled the dashboard event loop for minutes. These tests pin the two halves of the fix:
one kill-list resolution per rebuild, and a remembered failed fetch within a short TTL window.
A slow or unreachable catalog host used to cost one request timeout per plugin row, on the
dashboard plugins-hub rebuild (inline on the event loop) and on ``hermes plugins list``.
"""
from __future__ import annotations
import argparse
import json
import threading
import os
from pathlib import Path
from types import SimpleNamespace
import pytest
from hermes_cli import plugin_catalog as pc
from hermes_cli import plugins_cmd_catalog as pcc
from hermes_cli import plugins_cmd
from hermes_cli import web_server
import hermes_cli.config as _cfg_mod
import hermes_cli.web_server_dashboard as _web_server_dashboard
import hermes_cli.web_server_memory as _web_server_memory
from hermes_cli import plugins_cmd
from tools import registry as tools_registry
_PLUGIN_ROWS = [
("demo", "1.0.0", "demo plugin", "user", "/tmp/demo-plugin", "demo"),
("second", "0.2.0", "second plugin", "user", "/tmp/second-plugin", "second"),
]
@pytest.fixture(autouse=True)
def _reset_live_fetch_state(monkeypatch, tmp_path):
monkeypatch.setattr(pc, "_live_fetch_failed_until", {})
monkeypatch.setattr(
pc, "_live_cache_path", lambda: tmp_path / "cache" / "plugin-catalog.json"
)
def _isolated_live_catalog(monkeypatch, tmp_path):
monkeypatch.setattr(pc, "_live_fetch_failed_until", 0.0, raising=False)
monkeypatch.setattr(pc, "_live_cache_path", lambda: tmp_path / "cache" / "plugin-catalog.json")
tools_registry.invalidate_check_fn_cache()
_web_server_dashboard._invalidate_plugins_hub_cache()
class _UnreachableCatalog:
"""Counts network attempts; every attempt blocks like a real timeout, then fails."""
"""Counts network attempts; every one fails like a dead host."""
def __init__(self):
self.attempts = 0
@@ -52,200 +42,67 @@ class _UnreachableCatalog:
raise OSError("catalog host unreachable")
def _patch_hub_dependencies(monkeypatch, *, rows=None):
rows = rows if rows is not None else list(_PLUGIN_ROWS)
monkeypatch.setattr(
web_server, "_get_dashboard_plugins", lambda force_rescan=False: []
)
monkeypatch.setattr(
_web_server_memory, "_discover_memory_provider_statuses", lambda: []
)
def test_failed_live_fetch_is_remembered_until_the_failure_ttl(monkeypatch, tmp_path):
"""One network attempt per failure window; inside it a stale on-disk copy still answers
(removals published before the outage keep blocking) and the network is left alone."""
clock = {"now": 1_000_000.0}
monkeypatch.setattr(pc.time, "time", lambda: clock["now"])
unreachable = _UnreachableCatalog()
monkeypatch.setattr("httpx.get", unreachable)
assert pc.fetch_live_catalog() is None
assert pc.fetch_live_catalog() is None
assert unreachable.attempts == 1
cache = pc._live_cache_path()
cache.parent.mkdir(parents=True)
cache.write_text(json.dumps({"entries": [], "removed": [{"name": "pulled-live", "reason": "cve"}]}))
stale = clock["now"] - pc.LIVE_CATALOG_TTL_SECONDS - 1 # older than the success TTL under the fake clock
os.utime(cache, (stale, stale))
assert pc.fetch_live_catalog()["removed"][0]["name"] == "pulled-live"
assert unreachable.attempts == 1
clock["now"] += pc.LIVE_CATALOG_FAILURE_TTL_SECONDS + 1
pc.fetch_live_catalog()
assert unreachable.attempts == 2 # window over: exactly one fresh attempt
_PLUGIN_ROWS = [
("demo", "1.0.0", "demo plugin", "user", "/tmp/demo-plugin", "demo"),
("second", "0.2.0", "second plugin", "user", "/tmp/second-plugin", "second"),
("third", "0.3.0", "third plugin", "user", "/tmp/third-plugin", "third"),
]
def test_hub_rebuild_and_plugins_list_resolve_the_kill_list_once(monkeypatch, tmp_path, capsys):
"""Both listing surfaces cost at most ONE network attempt with the catalog unreachable — not
one per installed plugin — and still report an in-tree removal for every affected row."""
unreachable = _UnreachableCatalog()
monkeypatch.setattr("httpx.get", unreachable)
monkeypatch.setattr(pc, "get_catalog_dir", lambda: tmp_path)
(tmp_path / "removed.yaml").write_text("removed:\n- name: demo\n reason: exfiltrated env vars\n")
monkeypatch.setattr(web_server, "_get_dashboard_plugins", lambda force_rescan=False: [])
monkeypatch.setattr(_web_server_memory, "_discover_memory_provider_statuses", lambda: [])
monkeypatch.setattr(_cfg_mod, "get_hermes_home", lambda: Path("/tmp/hermes-home"))
monkeypatch.setattr(
_cfg_mod, "load_config", lambda: {"dashboard": {"hidden_plugins": []}}
)
monkeypatch.setattr(plugins_cmd, "_discover_all_plugins", lambda: rows)
monkeypatch.setattr(
plugins_cmd, "_get_current_context_engine", lambda: "compressor"
)
monkeypatch.setattr(_cfg_mod, "load_config", lambda: {"dashboard": {"hidden_plugins": []}})
monkeypatch.setattr(plugins_cmd, "_discover_all_plugins", lambda: list(_PLUGIN_ROWS))
monkeypatch.setattr(plugins_cmd, "_get_current_context_engine", lambda: "compressor")
monkeypatch.setattr(plugins_cmd, "_get_current_memory_provider", lambda: "")
monkeypatch.setattr(plugins_cmd, "_discover_context_engines", lambda: [])
monkeypatch.setattr(plugins_cmd, "_get_disabled_set", lambda: set())
monkeypatch.setattr(plugins_cmd, "_get_enabled_set", lambda: {"demo"})
monkeypatch.setattr(
plugins_cmd, "_read_manifest", lambda _path: {"provides_tools": []}
)
monkeypatch.setattr(
tools_registry.registry,
"get_entry",
lambda _name: SimpleNamespace(check_fn=None),
)
def test_hub_rebuild_issues_one_network_attempt_then_none(monkeypatch):
"""An unreachable catalog host costs ONE timeout per rebuild (not one per installed plugin),
and rebuilds inside the failure window cost none at all."""
unreachable = _UnreachableCatalog()
monkeypatch.setattr("httpx.get", unreachable)
_patch_hub_dependencies(monkeypatch)
monkeypatch.setattr(plugins_cmd, "_read_manifest", lambda _path: {"provides_tools": []})
monkeypatch.setattr(plugins_cmd, "_read_install_metadata", lambda: {})
monkeypatch.setattr(tools_registry.registry, "get_entry", lambda _name: SimpleNamespace(check_fn=None))
payload = _web_server_dashboard._merged_plugins_hub(force_refresh=True)
assert len(payload["plugins"]) == len(_PLUGIN_ROWS)
assert all(row["removed_reason"] is None for row in payload["plugins"])
by_name = {row["name"]: row["removed_reason"] for row in payload["plugins"]}
assert by_name == {"demo": "exfiltrated env vars", "second": None, "third": None}
assert unreachable.attempts == 1
_web_server_dashboard._invalidate_plugins_hub_cache()
_web_server_dashboard._merged_plugins_hub(force_refresh=True)
assert unreachable.attempts == 1 # remembered failure: no second timeout
def test_failed_fetch_is_remembered_only_within_ttl(monkeypatch):
clock = {"now": 1_000_000.0}
attempts = {"count": 0}
def fake_get(url, **kwargs):
attempts["count"] += 1
raise OSError("catalog host unreachable")
monkeypatch.setattr(pc.time, "time", lambda: clock["now"])
monkeypatch.setattr("httpx.get", fake_get)
assert pc.fetch_live_catalog() is None
pc.fetch_live_catalog() # remembered failure: served without a second attempt
assert attempts["count"] == 1
clock["now"] += pc.LIVE_CATALOG_FAILURE_TTL_SECONDS + 1
assert (
pc.fetch_live_catalog() is None
) # TTL expired: one fresh attempt reaches the network
assert attempts["count"] == 2
def test_failure_window_prefers_stale_cache_over_in_tree(monkeypatch, tmp_path):
"""Inside the failure window a previously fetched on-disk cache still answers — removals
published before the outage keep blocking without any network traffic."""
cache = tmp_path / "cache" / "plugin-catalog.json"
cache.parent.mkdir(parents=True)
cache.write_text(
json.dumps({
"entries": [],
"removed": [{"name": "pulled-live", "reason": "cve"}],
})
)
monkeypatch.setattr("httpx.get", _UnreachableCatalog())
data = pc.fetch_live_catalog()
assert data is not None and data["removed"][0]["name"] == "pulled-live"
assert pc.fetch_live_catalog() is not None # still served from the stale cache
def test_batch_annotation_matches_per_plugin_lookup(monkeypatch, tmp_path):
"""The batch resolver returns exactly what per-plugin ``removed_annotation`` returns, across
name matches, sidecar catalog names and repo URLs."""
kill_list = [
pc.RemovedEntry(
name="evil", repo="https://github.com/x/evil.git", reason="malware"
),
pc.RemovedEntry(name="pulled-live", reason="cve"),
]
monkeypatch.setattr(pc, "load_removed_list", lambda *a, **k: kill_list)
monkeypatch.setattr(pc, "live_removed_list", lambda: [])
sidecar_dir = tmp_path / "installed-from-catalog"
sidecar_dir.mkdir()
(sidecar_dir / pcc.CATALOG_SIDECAR).write_text(
json.dumps({
"catalog_name": "pulled-live",
"repo": "https://github.com/x/other",
"sha": "0" * 40,
"tier": "community",
})
)
repo_dir = tmp_path / "installed-from-repo"
repo_dir.mkdir()
(repo_dir / pcc.CATALOG_SIDECAR).write_text(
json.dumps({
"catalog_name": "unknown",
"repo": "https://github.com/x/EVIL/",
"sha": "0" * 40,
"tier": "community",
})
)
plain_dir = tmp_path / "plain"
plain_dir.mkdir()
plugins = [
("evil", plain_dir),
("catalog-install", sidecar_dir),
("repo-install", repo_dir),
("fine", plain_dir),
]
batch = pcc.removed_annotation_batch(plugins, pc.resolved_removed_entries())
for name, dir_path in plugins:
assert batch[name] == pcc.removed_annotation(name, dir_path)
assert batch["evil"] == "malware"
assert batch["catalog-install"] == "cve"
assert (
batch["repo-install"] == "malware"
) # repo match is .git/trailing-slash insensitive
assert batch["fine"] is None
def test_hub_rebuild_annotates_from_preloaded_kill_list(monkeypatch, tmp_path):
"""The hub surfaces removal reasons from ONE resolved kill list even when the live catalog
is unreachable — an installed removed plugin is still reported, at hub-rebuild speed."""
monkeypatch.setattr("httpx.get", _UnreachableCatalog())
monkeypatch.setattr(pc, "get_catalog_dir", lambda: tmp_path)
(tmp_path / "removed.yaml").write_text(
"removed:\n- name: demo\n reason: exfiltrated env vars\n"
)
_patch_hub_dependencies(monkeypatch)
payload = _web_server_dashboard._merged_plugins_hub(force_refresh=True)
by_name = {row["name"]: row["removed_reason"] for row in payload["plugins"]}
assert by_name["demo"] == "exfiltrated env vars"
assert by_name["second"] is None
def test_cmd_list_resolves_kill_list_once(monkeypatch, tmp_path, capsys):
"""``hermes plugins list`` resolves the kill list ONCE per listing, not per row: a slow or
unreachable live catalog must cost one resolution (one network attempt at most) regardless
of how many plugins are installed."""
import argparse
calls = {"load_removed_list": 0, "live_removed_list": 0}
kill_list = [pc.RemovedEntry(name="pulled-plugin", reason="security review")]
def counted_load_removed(*args, **kwargs):
calls["load_removed_list"] += 1
return kill_list
def counted_live_removed():
calls["live_removed_list"] += 1
return []
monkeypatch.setattr(pc, "load_removed_list", counted_load_removed)
monkeypatch.setattr(pc, "live_removed_list", counted_live_removed)
monkeypatch.setattr(
plugins_cmd, "_discover_all_plugins",
lambda: [(f"plugin-{i}", "1.0", "", "user", tmp_path / str(i), f"plugin-{i}")
for i in range(3)]
+ [("pulled-plugin", "1.0", "", "user", tmp_path / "pulled", "pulled-plugin")],
)
monkeypatch.setattr(plugins_cmd, "_get_enabled_set", lambda: {"plugin-0"})
monkeypatch.setattr(plugins_cmd, "_get_disabled_set", lambda: set())
monkeypatch.setattr(plugins_cmd, "_read_install_metadata", lambda: {})
monkeypatch.setattr(pc, "_live_cache_path", lambda: tmp_path / "cache" / "plugin-catalog.json")
plugins_cmd.cmd_list(argparse.Namespace(
enabled=False, user=False, no_bundled=False, plain=False, json=True))
assert calls["load_removed_list"] == 1
assert calls["live_removed_list"] == 1
rows = json.loads(capsys.readouterr().out)
by_name = {row["name"]: row["removed"] for row in rows}
assert by_name["pulled-plugin"] == "security review"
assert by_name["plugin-0"] is None
monkeypatch.setattr(pc, "_live_fetch_failed_until", 0.0) # forget the failure: a fresh window
plugins_cmd.cmd_list(argparse.Namespace(enabled=False, user=False, no_bundled=False, plain=False, json=True))
rows = {row["name"]: row["removed"] for row in json.loads(capsys.readouterr().out)}
assert rows == {"demo": "exfiltrated env vars", "second": None, "third": None}
assert unreachable.attempts == 2 # one more for the whole listing, not one per row

View File

@@ -11,6 +11,7 @@ import hermes_cli.web_routers.dashboard_ui as _rt_dashboard_ui
import hermes_cli.web_server_dashboard as _web_server_dashboard
import hermes_cli.web_server_memory as _web_server_memory
from hermes_cli import plugins_cmd
from hermes_cli import plugin_catalog
from hermes_cli import plugins_cmd_catalog
from tools import registry as tools_registry
@@ -156,11 +157,12 @@ def test_plugins_hub_route_builds_catalog_annotations_off_event_loop(monkeypatch
_patch_minimal_hub_dependencies(monkeypatch, check_fn=lambda: True)
monkeypatch.setattr(web_server, "_require_token", lambda _request: None)
def removed_annotation(name, _dir_path):
def removed_annotation(name, _dir_path, _removed_entries=None):
annotation_threads.append(threading.current_thread())
return "withdrawn by catalog" if name == "demo" else None
monkeypatch.setattr(plugins_cmd_catalog, "removed_annotation", removed_annotation)
monkeypatch.setattr(plugin_catalog, "resolved_removed_entries", lambda: [])
payload = asyncio.run(_rt_dashboard_ui.get_plugins_hub(object()))

View File

@@ -175,7 +175,9 @@ The docs build publishes the catalog as one JSON document
(`https://hermes-agent.nousresearch.com/docs/api/plugin-catalog.json`).
`search`/`install`/`update` fetch it at most every six hours and cache it under
`~/.hermes/cache/`, so new entries and removals reach installed clients without
updating Hermes. Offline, the copy shipped with your checkout is used. Removals
updating Hermes. Offline, the copy shipped with your checkout is used (a failed
fetch is remembered for a minute, so `plugins list` and the dashboard's Plugins
page pay at most one connection timeout, not one per installed plugin). Removals
from the in-tree list and the live list are always both enforced.
### Custom git URLs are different