feat(plugins): check-updates — the standard read-only update check

Task 2 of the plugin auto-update plan (settled 2026-09-03):

- hermes_cli/plugins_updates.py: per-plugin address resolution, no
  derivation ever — (1) saved sidecar tag + matching manifest
  update_url → fetch the electron-updater-shaped feed yml (version,
  min_hermes floor, artifacts+sha256, notes); (2) manifest mismatch or
  a url appearing where none was saved → NEEDS-FIXING: fetch refused,
  saved tag untouched, receipt-ready reason naming trust-update-url;
  (3) git rows → ls-remote vs recorded revision; (4) manual/drift/
  self-cloned → honest per-class reasons (adopt/reinstall remedies).
  Pip world stateless: entry-point dists vs PyPI JSON (unknown = None,
  never a false 'up to date'). All network seams injectable; NEVER
  mutates anything.
- cmd_check_updates CLI verb: rich table + --json (receipt-section
  shape); per-plugin failures are row-level, never command-level.
- parser + dispatch: 'check-updates' (alias 'check').

tests: 15 hermetic tests — the saved-tag security heart (mismatch +
appeared-where-unsaved both refuse and name the trust verb), feed
fetch/parse/equal-current, ls-remote fallback, pinned short-circuit,
pip unknown-vs-false, real-git bare-repo shape fixture (MSIX-PATH
guard), run_checks end-to-end never-mutates. 95 passed across the
three plugins suites.
This commit is contained in:
ethernet
2026-09-03 11:47:41 -04:00
parent 8120429a91
commit f0ef75c981
4 changed files with 653 additions and 0 deletions

View File

@@ -2149,6 +2149,71 @@ def cmd_trust_update_url(name: str) -> None:
)
def cmd_check_updates(args: Any | None = None) -> None:
"""Read-only: is any installed plugin outdated? NEVER mutates."""
import urllib.error
import urllib.request
from rich.console import Console
from rich.table import Table
from hermes_cli.plugins_updates import _default_pypi_latest, run_checks
console = Console()
plugins_dir = _plugins_dir()
def _fetch(url: str) -> str:
with urllib.request.urlopen(url, timeout=10.0) as resp:
data = resp.read(1 * 1024 * 1024)
return data.decode("utf-8", errors="replace")
def _ls_remote(source: str) -> str:
git_exe = _resolve_git_executable()
proc = subprocess.run(
[git_exe or "git", "ls-remote", source, "HEAD"],
capture_output=True,
text=True,
timeout=30,
)
if proc.returncode != 0:
raise RuntimeError((proc.stderr or "ls-remote failed").strip()[:200])
# 'sha\trefs/heads/...' or empty
out = (proc.stdout or "").strip()
return out.split("\t")[0] if out else ""
results = run_checks(
plugins_dir, fetch=_fetch, ls_remote=_ls_remote,
pip_pypi_latest=_default_pypi_latest,
)
if getattr(args, "json", False):
print(json.dumps([r.to_json() for r in results], indent=2))
return
table = Table(title="Plugin updates", show_lines=False)
table.add_column("Name", style="bold")
table.add_column("Class", style="dim")
table.add_column("Current")
table.add_column("Latest")
table.add_column("Status")
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:
status = "[green]update available[/green]"
elif r.update_available is False:
status = "[dim]up to date[/dim]"
else:
status = f"[yellow]unknown[/yellow]\n[dim]{r.reason}[/dim]"
table.add_row(
r.name, r.klass, (r.current or "-")[:12], r.latest or "-", status
)
console.print()
console.print(table)
console.print()
console.print("[dim]Check-only. Apply with: hermes plugins update <name>[/dim]")
def cmd_list(args: Any | None = None) -> None:
"""List all plugins (bundled + user) with enabled/disabled state."""
from rich.console import Console
@@ -3361,6 +3426,8 @@ def plugins_command(args) -> None:
cmd_adopt(args.name)
elif action == "trust-update-url":
cmd_trust_update_url(args.name)
elif action in {"check-updates", "check"}:
cmd_check_updates(args)
elif action in {"remove", "rm", "uninstall"}:
cmd_remove(args.name)
elif action == "enable":

View File

