feat(plugins): install exact commit refs
This commit is contained in:
parent
d409f67485
commit
6601330e0a
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue