feat(receipts): update receipts embed the pm sync sections + plugin docs

- 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.
This commit is contained in:
ethernet
2026-09-03 14:01:37 -04:00
parent cd3c8f3090
commit 1fc1d054af
4 changed files with 126 additions and 0 deletions

View File

@@ -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")

View File

@@ -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"

View File

@@ -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"

View File

@@ -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