Files
hermes-agent/hermes_cli/plugins_transaction.py
kshitijk4poor 062a6bfc57 refactor(plugins): one helper attributes scan blocks to preserved user files
64860c9c20 pasted the same try/except around the security scan in
_install_plugin_core and update_plugin, and plugins_transaction reached
into plugins_cmd_install for the private _preserved_files_note.

_scan_merged_tree now lives next to _scan_plugin_tree/PluginScanBlocked
in plugins_cmd and both sites call it once. Message, scan_result and the
chained cause are unchanged (checked for matched, unmatched, empty and
missing-scan_result cases). _preserved_files_note is typed
(PluginScanBlocked, list[str]) and drops the nested getattr/str() guards
for the module's usual `scan_result.findings if scan_result is not None`
form.
2026-09-27 01:42:52 +05:30

198 lines
11 KiB
Python

"""Publish plugin code and its dependency selection through one recoverable handoff."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import shutil
def recover_plugin_publication(project: Path, row: dict, journal: Path) -> None:
"""Recover a shipped caller's row through the stdlib boot-journal owner."""
from hermes_cli.runtime_state import _recover_plugin_publication
_recover_plugin_publication(project, row, journal)
def publish_plugin(staged: Path, target: Path, old_metadata: dict, new_metadata: dict,
*, target_digest: str | None = None, require_consent: bool = False) -> None:
from pm.client import sync_venv
from pm.plugin_inputs import StagedUpdate
from pm.store import tree_digest
if require_consent:
from hermes_cli import plugins_cmd
from pm.workspace import enabled_plugin_dirs
if target.resolve() in enabled_plugin_dirs(installing=target):
consented, reason = plugins_cmd._install_plugin_python_deps(
plugins_cmd._read_manifest_for_install(staged), staged, plugins_cmd._console())
if not consented:
raise plugins_cmd.PluginOperationError(
f"Reinstall declined: {reason}. The installed plugin and active environment are unchanged.")
sync_venv(explicit=True, plugins=StagedUpdate({
"staged": str(staged.resolve()), "target": str(target.absolute()),
"old_metadata": old_metadata, "new_metadata": new_metadata,
"target_digest": target_digest if target_digest is not None else (tree_digest(target) if target.exists() else None),
}))
def _refresh_declared_dependencies(target: Path, staged: Path, manifest: dict, *, interactive: bool) -> None:
"""Dependency changes carried by an update clear the same gates as an install.
New Python requirements install packages into the shared environment: install and
reinstall prompt for that, so an update prompts too, and an unattended one (dashboard,
``plugins.auto_apply`` from the gateway) is REFUSED rather than consented on the user's
behalf. Publication replaces the whole tree, so a Node sidecar the user accepted earlier is
rebuilt in the staged copy when its package.json/lock moved (custom pulls copy a stale
node_modules; catalog re-pins clone without one).
"""
from hermes_cli import plugins_cmd as pc
from hermes_cli.runtime_state import _bytes
from pm.plugin_declarations import read_python_declaration
from pm.workspace import enabled_plugin_dirs, install_node_sidecar
if (target / "node_modules").is_dir() and (staged / "package.json").is_file() and (
not (staged / "node_modules").is_dir()
or any(_bytes(target / name) != _bytes(staged / name) for name in ("package.json", "package-lock.json"))):
reason = install_node_sidecar(staged, explicit=True)
if reason:
raise pc.PluginOperationError(
f"Node dependencies could not be refreshed: {reason}. "
"The installed plugin and active environment are unchanged.")
if target.resolve() not in enabled_plugin_dirs(installing=target):
return # a disabled plugin's deps are admitted (and consented) by `hermes plugins enable`
before, after = read_python_declaration(target), read_python_declaration(staged)
added = tuple(spec for spec in after.install_requirements if spec not in before.install_requirements)
if not added and not (after.is_member and not before.is_member):
return
if not interactive:
raise pc.PluginOperationError(
f"The update declares new Python dependencies ({', '.join(added) or 'in its pyproject.toml'}); "
f"run `hermes plugins update {target.name}` in a terminal to review them.")
consented, reason = pc._consent_python_deps(manifest.get("name", target.name), added, pc._console())
if not consented:
raise pc.PluginOperationError(
f"Update declined: {reason}. The installed plugin and active environment are unchanged.")
def update_plugin(
target: Path,
*,
catalog_entry=None,
interactive: bool = False,
carry_user_files: Callable[[Path], list[str]] | None = None,
) -> str:
"""Prepare a catalog re-pin or custom Git pull without changing the live tree.
*interactive*: a terminal user is present to consent to newly declared dependencies;
the dashboard and the gateway's auto-apply pass False and get a refusal instead.
*carry_user_files(staged)* may merge user-owned state into the staged tree before
manifest validation, example-file generation, dependency preparation and publication; it
returns the carried paths so a scan block can attribute findings to them."""
import tempfile
from hermes_cli import plugins_cmd as pc
from hermes_cli.plugins_cmd_catalog import refuse_if_installed_removed
from pm.store import tree_digest
target = target.resolve()
metadata = pc._read_install_metadata()
record = dict(metadata.get(target.name, {}))
if catalog_entry is None and record.get("pinned"):
raise pc.PluginOperationError(f"Plugin '{target.name}' is pinned; reinstall with an explicit --ref to change it.")
source = catalog_entry.install_identifier if catalog_entry else str(record.get("source") or "")
if not source or (catalog_entry is None and not (target / ".git").is_dir()):
raise pc.PluginOperationError(f"Plugin '{target.name}' has no owned Git checkout; reinstall from its source.")
feed_revision = None
if catalog_entry is None:
from hermes_cli.plugins_provenance import Provenance, ProvenanceClass
from hermes_cli.plugins_updates import check_local_provenance, default_fetch, parse_feed_yml
checked = check_local_provenance(Provenance(target.name, ProvenanceClass.GIT, target, record))
if checked.needs_fixing:
raise pc.PluginOperationError(checked.needs_fixing)
if record.get("update_url"):
feed = parse_feed_yml(default_fetch(record["update_url"]))
proposed = (feed.get("artifacts") or {}).get("git", "")
if pc._EXACT_COMMIT_RE.fullmatch(proposed):
feed_revision = proposed.lower()
elif proposed not in (source, source.split("#", 1)[0]):
raise pc.PluginOperationError("Update feed must select a commit or the recorded Git source.")
if feed.get("min_hermes"):
pc._check_manifest_version({"requires_hermes": feed["min_hermes"]}, target.name)
refuse_if_installed_removed(target.name, target)
before = tree_digest(target)
with tempfile.TemporaryDirectory(prefix=".update-", dir=target.parent) as directory:
staged = Path(directory) / "plugin"
try:
if catalog_entry:
git_url, subdir = pc._resolve_git_url(source)
revision = pc._clone_plugin_repo(staged, git_url, catalog_entry.sha)
staged = pc._resolve_subdir_within(staged, subdir) if subdir else staged
output = f"Updated to catalog pin {revision}"
catalog_record = {
"name": catalog_entry.name,
"repo": catalog_entry.repo,
"tier": catalog_entry.tier,
"pin": catalog_entry.sha,
"sha": revision,
}
record.update(
catalog=catalog_record,
pinned=True,
source=pc._canonical_source(git_url, subdir),
)
record.pop("catalog_name", None)
record.pop("catalog_tier", None)
from hermes_cli.plugins_cmd_catalog import write_catalog_sidecar_record
write_catalog_sidecar_record(staged, catalog_record, revision)
else:
# Copy Git metadata and local changes. Autostash only ever touches the copy.
shutil.copytree(target, staged, symlinks=True, ignore=shutil.ignore_patterns("__pycache__"))
if feed_revision:
git = pc._resolve_git_executable()
status = pc._git_or_raise(git, target, "status", "--porcelain", failure_prefix="Could not inspect plugin edits: ")
if status.stdout.strip():
raise pc.PluginOperationError("Pinned feed update has local changes; save them before updating.")
pc._checkout_exact_revision(staged, git, feed_revision)
output = f"Updated to feed commit {feed_revision}"
else:
ok, output = pc._git_pull_plugin_dir(staged)
if not ok:
raise pc.PluginOperationError(output)
revision = pc._git_head_revision(staged, pc._resolve_git_executable())
merged = carry_user_files(staged) if carry_user_files is not None else None
manifest = pc._read_manifest_for_install(staged)
installed_name = str(manifest.get("name") or target.name)
if catalog_entry is None and installed_name != target.name:
raise pc.PluginOperationError("The updated plugin changed its installed name; reinstall it explicitly.")
try:
new_target = pc._sanitize_plugin_name(installed_name, target.parent)
except ValueError as exc:
raise pc.PluginOperationError(str(exc)) from exc
if new_target != target and new_target.exists():
raise pc.PluginOperationError(
f"The updated plugin renamed itself to '{installed_name}', but that plugin already exists.")
pc._check_manifest_version(manifest, installed_name)
pc._scan_merged_tree(staged, source, merged, force=False)
pc._copy_example_files(staged, pc._console())
_refresh_declared_dependencies(target, staged, manifest, interactive=interactive)
if tree_digest(target) != before:
raise pc.PluginOperationError("Plugin files changed while preparing the update; retry.")
record["revision"] = revision
if new_target == target and tree_digest(staged) == before:
return output
publish_plugin(
staged,
new_target,
metadata,
{**metadata, installed_name: record},
target_digest=before if new_target == target else None,
)
return output
except pc.PluginOperationError:
raise
except Exception as exc:
raise pc.PluginOperationError(f"Plugin '{target.name}' update was not published: {exc}") from exc