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