Files
hermes-agent/tests/hermes_cli/test_plugins_provenance.py
ethernet 6c05447469 fix(plugins): provenance probes origin with the resolved git
_git_origin_url spawned a bare 'git' while every other plugin git call
goes through _resolve_git_executable(); from a service with a minimal
PATH (or Windows without Git on PATH) the probe failed where install
and update succeed. No git at all now goes straight to the .git/config
parse.
2026-09-21 18:55:13 -04:00

127 lines
4.8 KiB
Python

"""Tests: plugin provenance reconciliation — the 2x2.
Row-presence says "hermes installed this"; `.git`-presence cross-checks
it. The reconciliation IS the disambiguation (settled 2026-09-03,
plugin-auto-update plan Task 1):
row+git = git install (updatable via the recorded source)
neither = manual drop (not auto-updatable, listed as such)
git only = self-cloned (adoptable: origin URL ready)
row only = provenance drift (flagged with a reinstall remedy)
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from hermes_cli.plugins_provenance import (
Provenance,
ProvenanceClass,
plugins_provenance,
)
def _write_sidecar(home: Path, rows: dict) -> None:
plugins = home / "plugins"
plugins.mkdir(parents=True, exist_ok=True)
(plugins / ".install-metadata.json").write_text(
json.dumps(rows, indent=2), encoding="utf-8"
)
def _fake_git_dir(plugin_dir: Path, origin_url: str = "https://example/o/r") -> None:
plugin_dir.mkdir(parents=True, exist_ok=True)
git = plugin_dir / ".git"
git.mkdir()
# a real-enough git dir: remote config the adopt path reads
(git / "config").write_text(
f'[remote "origin"]\n\turl = {origin_url}\n', encoding="utf-8"
)
def _manual_dir(plugin_dir: Path) -> None:
plugin_dir.mkdir(parents=True, exist_ok=True)
(plugin_dir / "plugin.yaml").write_text("name: x\n", encoding="utf-8")
def test_git_install_row_and_git(tmp_path):
_write_sidecar(tmp_path, {"plug": {"pinned": False, "revision": "a" * 40, "source": "https://example/o/r"}})
_fake_git_dir(tmp_path / "plugins" / "plug")
prov = plugins_provenance(tmp_path / "plugins")
assert len(prov) == 1
assert prov[0].klass is ProvenanceClass.GIT
assert prov[0].row is not None and prov[0].row["revision"] == "a" * 40
def test_manual_drop_neither(tmp_path):
_write_sidecar(tmp_path, {})
_manual_dir(tmp_path / "plugins" / "handmade")
prov = plugins_provenance(tmp_path / "plugins")
assert prov[0].klass is ProvenanceClass.MANUAL
def test_self_cloned_git_without_row(tmp_path):
_write_sidecar(tmp_path, {})
_fake_git_dir(tmp_path / "plugins" / "selfcloned", "https://example/m/r")
prov = plugins_provenance(tmp_path / "plugins")
assert prov[0].klass is ProvenanceClass.SELF_CLONED
assert prov[0].origin_url == "https://example/m/r"
def test_drift_row_without_git(tmp_path):
_write_sidecar(tmp_path, {"ghost": {"pinned": False, "revision": "b" * 40, "source": "https://example/g/r"}})
_manual_dir(tmp_path / "plugins" / "ghost")
prov = plugins_provenance(tmp_path / "plugins")
assert prov[0].klass is ProvenanceClass.DRIFT
assert prov[0].row["source"] == "https://example/g/r"
def test_missing_sidecar_is_all_manual_or_selfcloned(tmp_path):
# no sidecar at all: everything classifies from the dir alone
_fake_git_dir(tmp_path / "plugins" / "cloned")
_manual_dir(tmp_path / "plugins" / "dropped")
prov = plugins_provenance(tmp_path / "plugins")
classes = {p.name: p.klass for p in prov}
assert classes["cloned"] is ProvenanceClass.SELF_CLONED
assert classes["dropped"] is ProvenanceClass.MANUAL
def test_update_url_tag_round_trips(tmp_path):
# a saved update_url tag rides the row; the manifest is claims
_write_sidecar(
tmp_path,
{"plug": {"pinned": False, "revision": "c" * 40, "source": "https://example/o/r",
"update_url": "https://example/feed.yml"}},
)
_fake_git_dir(tmp_path / "plugins" / "plug")
prov = plugins_provenance(tmp_path / "plugins")
assert prov[0].saved_update_url == "https://example/feed.yml"
def test_origin_url_uses_the_resolved_git_and_survives_no_git_at_all(tmp_path, monkeypatch):
"""Provenance runs from the gateway service, where PATH can be minimal: the probe must use the
same resolved git as install/update, and with no git anywhere the .git/config parse still
yields the origin (never a bare ``git`` spawn that raises or picks up a stray binary)."""
import subprocess
from hermes_cli import plugins_cmd, plugins_provenance
plugin = tmp_path / "plugins" / "p"
plugin.mkdir(parents=True)
_fake_git_dir(plugin, "https://example/from-config")
spawned = []
real_run = subprocess.run
def record(argv, *a, **k):
spawned.append(argv[0])
return real_run(argv, *a, **k)
monkeypatch.setattr(subprocess, "run", record)
monkeypatch.setattr(plugins_cmd, "_resolve_git_executable", lambda: None)
assert plugins_provenance._git_origin_url(plugin) == "https://example/from-config"
assert spawned == []
monkeypatch.setattr(plugins_cmd, "_resolve_git_executable", lambda: "/nonexistent/resolved-git")
assert plugins_provenance._git_origin_url(plugin) == "https://example/from-config"
assert spawned == ["/nonexistent/resolved-git"]