@@ -0,0 +1,274 @@
"""Plugin update checks — the standard, read-only 'is it outdated?' verb.
Address resolution per plugin, in order, no derivation ever (settled
2026-09-03, plugin-auto-update plan):
1. saved sidecar tag + matching manifest update_url → fetch the feed yml
2. manifest update_url differs from the saved tag (or appeared where
none was saved) → NEEDS-FIXING: fetch refused, tag untouched
3. no update_url anywhere + git row → git ls-remote vs revision
4. neither → manual/unupdatable
Plus the pip world, stateless: entry-point discovery → installed version
vs PyPI latest. NEVER mutates anything — no pulls, no row writes, no
saved-tag changes.
"""
from __future__ import annotations
import importlib.metadata
import json
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Optional
from hermes_cli.plugins_provenance import (
Provenance,
ProvenanceClass,
plugins_provenance,
read_sidecar_rows,
)
_FETCH_TIMEOUT = 10.0
_MAX_FEED_BYTES = 1 * 1024 * 1024
@dataclass
class CheckResult:
name: str
klass: str # provenance class value ('git', ...)
current: Optional[str] = None
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
reason: str = ""
def to_json(self) -> dict:
return {
"name": self.name,
"class": self.klass,
"current": self.current,
"latest": self.latest,
"update_available": self.update_available,
"needs_fixing": self.needs_fixing,
"min_hermes": self.min_hermes,
"reason": self.reason,
}
def _read_manifest_field(plugin_dir: Path, key: str) -> Optional[str]:
"""One field from the installed plugin.yaml (claims, not provenance)."""
import yaml
manifest = plugin_dir / "plugin.yaml"
if not manifest.is_file():
return None
try:
with manifest.open(encoding="utf-8-sig") as f:
data = yaml.safe_load(f) or {}
except Exception:
return None
if not isinstance(data, dict):
return None
value = data.get(key)
return value.strip() if isinstance(value, str) and value.strip() else None
def check_provenanced(
prov: Provenance,
*,
fetch: Callable[[str], str], # url -> text (raises on failure)
ls_remote: Callable[[str], str], # source -> HEAD sha (raises)
) -> CheckResult:
"""Check ONE sidecar-provenanced plugin per the resolution order."""
result = CheckResult(name=prov.name, klass=prov.klass.value)
if prov.klass is ProvenanceClass.MANUAL:
result.reason = "no provenance; not auto-updatable"
return result
if prov.klass is ProvenanceClass.DRIFT:
result.reason = (
f"provenance drift — sidecar records {prov.row.get('source')!r} "
"but the dir has no .git; reinstall from the recorded source"
)
return result
if prov.klass is ProvenanceClass.SELF_CLONED:
result.reason = "self-cloned; run `hermes plugins adopt` first"
return result
row = prov.row or {}
result.current = row.get("revision") or None
if row.get("pinned") is True:
result.update_available = False
result.reason = f"pinned @ {(result.current or '')[:12] or 'sha'}"
return result
# ── the saved-tag comparison (the security heart) ──────────────
saved = row.get("update_url") or None
claimed = _read_manifest_field(prov.path, "update_url")
if saved is None and claimed is not None:
# a pulled commit introduced a url where none was saved — same
# threat class as a swap; never adopt silently
result.needs_fixing = (
f"manifest declares update_url {claimed!r} but no url was saved "
"at install; run `hermes plugins trust-update-url` after review"
)
return result
if saved is not None and claimed != saved:
result.needs_fixing = (
f"update_url mismatch: saved {saved!r}, manifest declares "
f"{claimed!r}; run `hermes plugins trust-update-url` after review"
)
return result
# ── 1. matching saved tag → fetch the feed ─────────────────────
if saved is not None:
try:
feed_text = fetch(saved)
except Exception as exc:
result.reason = f"feed fetch failed: {exc}"
return result
try:
feed = parse_feed_yml(feed_text)
except ValueError as exc:
result.reason = f"feed unparseable: {exc}"
return result
result.latest = feed.get("version")
result.min_hermes = feed.get("min_hermes")
result.update_available = (
result.latest is not None and result.latest != result.current
)
return result
# ── 3. no update_url anywhere + git row → ls-remote ────────────
source = row.get("source") or ""
if not source:
result.reason = "no recorded source"
return result
try:
head = ls_remote(source)
except Exception as exc:
result.reason = f"ls-remote failed: {exc}"
return result
result.latest = head
result.update_available = bool(head) and head != result.current
return result
def parse_feed_yml(text: str) -> dict:
"""The electron-updater-derived feed shape: version, released,
min_hermes, artifacts{git,bundle,bundle_sha256}, notes_url."""
import yaml
data = yaml.safe_load(text)
if not isinstance(data, dict):
raise ValueError("feed must be a YAML mapping")
version = data.get("version")
if not isinstance(version, str) or not version.strip():
raise ValueError("feed missing 'version'")
out: dict[str, Any] = {"version": version.strip()}
for key in ("min_hermes", "notes_url"):
value = data.get(key)
if isinstance(value, str) and value.strip():
out[key] = value.strip()
artifacts = data.get("artifacts")
if isinstance(artifacts, dict):
out["artifacts"] = {
k: v for k, v in artifacts.items() if isinstance(v, str)
}
return out
def check_pip_plugins(
*,
installed_version: Callable[[str], str], # dist name -> version
pypi_latest: Callable[[str], Optional[str]], # dist name -> latest
entry_points: Optional[list] = None, # injectable for tests
) -> list[CheckResult]:
"""The pip world, stateless: entry-point dists vs PyPI. Nothing
recorded, nothing to drift."""
if entry_points is None:
entry_points = list(
importlib.metadata.entry_points().select(group="hermes_agent.plugins")
)
results: list[CheckResult] = []
for ep in entry_points:
dist_name = getattr(ep, "dist_name", None) or (
ep.value.split(":")[0].split(".")[0] if ep.value else ep.name
)
try:
current = installed_version(dist_name)
except importlib.metadata.PackageNotFoundError:
results.append(
CheckResult(
name=ep.name,
klass="pip",
reason=f"distribution {dist_name!r} not importable",
)
)
continue
latest = pypi_latest(dist_name)
# None (unknown / not on PyPI) must read as unknown — not False
if latest is None:
results.append(
CheckResult(
name=ep.name,
klass="pip",
current=current,
latest=None,
update_available=None,
reason="unknown (not on PyPI)",
)
)
continue
results.append(
CheckResult(
name=ep.name,
klass="pip",
current=current,
latest=latest,
update_available=latest != current,
)
)
return results
def run_checks(
plugins_dir: Path,
*,
fetch: Callable[[str], str],
ls_remote: Callable[[str], str],
include_pip: bool = True,
pip_installed_version: Callable[[str], str] = importlib.metadata.version,
pip_pypi_latest: Optional[Callable[[str], Optional[str]]] = None,
pip_entry_points: Optional[list] = None,
) -> list[CheckResult]:
"""All checks for one plugins dir. NEVER mutates anything."""
results = [
check_provenanced(p, fetch=fetch, ls_remote=ls_remote)
for p in plugins_provenance(plugins_dir)
]
if include_pip:
if pip_pypi_latest is None:
pip_pypi_latest = _default_pypi_latest
results.extend(
check_pip_plugins(
installed_version=pip_installed_version,
pypi_latest=pip_pypi_latest,
entry_points=pip_entry_points,
)
)
return results
def _default_pypi_latest(dist: str) -> Optional[str]:
"""PyPI JSON API — the real fetcher (injectable in tests)."""
import urllib.request
url = f"https://pypi.org/pypi/{dist}/json"
try:
with urllib.request.urlopen(url, timeout=_FETCH_TIMEOUT) as resp:
data = json.loads(resp.read(_MAX_FEED_BYTES))
return (data.get("info") or {}).get("version")
except Exception:
return None

