From f0ef75c9812d6563d0f7f5834879cd13d416b129 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 3 Sep 2026 11:47:41 -0400 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20check-updates=20=E2=80=94=20th?= =?UTF-8?q?e=20standard=20read-only=20update=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hermes_cli/plugins_cmd.py | 67 ++++ hermes_cli/plugins_updates.py | 274 ++++++++++++++++ hermes_cli/subcommands/plugins.py | 18 ++ .../hermes_cli/test_plugins_check_updates.py | 294 ++++++++++++++++++ 4 files changed, 653 insertions(+) create mode 100644 hermes_cli/plugins_updates.py create mode 100644 tests/hermes_cli/test_plugins_check_updates.py diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 6882114254..2aab4e8592 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -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 [/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": diff --git a/hermes_cli/plugins_updates.py b/hermes_cli/plugins_updates.py new file mode 100644 index 0000000000..cd3da1deba --- /dev/null +++ b/hermes_cli/plugins_updates.py @@ -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 \ No newline at end of file diff --git a/hermes_cli/subcommands/plugins.py b/hermes_cli/subcommands/plugins.py index db1da018e8..eaeb7289bd 100644 --- a/hermes_cli/subcommands/plugins.py +++ b/hermes_cli/subcommands/plugins.py @@ -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 `." + ), + ) + 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" ) diff --git a/tests/hermes_cli/test_plugins_check_updates.py b/tests/hermes_cli/test_plugins_check_updates.py new file mode 100644 index 0000000000..ecb0048fa0 --- /dev/null +++ b/tests/hermes_cli/test_plugins_check_updates.py @@ -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 \ No newline at end of file