From 1fc1d054af14735dd4affb3d5b348f09437e74bd Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 3 Sep 2026 14:01:37 -0400 Subject: [PATCH] feat(receipts): update receipts embed the pm sync sections + plugin docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - finalize_update_receipt folds the newest pm sync receipt's venv_rebuild / plugin_bisect / feature_list into the update receipt (pm_-prefixed) so ONE latest.json carries the whole update story — the embedding contract both receipt plans declared. Additive, exception-swallowing (an embed failure never breaks the update receipt). - pm/receipt: _write_rotated mkdirs itself instead of depending on _receipt_dir()'s mkdir side effect (found via the embed tests — a patched dir lambda made writes FileNotFoundError under the OSError swallow). - website plugins.md: the three new verbs (check-updates, adopt, trust-update-url) + the provenance/cadence/auto_apply/trust flow, user-facing. tests: embed happy/absent/failure-isolated; existing receipt suites green. --- hermes_cli/update_receipt.py | 17 ++++ pm/receipt.py | 3 + .../test_update_receipt_pm_embed.py | 77 +++++++++++++++++++ website/docs/user-guide/features/plugins.md | 29 +++++++ 4 files changed, 126 insertions(+) create mode 100644 tests/hermes_cli/test_update_receipt_pm_embed.py diff --git a/hermes_cli/update_receipt.py b/hermes_cli/update_receipt.py index 8c41b79d38..c9ed8a2a5e 100644 --- a/hermes_cli/update_receipt.py +++ b/hermes_cli/update_receipt.py @@ -231,6 +231,23 @@ def finalize_update_receipt( receipt.data["stop_reason"] = stop_reason if fleet is not None: receipt.data["fleet"] = fleet + # EMBED the pm sync sections (the settled receipts contract): the + # update's rebuild/bisect ran through pm's own sync receipt, which + # finalizes before this one. Fold the newest sync receipt's + # venv_rebuild + plugin_bisect into the update receipt so ONE file + # carries the whole story (desktop reads a single latest.json). + try: + from pm import receipt as pm_receipt + + sync = pm_receipt.latest() + if isinstance(sync, dict): + for key in ("venv_rebuild", "plugin_bisect", "feature_list"): + if sync.get(key) is not None: + receipt.data[f"pm_{key}"] = sync[key] + if sync.get("outcome") is not None: + receipt.data["pm_sync_outcome"] = sync.get("outcome") + except Exception as exc: # pragma: no cover — embedding is additive + logger.debug("pm sync-section embed skipped: %s", exc) directory = _receipt_dir() directory.mkdir(parents=True, exist_ok=True) stamp = time.strftime("%Y%m%d_%H%M%S") diff --git a/pm/receipt.py b/pm/receipt.py index b361bc48e2..4d1ea88165 100644 --- a/pm/receipt.py +++ b/pm/receipt.py @@ -133,6 +133,9 @@ def latest() -> Optional[dict[str, Any]]: def _write_rotated(data: dict[str, Any]) -> Path: d = _receipt_dir() + # _write_rotated must not depend on _receipt_dir()'s mkdir side + # effect (a patched/injected dir lambda breaks it) — self-sufficient. + d.mkdir(parents=True, exist_ok=True) stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) kind = data.get("kind") or "sync" path = d / f"{stamp}-{kind}.json" diff --git a/tests/hermes_cli/test_update_receipt_pm_embed.py b/tests/hermes_cli/test_update_receipt_pm_embed.py new file mode 100644 index 0000000000..bfec095818 --- /dev/null +++ b/tests/hermes_cli/test_update_receipt_pm_embed.py @@ -0,0 +1,77 @@ +"""Tests: update receipts embed the pm sync sections (the settled contract). + +finalize_update_receipt folds the newest pm sync receipt's +venv_rebuild / plugin_bisect / feature_list into the update receipt +(pm_*-prefixed), so one latest.json carries the whole story. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +import hermes_cli.update_receipt as ur + + +@pytest.fixture +def homed(tmp_path, monkeypatch): + import hermes_constants + + monkeypatch.setattr(hermes_constants, "get_hermes_home", lambda: tmp_path) + import pm.receipt as pm_receipt_mod + + monkeypatch.setattr(pm_receipt_mod, "_receipt_dir", lambda: tmp_path / "logs" / "update_receipts") + monkeypatch.setattr(ur, "_receipt_dir", lambda: tmp_path / "logs" / "update_receipts") + return tmp_path + + +def _seed_sync_receipt(homed, **sections): + import pm.receipt as pm_receipt_mod + + pm_receipt_mod.begin("sync") + if "venv_rebuild" in sections: + pm_receipt_mod.record_venv_rebuild(**sections.pop("venv_rebuild")) + if "bisect" in sections: + pm_receipt_mod.record_bisect(sections.pop("bisect")) + pm_receipt_mod.finalize("ok") + + +def test_update_receipt_embeds_pm_sections(homed): + _seed_sync_receipt( + homed, + venv_rebuild={"ok": True, "reason": ""}, + bisect=[{"plugin": "bad", "action": "disabled", "reason": "conflict"}], + ) + ur.begin_update_receipt() + ur.record_step("git-pull", True) + path = ur.finalize_update_receipt("success") + + data = json.loads((homed / "logs" / "update_receipts" / "latest.json").read_text(encoding="utf-8")) + assert data["outcome"] == "success" + assert data["pm_venv_rebuild"] == {"ok": True, "reason": ""} + assert data["pm_plugin_bisect"][0]["plugin"] == "bad" + assert data["pm_sync_outcome"] == "ok" + + +def test_update_receipt_without_sync_embeds_nothing(homed): + ur.begin_update_receipt() + ur.finalize_update_receipt("success") + data = json.loads((homed / "logs" / "update_receipts" / "latest.json").read_text(encoding="utf-8")) + assert "pm_venv_rebuild" not in data + assert data["outcome"] == "success" + + +def test_embed_failure_never_breaks_the_update_receipt(homed, monkeypatch): + def boom(): + raise RuntimeError("pm import exploded") + + import pm.receipt as pm_receipt_mod + + monkeypatch.setattr(pm_receipt_mod, "latest", boom) + ur.begin_update_receipt() + path = ur.finalize_update_receipt("success") + assert path is not None # receipt written despite the embed failure + data = json.loads(path.read_text(encoding="utf-8")) + assert data["outcome"] == "success" diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 417850c1c7..fa6de4d2d6 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -350,8 +350,37 @@ hermes plugins remove my-plugin # uninstall hermes plugins enable my-plugin # add to allow-list hermes plugins disable my-plugin # remove from allow-list + add to disabled hermes plugins capabilities [my-plugin] # declared vs granted capabilities +hermes plugins check-updates # read-only: is any installed plugin outdated? +hermes plugins adopt my-plugin # track a self-cloned plugin dir (read its git origin) +hermes plugins trust-update-url my-plugin # confirm a changed update_url after review ``` +### Update checks and provenance + +Hermes records where every `hermes plugins install` came from (the git +source and exact revision, in `.install-metadata.json`), and +`hermes plugins check-updates` uses that provenance to answer "is it +outdated?" without touching your working tree: git-installed plugins are +compared against their remote's HEAD via `git ls-remote`, and plugins +whose manifest declares an `update_url` (a small update-feed file) are +checked against that feed — the standard way for non-github-hosted +plugins to be update-checkable. Plugins you cloned yourself (no install +record) are offered `hermes plugins adopt` to become tracked installs. + +The check is read-only; applying updates stays explicit via +`hermes plugins update`, which re-runs the security scan on the pulled +code. A background check runs at most once a day (config key +`plugins.auto_update_check_hours`, default 24, `0` disables) and writes +its results where the desktop and `hermes pm status` read them; set +`plugins.auto_apply: true` to also apply git-plugin updates +unattended — the security scan still gates every apply. + +If a plugin's manifest changes its `update_url` after install (visible +as a *needs fixing* warning in check-updates and `hermes doctor`), +Hermes refuses to fetch from the new address until you confirm it with +`hermes plugins trust-update-url` — an update can never silently +redirect where its code comes from. + ### One-click install links (Desktop) Hermes Desktop registers the `hermes://` URL scheme, so a website, README, or