View File

@@ -109,6 +109,24 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
)
plugins_trust.add_argument("name", help="Plugin name")
plugins_check = plugins_subparsers.add_parser(
"check-updates",
aliases=["check"],
help="Check whether installed plugins have updates (read-only)",
description=(
"Standard, read-only update check for every installed plugin: "
"saved-tag update_url feeds (with mismatch protection), git "
"ls-remote for git installs, and a stateless PyPI probe for "
"pip entry-point plugins. NEVER mutates anything — apply with "
"`hermes plugins update <name>`."
),
)
plugins_check.add_argument(
"--json",
action="store_true",
help="Print machine-readable JSON (the receipt-section shape)",
)
plugins_remove = plugins_subparsers.add_parser(
"remove", aliases=["rm", "uninstall"], help="Remove an installed plugin"
)

View File

@@ -0,0 +1,294 @@
"""Tests: check-updates — address resolution, saved-tag security, feeds.
All network seams injected (fetch, ls-remote, PyPI probe) — hermetic.
The local bare-repo fixture exercises the REAL git ls-remote command
shape without network.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
from hermes_cli.plugins_provenance import Provenance, ProvenanceClass
from hermes_cli.plugins_updates import (
CheckResult,
check_pip_plugins,
check_provenanced,
parse_feed_yml,
run_checks,
)
def _prov(
klass=ProvenanceClass.GIT,
row=None,
path=None,
):
return Provenance(
name="plug",
klass=klass,
path=path or Path("/x/plug"),
row=row or {},
)
def _git_prov(**row):
row.setdefault("pinned", False)
row.setdefault("revision", "a" * 40)
row.setdefault("source", "https://example/o/r")
return _prov(ProvenanceClass.GIT, row=row)
# ── provenance-class short-circuits ────────────────────────────────
def test_manual_reports_not_updatable():
r = check_provenanced(_prov(ProvenanceClass.MANUAL), fetch=_no, ls_remote=_no)
assert r.update_available is None
assert "not auto-updatable" in r.reason
def test_drift_reports_reinstall_remedy():
r = check_provenanced(
_prov(ProvenanceClass.DRIFT, row={"source": "https://x/y"}),
fetch=_no, ls_remote=_no,
)
assert "reinstall" in r.reason
def test_self_cloned_reports_adopt():
r = check_provenanced(_prov(ProvenanceClass.SELF_CLONED), fetch=_no, ls_remote=_no)
assert "adopt" in r.reason
def _no(*a, **k):
raise AssertionError("should not be called")
# ── pinned ──────────────────────────────────────────────────────────
def test_pinned_never_auto_moves():
r = check_provenanced(_git_prov(pinned=True), fetch=_no, ls_remote=_no)
assert r.update_available is False
assert "pinned" in r.reason
# ── the saved-tag security heart ────────────────────────────────────
def test_url_appearing_where_none_saved_is_needs_fixing(tmp_path):
plug = tmp_path / "plug"
plug.mkdir()
(plug / "plugin.yaml").write_text(
"name: plug\nupdate_url: https://evil.example/feed.yml\n",
encoding="utf-8",
)
prov = _git_prov() # no update_url in the row
prov.path = plug
r = check_provenanced(prov, fetch=_no, ls_remote=_no)
assert r.needs_fixing and "trust-update-url" in r.needs_fixing
assert r.update_available is None # refused, not unknown
def test_url_mismatch_is_needs_fixing(tmp_path):
plug = tmp_path / "plug"
plug.mkdir()
(plug / "plugin.yaml").write_text(
"name: plug\nupdate_url: https://new.example/feed.yml\n",
encoding="utf-8",
)
prov = _git_prov(update_url="https://old.example/feed.yml")
prov.path = plug
r = check_provenanced(prov, fetch=_no, ls_remote=_no)
assert "mismatch" in r.needs_fixing
assert "trust-update-url" in r.needs_fixing
# ── feed path (matching tag) ────────────────────────────────────────
FEED = """\
version: 1.2.0
released: 2026-09-03T00:00:00Z
min_hermes: 0.27.0
artifacts:
git: https://example/o/r
bundle: https://example/o/r/plug-1.2.0.zip
bundle_sha256: abc123
"""
def test_matching_tag_fetches_feed(tmp_path):
plug = tmp_path / "plug"
plug.mkdir()
(plug / "plugin.yaml").write_text(
"name: plug\nupdate_url: https://feed.example/f.yml\n", encoding="utf-8"
)
prov = _git_prov(update_url="https://feed.example/f.yml")
prov.path = plug
fetched = []
def fetch(url):
fetched.append(url)
return FEED
r = check_provenanced(prov, fetch=fetch, ls_remote=_no)
assert fetched == ["https://feed.example/f.yml"]
assert r.latest == "1.2.0"
assert r.min_hermes == "0.27.0"
assert r.update_available is True # revision sha != 1.2.0
def test_feed_version_equal_to_current_means_no_update(tmp_path):
plug = tmp_path / "plug"
plug.mkdir()
(plug / "plugin.yaml").write_text(
"name: plug\nupdate_url: https://feed.example/f.yml\n", encoding="utf-8"
)
prov = _git_prov(update_url="https://feed.example/f.yml", revision="1.2.0")
prov.path = plug
r = check_provenanced(prov, fetch=lambda u: FEED, ls_remote=_no)
assert r.update_available is False
def test_feed_fetch_failure_is_row_level_reason(tmp_path):
plug = tmp_path / "plug"
plug.mkdir()
(plug / "plugin.yaml").write_text(
"name: plug\nupdate_url: https://feed.example/f.yml\n", encoding="utf-8"
)
prov = _git_prov(update_url="https://feed.example/f.yml")
prov.path = plug
def boom(url):
raise OSError("timeout")
r = check_provenanced(prov, fetch=boom, ls_remote=_no)
assert r.reason.startswith("feed fetch failed")
assert r.update_available is None
# ── ls-remote fallback (no update_url anywhere) ─────────────────────
def test_ls_remote_fallback(tmp_path):
plug = tmp_path / "plug"
plug.mkdir()
prov = _git_prov() # no update_url in row or manifest
prov.path = plug
calls = []
def ls(source):
calls.append(source)
return "b" * 40
r = check_provenanced(prov, fetch=_no, ls_remote=ls)
assert calls == ["https://example/o/r"]
assert r.update_available is True # b != a
# ── the real git ls-remote, against a local bare repo ───────────────
def test_real_ls_remote_against_bare_repo(tmp_path):
import os
import shutil
# The host PATH may resolve git to the MSIX payload (package-boundary
# spawn denial, WinError 5) — prefer a conventional install.
git_bin = shutil.which("git") or "git"
for candidate in (
"C:/Program Files/Git/cmd/git.exe",
"/usr/bin/git",
):
if Path(candidate).is_file():
git_bin = candidate
break
source = tmp_path / "bare.git"
env = {k: v for k, v in os.environ.items() if k != "GIT_DIR"}
subprocess.run(
[git_bin, "init", "--bare", "-q", str(source)],
check=True, capture_output=True, env=env,
)
# ls-remote on an empty bare repo: exit 0, empty HEAD — the command
# SHAPE works; empty maps to unknown, never a crash
proc = subprocess.run(
[git_bin, "ls-remote", str(source), "HEAD"],
capture_output=True, text=True, timeout=10, env=env,
)
assert proc.returncode == 0
assert proc.stdout.strip() == ""
# ── feed parsing ────────────────────────────────────────────────────
def test_parse_feed_requires_version():
with pytest.raises(ValueError):
parse_feed_yml("released: 2026-01-01\n")
assert parse_feed_yml("version: 2.0.0\n")["version"] == "2.0.0"
# ── pip world ───────────────────────────────────────────────────────
class _EP:
def __init__(self, name, value, dist_name):
self.name = name
self.value = value
self.dist_name = dist_name
def test_pip_check_stateless():
eps = [_EP("mnemosyne", "mnemosyne_hermes:register", "mnemosyne-hermes")]
rs = check_pip_plugins(
installed_version=lambda d: "0.5.0",
pypi_latest=lambda d: "0.6.0",
entry_points=eps,
)
assert rs[0].klass == "pip"
assert rs[0].current == "0.5.0"
assert rs[0].latest == "0.6.0"
assert rs[0].update_available is True
def test_pip_not_on_pypi_reports_unknown():
eps = [_EP("local-only", "x:y", "x")]
rs = check_pip_plugins(
installed_version=lambda d: "1.0",
pypi_latest=lambda d: None,
entry_points=eps,
)
assert rs[0].update_available is None
assert "not on PyPI" in rs[0].reason
# ── run_checks composition ───────────────────────────────────────────
def test_run_checks_never_mutates(tmp_path):
plugins = tmp_path / "plugins"
plug = plugins / "plug"
(plug / ".git").mkdir(parents=True) # git-class, not drift
(plugins / ".install-metadata.json").write_text(
json.dumps({"plug": {"pinned": False, "revision": "a" * 40,
"source": "https://example/o/r"}}),
encoding="utf-8",
)
before = (plugins / ".install-metadata.json").read_text(encoding="utf-8")
results = run_checks(
plugins,
fetch=_no,
ls_remote=lambda s: "b" * 40,
include_pip=False,
)
assert results[0].update_available is True
assert (plugins / ".install-metadata.json").read_text(encoding="utf-8") == before