feat(plugins): install exact commit refs

This commit is contained in:
Teknium 2026-08-08 13:02:36 -07:00
parent d409f67485
commit 6601330e0a
5 changed files with 713 additions and 36 deletions

View File

@ -14,9 +14,12 @@ import importlib.metadata
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.parse
from pathlib import Path
from typing import Any, Optional
@ -24,6 +27,7 @@ from hermes_constants import get_hermes_home
from hermes_cli._subprocess_compat import noninteractive_git_env
from hermes_cli.config import cfg_get
from hermes_cli.secret_prompt import masked_secret_prompt
from utils import atomic_write_text
logger = logging.getLogger(__name__)
@ -459,31 +463,202 @@ def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path:
# ---------------------------------------------------------------------------
def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, str]:
"""Clone Git plugin into ``~/.hermes/plugins``.
_EXACT_COMMIT_RE = re.compile(r"^[0-9a-fA-F]{40}$")
_INSTALL_METADATA_FILE = ".install-metadata.json"
Returns ``(target_dir, installed_manifest, canonical_name)``.
Raises ``PluginOperationError`` on failure.
"""
import tempfile
def _install_metadata_path() -> Path:
return get_hermes_home() / "plugins" / _INSTALL_METADATA_FILE
def _read_install_metadata() -> dict[str, dict[str, object]]:
"""Read profile-local, non-secret plugin source metadata from disk."""
path = _install_metadata_path()
if not path.exists():
return {}
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise PluginOperationError(f"Could not read plugin install metadata: {exc}") from exc
if not isinstance(value, dict):
raise PluginOperationError("Plugin install metadata must be a JSON object.")
return value
def _write_install_metadata(metadata: dict[str, dict[str, object]]) -> None:
"""Atomically replace the profile-local plugin install metadata sidecar."""
path = _install_metadata_path()
atomic_write_text(
path,
json.dumps(metadata, indent=2, sort_keys=True) + "\n",
tmp_prefix=f"{path.name}.tmp-",
)
def _normalize_exact_revision(ref: str) -> str:
if not isinstance(ref, str) or not _EXACT_COMMIT_RE.fullmatch(ref):
raise PluginOperationError("--ref must be a full 40-character commit SHA.")
return ref.lower()
def _safe_git_error(result: subprocess.CompletedProcess, source_url: str = "") -> str:
"""Return diagnosable Git output without echoing embedded credentials."""
from agent.redact import redact_sensitive_text
error = (result.stderr or result.stdout or "").strip()
if source_url:
error = error.replace(source_url, _scrub_git_url(source_url))
return redact_sensitive_text(error)
def _git_head_revision(repo: Path, git_exe: str) -> str:
result = subprocess.run(
[git_exe, "rev-parse", "HEAD"],
cwd=str(repo),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=15,
stdin=subprocess.DEVNULL,
env=noninteractive_git_env(),
)
if result.returncode != 0:
err = _safe_git_error(result)
raise PluginOperationError(f"Could not determine installed Git revision:\n{err}")
return result.stdout.strip().lower()
def _checkout_exact_revision(repo: Path, git_exe: str, revision: str) -> None:
"""Fetch and detach at one immutable commit, then verify the resulting HEAD."""
try:
fetched = subprocess.run(
[git_exe, "fetch", "--depth", "1", "origin", revision],
cwd=str(repo),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
stdin=subprocess.DEVNULL,
env=noninteractive_git_env(),
)
except subprocess.TimeoutExpired as exc:
raise PluginOperationError(
f"Git fetch of commit '{revision}' timed out after 60 seconds."
) from exc
if fetched.returncode != 0:
err = _safe_git_error(fetched)
raise PluginOperationError(
f"Git commit '{revision}' could not be fetched:\n{err}"
)
try:
checked_out = subprocess.run(
[git_exe, "checkout", "--detach", revision],
cwd=str(repo),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
stdin=subprocess.DEVNULL,
env=noninteractive_git_env(),
)
except subprocess.TimeoutExpired as exc:
raise PluginOperationError(
f"Git checkout of commit '{revision}' timed out after 60 seconds."
) from exc
if checked_out.returncode != 0:
err = _safe_git_error(checked_out)
raise PluginOperationError(
f"Git checkout of commit '{revision}' failed:\n{err}"
)
actual = _git_head_revision(repo, git_exe)
if actual != revision:
raise PluginOperationError(
f"Checked-out revision '{actual}' does not match requested commit '{revision}'."
)
def _scrub_git_url(git_url: str) -> str:
"""Strip credentials and query/fragment data from an HTTP Git URL."""
parsed = urllib.parse.urlsplit(git_url)
if parsed.scheme in {"http", "https"} and parsed.hostname:
host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname
if parsed.port is not None:
host = f"{host}:{parsed.port}"
return urllib.parse.urlunsplit(
(parsed.scheme, host, parsed.path, "", "")
)
return git_url
def _canonical_source(git_url: str, subdir: Optional[str]) -> str:
scrubbed = _scrub_git_url(git_url)
return f"{scrubbed}#{subdir}" if subdir else scrubbed
def _scrub_cloned_origin(repo: Path, git_exe: str, git_url: str) -> None:
"""Ensure credentials used for cloning do not survive in ``.git/config``."""
scrubbed = _scrub_git_url(git_url)
if scrubbed == git_url:
return
result = subprocess.run(
[git_exe, "remote", "set-url", "origin", scrubbed],
cwd=str(repo),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=15,
stdin=subprocess.DEVNULL,
env=noninteractive_git_env(),
)
if result.returncode != 0:
err = _safe_git_error(result, git_url)
raise PluginOperationError(f"Could not sanitize installed Git remote:\n{err}")
def _install_plugin_core(
identifier: str, *, force: bool, ref: Optional[str] = None
) -> tuple[Path, dict, str]:
"""Clone a Git plugin and atomically record its source and exact revision."""
requested_revision = _normalize_exact_revision(ref) if ref is not None else None
try:
git_url, subdir = _resolve_git_url(identifier)
except ValueError as e:
raise PluginOperationError(str(e)) from e
plugins_dir = _plugins_dir()
source = _canonical_source(git_url, subdir)
old_metadata = _read_install_metadata()
with tempfile.TemporaryDirectory() as tmp:
# Reinstalling the same pinned source retains its pin, even if its plugin
# directory was manually removed. Moving a pin requires an explicit --ref.
if requested_revision is None:
matching_pins = [
entry
for entry in old_metadata.values()
if entry.get("source") == source and entry.get("pinned") is True
]
if len(matching_pins) == 1:
revision = matching_pins[0].get("revision")
if isinstance(revision, str):
requested_revision = _normalize_exact_revision(revision)
with tempfile.TemporaryDirectory(prefix=".install-", dir=plugins_dir) as tmp:
tmp_clone = Path(tmp) / "plugin"
git_exe = _resolve_git_executable()
if not git_exe:
raise PluginOperationError("git is not installed or not in PATH.")
clone_args = [git_exe, "clone", "--depth", "1"]
if requested_revision:
clone_args.append("--no-checkout")
clone_args.extend([git_url, str(tmp_clone)])
try:
result = subprocess.run(
[git_exe, "clone", "--depth", "1", git_url, str(tmp_clone)],
clone_args,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
timeout=60,
@ -491,24 +666,21 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s
env=noninteractive_git_env(),
)
except FileNotFoundError as e:
raise PluginOperationError(
"git is not installed or not in PATH.",
) from e
raise PluginOperationError("git is not installed or not in PATH.") from e
except subprocess.TimeoutExpired as e:
raise PluginOperationError(
"Git clone timed out after 60 seconds.",
) from e
raise PluginOperationError("Git clone timed out after 60 seconds.") from e
if result.returncode != 0:
err = (result.stderr or result.stdout or "").strip()
err = _safe_git_error(result, git_url)
raise PluginOperationError(f"Git clone failed:\n{err}")
# Resolve the directory within the clone that holds the plugin.
if subdir:
tmp_target = _resolve_subdir_within(tmp_clone, subdir)
else:
tmp_target = tmp_clone
_scrub_cloned_origin(tmp_clone, git_exe, git_url)
if requested_revision:
_checkout_exact_revision(tmp_clone, git_exe, requested_revision)
installed_revision = _git_head_revision(tmp_clone, git_exe)
tmp_target = (
_resolve_subdir_within(tmp_clone, subdir) if subdir else tmp_clone
)
has_native_manifest = (tmp_target / "plugin.yaml").exists() or (
tmp_target / "plugin.yml"
).exists()
@ -531,7 +703,6 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s
plugin_name = manifest.get("name") or (
subdir.rstrip("/").rsplit("/", 1)[-1] if subdir else _repo_name_from_url(git_url)
)
try:
target = _sanitize_plugin_name(plugin_name, plugins_dir)
except ValueError as e:
@ -555,15 +726,46 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s
f"Run {recommended_update_command()} to update Hermes.",
) from None
if target.exists():
if not force:
raise PluginOperationError(
f"Plugin '{plugin_name}' already exists. Use force reinstall "
f"or run `hermes plugins update {plugin_name}`.",
)
shutil.rmtree(target)
if target.exists() and not force:
raise PluginOperationError(
f"Plugin '{plugin_name}' already exists. Use force reinstall "
f"or run `hermes plugins update {plugin_name}`."
)
prior = old_metadata.get(plugin_name)
if (
target.exists()
and requested_revision is None
and isinstance(prior, dict)
and prior.get("pinned") is True
):
raise PluginOperationError(
f"Plugin '{plugin_name}' is pinned. Reinstall it with an explicit "
"--ref <40-character commit SHA> to change its source or revision."
)
shutil.move(str(tmp_target), str(target))
new_metadata = dict(old_metadata)
new_metadata[plugin_name] = {
"pinned": requested_revision is not None,
"revision": installed_revision,
"source": source,
}
backup = Path(tmp) / "previous-plugin"
replaced_existing = target.exists()
if replaced_existing:
os.replace(target, backup)
try:
os.replace(tmp_target, target)
_write_install_metadata(new_metadata)
except Exception:
if target.exists():
shutil.rmtree(target)
if replaced_existing and backup.exists():
os.replace(backup, target)
if old_metadata:
_write_install_metadata(old_metadata)
else:
_install_metadata_path().unlink(missing_ok=True)
raise
has_yaml = (target / "plugin.yaml").exists() or (target / "plugin.yml").exists()
has_portable = (target / "plugin.json").exists()
@ -585,6 +787,7 @@ def cmd_install(
identifier: str,
force: bool = False,
enable: Optional[bool] = None,
ref: Optional[str] = None,
) -> None:
"""Install a plugin from a Git URL or owner/repo shorthand.
@ -616,6 +819,7 @@ def cmd_install(
target, installed_manifest, installed_name = _install_plugin_core(
identifier,
force=force,
ref=ref,
)
except PluginOperationError as e:
console.print(f"[red]Error:[/red] {e}")
@ -670,6 +874,7 @@ def cmd_install(
def cmd_update(name: str) -> None:
"""Update an installed plugin by pulling latest from its git remote."""
from rich.console import Console
from rich.markup import escape
console = Console()
plugins_dir = _plugins_dir()
@ -680,6 +885,22 @@ def cmd_update(name: str) -> None:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
try:
metadata = _read_install_metadata()
except PluginOperationError as exc:
console.print(f"[red]Error:[/red] {exc}")
sys.exit(1)
install_record = metadata.get(target.name, {})
if install_record.get("pinned") is True:
recorded_source = escape(str(install_record.get("source", "<source>")))
console.print(
f"[red]Error:[/red] Plugin '{name}' is pinned to "
f"{install_record.get('revision')}. To move it, run "
f"`hermes plugins install {recorded_source} --force "
"--ref <40-character commit SHA>`."
)
sys.exit(1)
if not (target / ".git").exists():
console.print(
f"[red]Error:[/red] Plugin '{name}' was not installed from git "
@ -694,6 +915,13 @@ def cmd_update(name: str) -> None:
console.print(f"[red]Error:[/red] {output}")
sys.exit(1)
if install_record:
git_exe = _resolve_git_executable()
if git_exe:
install_record["revision"] = _git_head_revision(target, git_exe)
metadata[target.name] = install_record
_write_install_metadata(metadata)
# Same stale-bytecode class as the main checkout (#6207/#60242): the
# pull just changed .py files under this plugin dir, so drop any
# __pycache__ compiled from the previous revision.
@ -712,6 +940,35 @@ def cmd_update(name: str) -> None:
console.print(f"[dim]{out}[/dim]")
def _remove_plugin_core(target: Path) -> None:
"""Remove one plugin and its metadata without splitting their state."""
metadata = _read_install_metadata()
if target.name not in metadata:
shutil.rmtree(target)
return
updated = dict(metadata)
updated.pop(target.name)
staging = Path(
tempfile.mkdtemp(prefix=f".{target.name}.remove-", dir=target.parent)
)
backup = staging / "plugin"
os.replace(target, backup)
try:
_write_install_metadata(updated)
except Exception:
try:
os.replace(backup, target)
except OSError as restore_exc:
raise PluginOperationError(
f"Plugin metadata update failed and '{target.name}' could not be "
f"restored automatically; recovery copy remains at {backup}."
) from restore_exc
shutil.rmtree(staging, ignore_errors=True)
raise
shutil.rmtree(staging)
def cmd_remove(name: str) -> None:
"""Remove an installed plugin by name."""
from rich.console import Console
@ -725,7 +982,11 @@ def cmd_remove(name: str) -> None:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
shutil.rmtree(target)
try:
_remove_plugin_core(target)
except (OSError, PluginOperationError) as exc:
console.print(f"[red]Error:[/red] Could not remove plugin '{name}': {exc}")
sys.exit(1)
_display_removed(name, plugins_dir)
@ -2040,6 +2301,22 @@ def dashboard_update_user_plugin(name: str) -> dict[str, Any]:
"error": f"Plugin '{name}' was not found under {_plugins_dir()}.",
}
try:
metadata = _read_install_metadata()
except PluginOperationError as exc:
return {"ok": False, "error": str(exc)}
install_record = metadata.get(target.name, {})
if install_record.get("pinned") is True:
recorded_source = install_record.get("source", "<source>")
return {
"ok": False,
"error": (
f"Plugin '{name}' is pinned to {install_record.get('revision')}; "
f"run `hermes plugins install {recorded_source} --force "
"--ref <40-character commit SHA>` to move it."
),
}
if not (target / ".git").exists():
return {
"ok": False,
@ -2050,6 +2327,13 @@ def dashboard_update_user_plugin(name: str) -> dict[str, Any]:
if not ok:
return {"ok": False, "error": msg}
if install_record:
git_exe = _resolve_git_executable()
if git_exe:
install_record["revision"] = _git_head_revision(target, git_exe)
metadata[target.name] = install_record
_write_install_metadata(metadata)
# Sibling of the CLI ``hermes plugins update`` path: drop bytecode
# compiled from the pre-pull plugin revision.
_clear_plugin_bytecode(target)
@ -2105,7 +2389,7 @@ def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]:
return False, "Git pull timed out after 60 seconds."
if result.returncode != 0:
err = (result.stderr or "").strip() or result.stdout.strip()
err = _safe_git_error(result)
return False, err or "git pull failed."
return True, result.stdout.strip()
@ -2124,7 +2408,10 @@ def dashboard_remove_user_plugin(name: str) -> dict[str, Any]:
"error": f"Plugin '{name}' was not found under {plugins_dir}.",
}
shutil.rmtree(target)
try:
_remove_plugin_core(target)
except (OSError, PluginOperationError) as exc:
return {"ok": False, "error": f"Could not remove plugin '{name}': {exc}"}
return {"ok": True, "name": name}
@ -2155,6 +2442,7 @@ def plugins_command(args) -> None:
args.identifier,
force=getattr(args, "force", False),
enable=enable_arg,
ref=getattr(args, "ref", None),
)
elif action == "update":
cmd_update(args.name)

View File

@ -34,6 +34,11 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
action="store_true",
help="Remove existing plugin and reinstall",
)
plugins_install.add_argument(
"--ref",
metavar="COMMIT_SHA",
help="Install exactly one immutable 40-character Git commit SHA",
)
_install_enable_group = plugins_install.add_mutually_exclusive_group()
_install_enable_group.add_argument(
"--enable",

View File

@ -0,0 +1,366 @@
"""Exact-commit plugin installation and source metadata."""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
from pathlib import Path
import pytest
import yaml
from hermes_cli.subcommands.plugins import build_plugins_parser
def _git(repo: Path, *args: str) -> str:
result = subprocess.run(
["git", *args], cwd=repo, check=True, capture_output=True, text=True
)
return result.stdout.strip()
def _commit(repo: Path, message: str, marker: str) -> str:
(repo / "marker.txt").write_text(marker, encoding="utf-8")
_git(repo, "add", ".")
_git(repo, "commit", "-qm", message)
return _git(repo, "rev-parse", "HEAD")
def _plugin_repo(root: Path, name: str = "demo") -> tuple[Path, str, str]:
repo = root / "repo"
repo.mkdir()
_git(repo, "init", "-q")
_git(repo, "config", "user.email", "fixture@example.com")
_git(repo, "config", "user.name", "Fixture")
(repo / "plugin.yaml").write_text(
yaml.safe_dump({"name": name, "version": "1.0.0"}), encoding="utf-8"
)
old_sha = _commit(repo, "old", "old")
new_sha = _commit(repo, "new", "new")
return repo, old_sha, new_sha
def _metadata(home: Path) -> dict:
return json.loads((home / "plugins" / ".install-metadata.json").read_text())
def test_parser_accepts_only_explicit_install_ref_option():
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command")
build_plugins_parser(subparsers, cmd_plugins=lambda _args: None)
args = parser.parse_args(["plugins", "install", "owner/repo", "--ref", "a" * 40])
assert args.ref == "a" * 40
def test_canonical_source_never_persists_http_credentials():
from hermes_cli.plugins_cmd import _canonical_source
assert (
_canonical_source("https://user:token@example.com/owner/repo.git", None)
== "https://example.com/owner/repo.git"
)
assert (
_canonical_source("https://example.com/owner/repo.git?token=secret", None)
== "https://example.com/owner/repo.git"
)
def test_cloned_origin_never_persists_http_credentials(tmp_path):
from hermes_cli.plugins_cmd import _scrub_cloned_origin
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q")
_git(
repo,
"remote",
"add",
"origin",
"https://user:secret@example.com/owner/repo.git?token=secret",
)
_scrub_cloned_origin(
repo,
"git",
"https://user:secret@example.com/owner/repo.git?token=secret",
)
assert _git(repo, "remote", "get-url", "origin") == (
"https://example.com/owner/repo.git"
)
assert "secret" not in (repo / ".git" / "config").read_text(encoding="utf-8")
def test_git_errors_never_echo_source_credentials():
from hermes_cli.plugins_cmd import _safe_git_error
source = "https://user:secret@example.com/owner/repo.git?token=secret"
result = subprocess.CompletedProcess(
args=["git", "clone"],
returncode=1,
stdout="",
stderr=f"fatal: unable to access '{source}': connection failed",
)
error = _safe_git_error(result, source)
assert "secret" not in error
assert "user:" not in error
assert "https://example.com/owner/repo.git" in error
def test_exact_ref_installs_old_commit_and_normalizes_uppercase(monkeypatch, tmp_path):
from hermes_cli.plugins_cmd import _install_plugin_core
repo, old_sha, new_sha = _plugin_repo(tmp_path)
home = tmp_path / "home"
monkeypatch.setenv("HERMES_HOME", str(home))
target, _manifest, name = _install_plugin_core(
repo.as_uri(), force=False, ref=old_sha.upper()
)
assert name == "demo"
assert _git(target, "rev-parse", "HEAD") == old_sha
assert old_sha != new_sha
assert (target / "marker.txt").read_text() == "old"
assert _metadata(home) == {
"demo": {"pinned": True, "revision": old_sha, "source": repo.as_uri()}
}
@pytest.mark.parametrize("ref", ["", "main", "abc", "g" * 40, "a" * 39, "a" * 41])
def test_invalid_ref_is_rejected_before_any_install_state(monkeypatch, tmp_path, ref):
from hermes_cli.plugins_cmd import PluginOperationError, _install_plugin_core
repo, _old_sha, _new_sha = _plugin_repo(tmp_path)
home = tmp_path / "home"
monkeypatch.setenv("HERMES_HOME", str(home))
with pytest.raises(PluginOperationError, match="40-character commit SHA"):
_install_plugin_core(repo.as_uri(), force=False, ref=ref)
assert not (home / "plugins" / "demo").exists()
assert not (home / "plugins" / ".install-metadata.json").exists()
def test_subdir_pin_records_source_identity_and_installs_requested_tree(
monkeypatch, tmp_path
):
from hermes_cli.plugins_cmd import _install_plugin_core
repo = tmp_path / "monorepo"
plugin = repo / "extensions" / "demo"
plugin.mkdir(parents=True)
_git(repo, "init", "-q")
_git(repo, "config", "user.email", "fixture@example.com")
_git(repo, "config", "user.name", "Fixture")
(plugin / "plugin.yaml").write_text("name: nested-demo\n", encoding="utf-8")
(plugin / "value.txt").write_text("old", encoding="utf-8")
_git(repo, "add", ".")
_git(repo, "commit", "-qm", "old")
old_sha = _git(repo, "rev-parse", "HEAD")
(plugin / "value.txt").write_text("new", encoding="utf-8")
_git(repo, "commit", "-qam", "new")
home = tmp_path / "home"
monkeypatch.setenv("HERMES_HOME", str(home))
identifier = f"{repo.as_uri()}#extensions/demo"
target, _manifest, _name = _install_plugin_core(
identifier, force=False, ref=old_sha
)
assert (target / "value.txt").read_text() == "old"
assert _metadata(home)["nested-demo"] == {
"pinned": True,
"revision": old_sha,
"source": identifier,
}
def test_force_reinstall_does_not_drift_pin_without_explicit_new_ref(
monkeypatch, tmp_path
):
from hermes_cli.plugins_cmd import _install_plugin_core
repo, old_sha, new_sha = _plugin_repo(tmp_path)
home = tmp_path / "home"
monkeypatch.setenv("HERMES_HOME", str(home))
_install_plugin_core(repo.as_uri(), force=False, ref=old_sha)
target, _manifest, _name = _install_plugin_core(repo.as_uri(), force=True)
assert _git(target, "rev-parse", "HEAD") == old_sha
assert _metadata(home)["demo"]["pinned"] is True
target, _manifest, _name = _install_plugin_core(
repo.as_uri(), force=True, ref=new_sha
)
assert _git(target, "rev-parse", "HEAD") == new_sha
assert _metadata(home)["demo"]["revision"] == new_sha
def test_unpinned_install_and_force_reinstall_keep_tracking_head(monkeypatch, tmp_path):
from hermes_cli.plugins_cmd import _install_plugin_core
repo, _old_sha, first_head = _plugin_repo(tmp_path)
home = tmp_path / "home"
monkeypatch.setenv("HERMES_HOME", str(home))
target, _manifest, _name = _install_plugin_core(repo.as_uri(), force=False)
assert _git(target, "rev-parse", "HEAD") == first_head
assert _metadata(home)["demo"]["pinned"] is False
next_head = _commit(repo, "later", "later")
target, _manifest, _name = _install_plugin_core(repo.as_uri(), force=True)
assert _git(target, "rev-parse", "HEAD") == next_head
assert _metadata(home)["demo"]["revision"] == next_head
assert _metadata(home)["demo"]["pinned"] is False
def test_metadata_is_profile_local_and_read_from_disk_each_time(monkeypatch, tmp_path):
from hermes_cli.plugins_cmd import _install_plugin_core, _read_install_metadata
repo, old_sha, _new_sha = _plugin_repo(tmp_path)
home_a = tmp_path / "profile-a"
home_b = tmp_path / "profile-b"
monkeypatch.setenv("HERMES_HOME", str(home_a))
_install_plugin_core(repo.as_uri(), force=False, ref=old_sha)
assert _read_install_metadata()["demo"]["revision"] == old_sha
monkeypatch.setenv("HERMES_HOME", str(home_b))
assert _read_install_metadata() == {}
monkeypatch.setenv("HERMES_HOME", str(home_a))
assert _read_install_metadata()["demo"]["source"] == repo.as_uri()
def test_pinned_plugin_update_refuses_to_drift(monkeypatch, tmp_path, capsys):
from hermes_cli.plugins_cmd import _install_plugin_core, cmd_update
repo, old_sha, _new_sha = _plugin_repo(tmp_path)
home = tmp_path / "home"
monkeypatch.setenv("HERMES_HOME", str(home))
_install_plugin_core(repo.as_uri(), force=False, ref=old_sha)
with pytest.raises(SystemExit) as exc:
cmd_update("demo")
assert exc.value.code == 1
assert "pinned" in capsys.readouterr().out.lower()
assert _git(home / "plugins" / "demo", "rev-parse", "HEAD") == old_sha
def test_dashboard_update_also_refuses_to_drift_pin(monkeypatch, tmp_path):
from hermes_cli.plugins_cmd import (
_install_plugin_core,
dashboard_update_user_plugin,
)
repo, old_sha, _new_sha = _plugin_repo(tmp_path)
home = tmp_path / "home"
monkeypatch.setenv("HERMES_HOME", str(home))
_install_plugin_core(repo.as_uri(), force=False, ref=old_sha)
result = dashboard_update_user_plugin("demo")
assert result["ok"] is False
assert "pinned" in result["error"]
assert _git(home / "plugins" / "demo", "rev-parse", "HEAD") == old_sha
def test_failed_force_reinstall_keeps_existing_plugin_and_metadata(
monkeypatch, tmp_path
):
from hermes_cli.plugins_cmd import PluginOperationError, _install_plugin_core
repo, old_sha, _new_sha = _plugin_repo(tmp_path)
home = tmp_path / "home"
monkeypatch.setenv("HERMES_HOME", str(home))
target, _manifest, _name = _install_plugin_core(
repo.as_uri(), force=False, ref=old_sha
)
before = _metadata(home)
with pytest.raises(PluginOperationError):
_install_plugin_core(repo.as_uri(), force=True, ref="f" * 40)
assert target.exists()
assert _git(target, "rev-parse", "HEAD") == old_sha
assert _metadata(home) == before
def test_checkout_mismatch_is_rejected(monkeypatch, tmp_path):
from hermes_cli.plugins_cmd import PluginOperationError, _checkout_exact_revision
repo, old_sha, new_sha = _plugin_repo(tmp_path)
clone = tmp_path / "clone"
subprocess.run(["git", "clone", "-q", repo.as_uri(), str(clone)], check=True)
monkeypatch.setattr(
"hermes_cli.plugins_cmd._git_head_revision", lambda _repo, _git: new_sha
)
with pytest.raises(PluginOperationError, match="does not match requested"):
_checkout_exact_revision(clone, "git", old_sha)
def test_metadata_write_failure_rolls_back_new_install(monkeypatch, tmp_path):
from hermes_cli.plugins_cmd import _install_plugin_core
repo, old_sha, _new_sha = _plugin_repo(tmp_path)
home = tmp_path / "home"
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(
"hermes_cli.plugins_cmd._write_install_metadata",
lambda _metadata: (_ for _ in ()).throw(OSError("disk full")),
)
with pytest.raises(OSError, match="disk full"):
_install_plugin_core(repo.as_uri(), force=False, ref=old_sha)
assert not (home / "plugins" / "demo").exists()
assert not (home / "plugins" / ".install-metadata.json").exists()
def test_metadata_write_failure_rolls_back_removal(monkeypatch, tmp_path):
from hermes_cli.plugins_cmd import _install_plugin_core, _remove_plugin_core
repo, old_sha, _new_sha = _plugin_repo(tmp_path)
home = tmp_path / "home"
monkeypatch.setenv("HERMES_HOME", str(home))
target, _manifest, _name = _install_plugin_core(
repo.as_uri(), force=False, ref=old_sha
)
before = _metadata(home)
monkeypatch.setattr(
"hermes_cli.plugins_cmd._write_install_metadata",
lambda _metadata: (_ for _ in ()).throw(OSError("disk full")),
)
with pytest.raises(OSError, match="disk full"):
_remove_plugin_core(target)
assert target.exists()
assert _git(target, "rev-parse", "HEAD") == old_sha
assert _metadata(home) == before
assert list(target.parent.glob(".demo.remove-*")) == []
def test_reinstall_after_manual_directory_removal_retains_pin(monkeypatch, tmp_path):
from hermes_cli.plugins_cmd import _install_plugin_core
repo, old_sha, _new_sha = _plugin_repo(tmp_path)
home = tmp_path / "home"
monkeypatch.setenv("HERMES_HOME", str(home))
target, _manifest, _name = _install_plugin_core(
repo.as_uri(), force=False, ref=old_sha
)
shutil.rmtree(target)
target, _manifest, _name = _install_plugin_core(repo.as_uri(), force=False)
assert _git(target, "rev-parse", "HEAD") == old_sha
assert _metadata(home)["demo"]["pinned"] is True

View File

@ -1374,8 +1374,8 @@ Unified plugin management — general plugins, memory providers, and context eng
| Subcommand | Description |
|------------|-------------|
| *(none)* | Composite interactive UI — general plugin toggles + provider plugin configuration. |
| `install <identifier> [--force]` | Install a plugin from a Git URL or `owner/repo`. |
| `update <name>` | Pull latest changes for an installed plugin. |
| `install <identifier> [--force] [--ref COMMIT_SHA]` | Install a plugin from a Git URL or `owner/repo`. `--ref` accepts only a full 40-character commit SHA and installs that exact immutable revision. |
| `update <name>` | Pull latest changes for an unpinned installed plugin. Pinned plugins must be reinstalled with `--force --ref <new-commit>` to move. |
| `remove <name>` (aliases: `rm`, `uninstall`) | Remove an installed plugin. |
| `enable <name>` | Enable a disabled plugin. |
| `disable <name>` | Disable a plugin without removing it. |
@ -1387,6 +1387,9 @@ Provider plugin selections are saved to `config.yaml`:
- `context.engine` — active context engine (`"compressor"` = built-in default)
General plugin disabled list is stored in `config.yaml` under `plugins.disabled`.
Git installs also record only their canonical source, exact installed revision, and
pin status in the profile-local `plugins/.install-metadata.json` sidecar. It does
not contain plugin config, environment values, secrets, or capability grants.
See [Plugins](../user-guide/features/plugins.md) and [Build a Hermes Plugin](../developer-guide/plugins/index.md).

View File

@ -167,6 +167,21 @@ hermes plugins disable <name> # remove from allow-list + add to disabled
After `hermes plugins install owner/repo`, you're asked `Enable 'name' now? [y/N]` — defaults to no. Skip the prompt for scripted installs with `--enable` or `--no-enable`.
For a reproducible install, pin a full immutable commit (tags, branches, and
abbreviated SHAs are not accepted):
```bash
hermes plugins install owner/repo --ref 0123456789abcdef0123456789abcdef01234567
```
Hermes checks out the commit detached, verifies that `HEAD` exactly matches the
requested SHA, and records the canonical source, installed revision, and pin
status in the current profile. `hermes plugins update` refuses to move a pinned
plugin; choose a new exact commit explicitly with
`hermes plugins install <source> --force --ref <new-commit>`. The
profile-local install metadata contains no config values, environment values,
secrets, or capability grants.
### What the allow-list does NOT gate
Several categories of plugin bypass `plugins.enabled` — they're part of Hermes' built-in surface and would break basic functionality if gated off by default: