From 5819226468ab154fccc607fef52302d39e4d4e81 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:32:06 -0800 Subject: [PATCH] feat(plugins): one switch and one action for unattended plugin updates main already ships `hermes plugins update` (no name = update all) and the opt-in `plugins.auto_apply` cadence. This makes both reachable from every surface and closes the gaps reported on the original PR: - Catalog installs now ride `plugins.auto_apply` too (it moved only git rows, so catalog plugins stayed on their install-day pin). The update path still refuses a widened pin or a too-new requires_hermes unattended. - One updater per plugin: a cross-process lock around update_one and the dashboard/Desktop update, so the gateway's auto-apply tick and a manual update never run on the same plugin at once; the loser changes nothing. - `hermes update` names, per profile, the catalog installs its new catalog pins left behind and re-arms that profile's plugin check, so an opted-in gateway applies them on the next tick. - New CLI verb `hermes plugins auto-update [on|off]`; `/plugins update` and `/plugins auto-update [on|off]` in the classic CLI and the TUI (slash worker). - RPC `plugins.manage` actions `update_all` and `auto_update` (contract + regenerated apps/shared outputs) for Desktop. Off by default (legacy behaviour); all writes are profile-scoped through get_hermes_home()/the scoped RPC. --- apps/shared/src/gateway-contract.generated.ts | 20 +- apps/shared/src/gateway-contract.openrpc.json | 125 +++++++++++- hermes_cli/cli_loops_mixin.py | 7 + hermes_cli/commands.py | 3 +- hermes_cli/config_defaults.py | 8 +- hermes_cli/plugins_auto_update.py | 191 ++++++++++++++++++ hermes_cli/plugins_cadence.py | 20 +- hermes_cli/plugins_cmd.py | 16 ++ hermes_cli/plugins_cmd_update.py | 23 ++- hermes_cli/subcommands/plugins.py | 10 + hermes_cli/update_cmd_maint.py | 12 ++ scripts/ci/classify_changes.py | 1 + tests/hermes_cli/test_plugins_auto_update.py | 113 +++++++++++ tui_gateway/contracts/tools_mcp_plugins.py | 34 +++- tui_gateway/methods_tools.py | 24 ++- 15 files changed, 583 insertions(+), 24 deletions(-) create mode 100644 hermes_cli/plugins_auto_update.py create mode 100644 tests/hermes_cli/test_plugins_auto_update.py diff --git a/apps/shared/src/gateway-contract.generated.ts b/apps/shared/src/gateway-contract.generated.ts index 9db602f406..2aebb42320 100644 --- a/apps/shared/src/gateway-contract.generated.ts +++ b/apps/shared/src/gateway-contract.generated.ts @@ -4466,7 +4466,7 @@ export interface PluginToolsetRow { label: string description: string } -/** ``toggle``: ``key``/``name`` + ``enable``; ``install``: ``identifier``/``repo`` or ``catalog_name`` (+ ``force``, ``enable``, ``ref``, ``inspected_sha`` = the commit ``inspect`` read: a custom source with no ``ref`` whose branch moved since is refused); ``inspect``: ``identifier``/``repo`` or ``catalog_name``; ``install_desktop``: ``identifier``/``repo`` (any source ``install`` takes) or ``catalog_name``, always with ``ref`` (the commit ``inspect`` read and the dialog showed; another commit is refused) (+ ``force``) — the desktop half onto the host that serves this client; ``remove_desktop``: ``name`` (+ ``force`` for a package's own half); ``update``: ``name`` (+ ``accept_capabilities`` to apply a re-pin that widened the plugin after the user confirmed the ``delta``, + ``force`` to apply an update whose security scan returned caution after the user saw the findings); ``remove``: ``name`` (user installs only); ``settings``: ``key`` + ``values`` (``{setting_key: value}``, non-secret schema keys only); ``presence``: ``names`` (catalog names, answered in that order). */ +/** ``toggle``: ``key``/``name`` + ``enable``; ``install``: ``identifier``/``repo`` or ``catalog_name`` (+ ``force``, ``enable``, ``ref``, ``inspected_sha`` = the commit ``inspect`` read: a custom source with no ``ref`` whose branch moved since is refused); ``inspect``: ``identifier``/``repo`` or ``catalog_name``; ``install_desktop``: ``identifier``/``repo`` (any source ``install`` takes) or ``catalog_name``, always with ``ref`` (the commit ``inspect`` read and the dialog showed; another commit is refused) (+ ``force``) — the desktop half onto the host that serves this client; ``remove_desktop``: ``name`` (+ ``force`` for a package's own half); ``update``: ``name`` (+ ``accept_capabilities`` to apply a re-pin that widened the plugin after the user confirmed the ``delta``, + ``force`` to apply an update whose security scan returned caution after the user saw the findings); ``remove``: ``name`` (user installs only); ``settings``: ``key`` + ``values`` (``{setting_key: value}``, non-secret schema keys only); ``presence``: ``names`` (catalog names, answered in that order); ``update_all``: no fields; ``auto_update``: ``enable`` to set ``plugins.auto_apply`` (omit to read it). */ export interface PluginsManageParams { profile?: string | null action?: PluginsAction @@ -4483,7 +4483,7 @@ export interface PluginsManageParams { values?: Record | null names?: string[] | null } -export type PluginsAction = 'list' | 'toggle' | 'install' | 'inspect' | 'install_desktop' | 'remove_desktop' | 'update' | 'remove' | 'settings' | 'onboarding' | 'presence' +export type PluginsAction = 'list' | 'toggle' | 'install' | 'inspect' | 'install_desktop' | 'remove_desktop' | 'update' | 'remove' | 'settings' | 'onboarding' | 'presence' | 'update_all' | 'auto_update' /** ``list`` → ``plugins`` + counts; ``toggle`` → ``ok``/``unchanged``/``restart_required``/``name`` (the canonical key written)/``plugin``; ``install`` → ``hermes_cli.plugins_cmd.dashboard_install_plugin``'s ok payload; ``inspect`` → ``ok``/``agent``/``desktop``/``agent_name``/``desktop_name``/``sha`` (the commit read)/``warnings``/``insecure`` or ``ok=false`` + ``error``; ``toggle``/``install``/``update`` that loaded a plugin also carry ``gateway_reloaded`` (the running gateway picked it up and re-wired its handlers) and ``activation`` — the honest split of what is live now vs deferred, so ``restart_required`` is True only when no gateway answered or the running gateway/backend cannot import the plugin's Python dependencies (``activation.missing_dependencies``); ``update`` → ``ok``/``unchanged``/``sha``, or ``ok=false`` + ``consent_required`` with the ``delta`` (``{surface: [added...]}``) / ``delta_lines`` a widened pin adds — nothing changed until the client retries with ``accept_capabilities`` — or ``ok=false`` + ``scan_blocked`` with ``scan_verdict`` / ``scan_findings`` (retry a caution verdict with ``force``); ``remove`` → ``ok``/``name`` plus ``cleared_memory_provider`` when the removed plugin was the live ``memory.provider``. */ export interface PluginsManageResult { plugins?: AgentPluginRow[] | null @@ -4523,6 +4523,11 @@ export interface PluginsManageResult { path?: string | null paired_with_agent?: boolean | null unpaired_from?: string | null + results?: PluginUpdateAllRow[] | null + skipped?: PluginUpdateAllSkip[] | null + current?: number | null + auto_apply?: boolean | null + check_hours?: number | null } /** ``methods_tools._plugin_rows`` + ``plugins_cmd_catalog.catalog_row_fields`` provenance. */ export interface AgentPluginRow { @@ -4621,6 +4626,17 @@ export interface CatalogPluginPresence { sentence: string disclosure: string } +/** One plugin ``update_all`` tried. ``needs_review``: nothing changed because the update widens the plugin or its scan returned caution; the plugin's own ``update`` walks that review. */ +export interface PluginUpdateAllRow { + name: string + status: PluginUpdateAllStatus + detail?: string +} +export type PluginUpdateAllStatus = 'updated' | 'current' | 'failed' | 'needs_review' +export interface PluginUpdateAllSkip { + name: string + reason: string +} export interface PluginCatalogLookupParams { profile?: string | null name: string diff --git a/apps/shared/src/gateway-contract.openrpc.json b/apps/shared/src/gateway-contract.openrpc.json index 7641500a78..6c7547555a 100644 --- a/apps/shared/src/gateway-contract.openrpc.json +++ b/apps/shared/src/gateway-contract.openrpc.json @@ -28292,6 +28292,59 @@ "title": "PluginToolsetRow", "type": "object" }, + "PluginUpdateAllRow": { + "additionalProperties": false, + "description": "One plugin ``update_all`` tried. ``needs_review``: nothing changed because the update widens the\nplugin or its scan returned caution; the plugin's own ``update`` walks that review.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/PluginUpdateAllStatus" + }, + "detail": { + "default": "", + "title": "Detail", + "type": "string" + } + }, + "required": [ + "name", + "status" + ], + "title": "PluginUpdateAllRow", + "type": "object" + }, + "PluginUpdateAllSkip": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "reason": { + "title": "Reason", + "type": "string" + } + }, + "required": [ + "name", + "reason" + ], + "title": "PluginUpdateAllSkip", + "type": "object" + }, + "PluginUpdateAllStatus": { + "enum": [ + "updated", + "current", + "failed", + "needs_review" + ], + "title": "PluginUpdateAllStatus", + "type": "string" + }, "PluginsAction": { "enum": [ "list", @@ -28304,7 +28357,9 @@ "remove", "settings", "onboarding", - "presence" + "presence", + "update_all", + "auto_update" ], "title": "PluginsAction", "type": "string" @@ -28364,7 +28419,7 @@ }, "PluginsManageParams": { "additionalProperties": false, - "description": "``toggle``: ``key``/``name`` + ``enable``; ``install``: ``identifier``/``repo`` or ``catalog_name``\n(+ ``force``, ``enable``, ``ref``, ``inspected_sha`` = the commit ``inspect`` read: a custom source with no\n``ref`` whose branch moved since is refused); ``inspect``: ``identifier``/``repo`` or ``catalog_name``;\n``install_desktop``: ``identifier``/``repo`` (any source ``install`` takes) or ``catalog_name``, always\nwith ``ref`` (the commit ``inspect`` read and the dialog showed; another commit is refused) (+ ``force``) \u2014 the desktop half onto\nthe host that serves this client; ``remove_desktop``: ``name``\n(+ ``force`` for a package's own half); ``update``: ``name`` (+ ``accept_capabilities`` to apply a re-pin\nthat widened the plugin after the user confirmed the ``delta``, + ``force`` to apply an update whose\nsecurity scan returned caution after the user saw the findings); ``remove``: ``name`` (user installs only);\n``settings``: ``key`` + ``values`` (``{setting_key: value}``, non-secret schema keys only);\n``presence``: ``names`` (catalog names, answered in that order).", + "description": "``toggle``: ``key``/``name`` + ``enable``; ``install``: ``identifier``/``repo`` or ``catalog_name``\n(+ ``force``, ``enable``, ``ref``, ``inspected_sha`` = the commit ``inspect`` read: a custom source with no\n``ref`` whose branch moved since is refused); ``inspect``: ``identifier``/``repo`` or ``catalog_name``;\n``install_desktop``: ``identifier``/``repo`` (any source ``install`` takes) or ``catalog_name``, always\nwith ``ref`` (the commit ``inspect`` read and the dialog showed; another commit is refused) (+ ``force``) \u2014 the desktop half onto\nthe host that serves this client; ``remove_desktop``: ``name``\n(+ ``force`` for a package's own half); ``update``: ``name`` (+ ``accept_capabilities`` to apply a re-pin\nthat widened the plugin after the user confirmed the ``delta``, + ``force`` to apply an update whose\nsecurity scan returned caution after the user saw the findings); ``remove``: ``name`` (user installs only);\n``settings``: ``key`` + ``values`` (``{setting_key: value}``, non-secret schema keys only);\n``presence``: ``names`` (catalog names, answered in that order); ``update_all``: no fields;\n``auto_update``: ``enable`` to set ``plugins.auto_apply`` (omit to read it).", "properties": { "profile": { "anyOf": [ @@ -29015,6 +29070,72 @@ ], "default": null, "title": "Unpaired From" + }, + "results": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/PluginUpdateAllRow" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Results" + }, + "skipped": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/PluginUpdateAllSkip" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skipped" + }, + "current": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Current" + }, + "auto_apply": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Auto Apply" + }, + "check_hours": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Check Hours" } }, "title": "PluginsManageResult", diff --git a/hermes_cli/cli_loops_mixin.py b/hermes_cli/cli_loops_mixin.py index 3f4cf2b22d..3b5c5cbe78 100644 --- a/hermes_cli/cli_loops_mixin.py +++ b/hermes_cli/cli_loops_mixin.py @@ -280,6 +280,13 @@ class CLILoopsMixin: def _cmd_plugins(self, cmd_original: str): from hermes_constants import display_hermes_home + parts = cmd_original.split()[1:] + if parts and parts[0] in ("update", "auto-update"): + from hermes_cli.plugins_auto_update import plugins_slash_action + with self._busy_command(self._slow_command_status(cmd_original)): + for line in plugins_slash_action(parts): + print(line) + return try: # Discover from disk (bundled + user) like `hermes plugins list`, so # installed-but-not-enabled plugins show up; the plugin manager only knows diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 57c6120cae..34f2c78fd8 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -306,7 +306,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ cli_only=True, args_hint="[connect|disconnect|status]", subcommands=("connect", "disconnect", "status")), CommandDef("plugins", "List installed plugins and their status", - "Tools & Skills", cli_only=True, desktop="terminal"), + "Tools & Skills", cli_only=True, desktop="terminal", args_hint="[update|auto-update [on|off]]", + subcommands=("update", "auto-update")), # Info CommandDef("commands", "Browse all commands and skills (paginated)", "Info", diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index ac15aa65fb..50117f6fb1 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1705,11 +1705,11 @@ DEFAULT_CONFIG = { # abandoned. 0 = no deadline (load inline). Max 600. "load_timeout_seconds": 10, # Read-only plugin update-check cadence, hours (gateway tick; 0 disables). Applying stays - # explicit: `hermes plugins update `, or auto_apply below (git-class plugins only, - # scan-gated by that same pipeline). + # explicit: `hermes plugins update `, or auto_apply below (catalog and tracked git + # plugins, scan-gated by that same pipeline). "auto_update_check_hours": 24, - # Opt-in unattended apply for the cadence check. Git-row plugins ONLY; every apply runs the - # same security scan / consent pipeline as the manual update command. + # Opt-in unattended apply (`hermes plugins auto-update on`, Desktop's Plugins tab): catalog + + # tracked git plugins, same scan/consent pipeline as the manual update (never a widened pin). "auto_apply": False, # Where third-party Python plugins run. in_process: imported into Hermes (default). # host: one plugin-host process per profile runs them and they reach Hermes only through diff --git a/hermes_cli/plugins_auto_update.py b/hermes_cli/plugins_auto_update.py new file mode 100644 index 0000000000..2c86954e1f --- /dev/null +++ b/hermes_cli/plugins_auto_update.py @@ -0,0 +1,191 @@ +"""Unattended plugin updates shared by every surface: the opt-in ``plugins.auto_apply`` switch, the +one-click "update all" (Desktop, dashboard, ``/plugins update``), the one-updater-per-plugin lock, +and the notice ``hermes update`` prints when it moved catalog pins. + +Sibling of :mod:`hermes_cli.plugins_cmd` (the facade is imported late, never at module level). +""" + +from __future__ import annotations + +import logging +import threading +from contextlib import ExitStack, contextmanager +from pathlib import Path +from typing import Any, Iterator + +logger = logging.getLogger(__name__) + +# A second updater of the same plugin is refused rather than queued: the first one's result already +# answers the second's question, and a queued pull would run against a tree the first just replaced. +_LOCK_WAIT_SECONDS = 1.0 +_lock_holders: dict[str, threading.local] = {} +_lock_holders_guard = threading.Lock() + + +def _pc(): + from hermes_cli import plugins_cmd + return plugins_cmd + + +def _holder(key: str) -> threading.local: + with _lock_holders_guard: + return _lock_holders.setdefault(key, threading.local()) + + +@contextmanager +def plugin_update_lock(target: Path) -> Iterator[None]: + """Hold the one-updater lock for the plugin at *target*, across threads and processes (the + gateway's auto-apply tick, a terminal ``hermes plugins update``, Desktop and the dashboard). + A busy lock raises ``PluginOperationError`` after a short wait, so every caller reports it like + any other refusal (nothing changed); reentrant per thread.""" + from hermes_cli.auth import _file_lock + from hermes_constants import get_hermes_home + + lock = get_hermes_home() / "cache" / "plugin-update-locks" / f"{target.name}.lock" + stack = ExitStack() + try: + stack.enter_context(_file_lock(lock, _holder(str(lock)), _LOCK_WAIT_SECONDS, "plugin update busy")) + except TimeoutError as exc: + raise _pc().PluginOperationError( + f"Plugin '{target.name}' is already being updated by another Hermes process; " + "nothing was changed. Try again when that update finishes.") from exc + with stack: + yield + + +# ── the auto-update switch ──────────────────────────────────────────────────────────────────── + +def auto_update_state() -> dict[str, Any]: + """``{"auto_apply", "check_hours"}`` for the active profile.""" + from hermes_cli.plugins_cadence import auto_apply_enabled, check_interval_hours + return {"auto_apply": auto_apply_enabled(), "check_hours": check_interval_hours()} + + +def set_auto_update(enabled: bool) -> dict[str, Any]: + """Write ``plugins.auto_apply`` for the active profile and return the new state. Turning it on also + clears the check marker, so the gateway's next housekeeping tick checks and applies instead of + waiting out the rest of the interval.""" + from hermes_cli.config import get_config_path, is_managed + from hermes_cli.config_backend import write_config_key + from hermes_constants import get_hermes_home + + if is_managed(): + raise _pc().PluginOperationError( + "This Hermes install's configuration is managed by a package manager; set plugins.auto_apply there.") + write_config_key(get_config_path(), "plugins.auto_apply", bool(enabled)) + if enabled: + _clear_check_marker(get_hermes_home()) + return auto_update_state() + + +def _clear_check_marker(home: Path) -> None: + from hermes_cli.plugins_cadence import _MARKERS_DIR + (home / _MARKERS_DIR / "last-run").unlink(missing_ok=True) + + +# ── update all, unattended ──────────────────────────────────────────────────────────────────── + +def _apply_one(name: str) -> dict[str, str]: + """One plan row through the dashboard's non-interactive update. A widened catalog pin or a + caution scan is NOT accepted on the user's behalf: the row comes back ``needs_review``.""" + from hermes_cli.plugins_cmd_update import dashboard_update_user_plugin + try: + res = dashboard_update_user_plugin(name) + except Exception as exc: # per-plugin batch boundary: one crash must not stop the rest + logger.warning("update of plugin %s failed", name, exc_info=True) + return {"name": name, "status": "failed", "detail": f"{type(exc).__name__}: {exc}"} + if res.get("consent_required") or res.get("scan_blocked"): + return {"name": name, "status": "needs_review", "detail": str(res.get("error") or "")} + if not res.get("ok"): + return {"name": name, "status": "failed", "detail": str(res.get("error") or "")} + return {"name": str(res.get("name") or name), "status": "current" if res.get("unchanged") else "updated", + "detail": ""} + + +def update_all_unattended() -> dict[str, Any]: + """Check every installed plugin, then update each one ``hermes plugins update`` would move (same + plan: catalog re-pins and tracked Git pulls; pinned, held and pip rows are skipped with why). + + Returns ``{"results": [{name, status, detail}], "skipped": [{name, reason}], "current": n}``; + status is ``updated`` / ``current`` / ``failed`` / ``needs_review`` (nothing changed; the + plugin's own Update action walks the consent or scan review).""" + from hermes_cli.plugins_cadence import record_check_results, snapshot_installs + from hermes_cli.plugins_cmd_update_all import plan_update_all + from hermes_cli.plugins_updates import run_checks + + plugins_dir = _pc()._plugins_dir() + checked = snapshot_installs(plugins_dir) + results = run_checks(plugins_dir) + record_check_results(results, checked) + plan = plan_update_all(results) + return {"results": [_apply_one(row.name) for row in plan.movable], + "skipped": [{"name": row.name, "reason": reason} for row, reason in plan.skipped], + "current": plan.current} + + +_STATUS_MARKS = {"updated": "✓ updated", "current": "= already current", "failed": "✗ failed", + "needs_review": "! needs review"} + + +def summary_lines(outcome: dict[str, Any]) -> list[str]: + """Plain-text lines for an :func:`update_all_unattended` result (``/plugins update``).""" + lines = [f" {_STATUS_MARKS[r['status']]} {r['name']}" + (f" ({r['detail']})" if r["detail"] else "") + for r in outcome["results"]] + lines += [f" - skipped {s['name']}: {s['reason']}" for s in outcome["skipped"]] + if not outcome["results"]: + lines.append(" No plugin updates to apply.") + if any(r["status"] == "needs_review" for r in outcome["results"]): + lines.append(" Review those with `hermes plugins update ` in a terminal.") + if any(r["status"] == "updated" for r in outcome["results"]): + lines.append(" Updated plugin code loads in new sessions.") + return lines + + +def plugins_slash_action(parts: list[str]) -> list[str]: + """``/plugins update`` (update every plugin with an update, unattended) and ``/plugins auto-update + [on|off]`` for the classic CLI and the TUI (which runs the same handler in its slash worker).""" + if parts[0] == "update": + return ["Plugin updates:", *summary_lines(update_all_unattended())] + arg = parts[1].lower() if len(parts) > 1 else "" + if arg not in ("", "on", "off"): + return ["Usage: /plugins auto-update [on|off]"] + try: + state = auto_update_state() if not arg else set_auto_update(arg == "on") + except _pc().PluginOperationError as exc: + return [f" {exc}"] + word = "on" if state["auto_apply"] else "off" + return [f"Plugin auto-update: {word} (gateway check every {state['check_hours']:g}h; " + "updates load in new sessions)"] + + +# ── after `hermes update` ───────────────────────────────────────────────────────────────────── + +def catalog_pins_behind(home: Path) -> list[str]: + """Catalog installs under *home* whose installed pin is not the in-tree catalog's pin. Offline: + reads the install records and the catalog shipped with this checkout.""" + from hermes_cli.plugin_catalog import get_catalog_entry + from hermes_cli.plugins_cmd_catalog import at_catalog_pin + from hermes_cli.plugins_provenance import read_sidecar_rows + + behind = [] + for name, row in read_sidecar_rows(home / "plugins").items(): + block = row.get("catalog") if isinstance(row, dict) else None + if not isinstance(block, dict) or not block.get("name"): + continue + entry = get_catalog_entry(str(block["name"])) + if entry is not None and not at_catalog_pin(block, entry.sha): + behind.append(str(name)) + return sorted(behind) + + +def report_catalog_updates_after_hermes_update(home: Path, sibling_homes: list[tuple[str, Path]]) -> None: + """An update can move catalog pins; installed plugins stay on their old pin until updated. Name + them per profile, and clear that profile's check marker so its gateway re-checks on the next tick + (which applies them when that profile opted into ``plugins.auto_apply``).""" + for label, profile_home in [("this profile", home), *sibling_homes]: + behind = catalog_pins_behind(profile_home) + if not behind: + continue + _clear_check_marker(profile_home) + print(f" → {len(behind)} plugin update(s) from the new catalog in {label}: {', '.join(behind)}") + print(" apply: hermes plugins update (or turn on: hermes plugins auto-update on)") diff --git a/hermes_cli/plugins_cadence.py b/hermes_cli/plugins_cadence.py index 260d7806d7..68bb01c5cb 100644 --- a/hermes_cli/plugins_cadence.py +++ b/hermes_cli/plugins_cadence.py @@ -5,7 +5,7 @@ Runs at gateway start + the periodic tick when due, writes a plugin-check receipt (pm.receipt, kind 'plugin-check'), and logs ONE actionable line when updates exist. Applying updates stays explicit — ``plugins.auto_apply: true`` (default false) opts into -unattended apply for git-row plugins ONLY, scan-gated like cmd_update. +unattended apply for catalog and tracked git plugins, scan-gated like cmd_update. Pure, injectable clock/check/update seams for hermetic tests. """ @@ -243,16 +243,18 @@ def _run_check_locked( names, ) - # Opt-in unattended apply: git rows ONLY, scan-gated by the update - # path itself. Pinned/manual/drift/pip are never auto-applied - # (their update_available is False/None or their class excludes it). + # Opt-in unattended apply: the classes `hermes plugins update` can move (catalog re-pins to the + # reviewed sha, tracked git pulls), scan-gated by the update path itself, which also refuses a + # widened catalog pin or a too-new requires_hermes without a terminal. Pinned/manual/drift/pip + # are never auto-applied (their update_available is False/None or their class excludes it). if apply_updates_fn and updates and auto_apply_enabled(config_get): for r in updates: - if getattr(r, "klass", "") == "git": - try: - apply_updates_fn(r.name) - except (Exception, SystemExit): - log.warning("auto-apply of %s failed", r.name, exc_info=True) + if getattr(r, "klass", "") not in ("git", "catalog"): + continue + try: + apply_updates_fn(r.name) + except (Exception, SystemExit): + log.warning("auto-apply of %s failed", r.name, exc_info=True) # stamp the marker AFTER a completed run (even a failed one — a # failing check retrying every tick would hammer the network) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 8bfd8af47c..86dc544d5a 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -1093,6 +1093,20 @@ def _action_update(args): cmd_update_all(yes=getattr(args, "yes", False)) +def _action_auto_update(args): + from hermes_cli.plugins_auto_update import auto_update_state, set_auto_update + console = _console() + state = getattr(args, "state", None) + try: + current = auto_update_state() if state is None else set_auto_update(state == "on") + except PluginOperationError as exc: + _fail(console, f"[red]Error:[/red] {exc}") + word = "[green]on[/green]" if current["auto_apply"] else "[dim]off[/dim]" + cadence = (f"checked every {current['check_hours']:g}h by the gateway" if current["check_hours"] > 0 + else "the gateway's check is off: plugins.auto_update_check_hours is 0") + console.print(f"Plugin auto-update: {word} ({cadence}; updates load in new sessions)") + + def _action_pack(args): from hermes_cli.plugin_packs import pack_command pack_command(args) @@ -1114,6 +1128,8 @@ _PLUGIN_ACTIONS = { "validate": lambda args: _catalog().cmd_validate( args.path, as_json=getattr(args, "json", False), install_deps=getattr(args, "install_deps", False)), "update": _action_update, + "auto-update": _action_auto_update, + "autoupdate": _action_auto_update, "adopt": lambda args: cmd_adopt(args.name), "trust-update-url": lambda args: cmd_trust_update_url(args.name), "check-updates": lambda args: cmd_check_updates(args), diff --git a/hermes_cli/plugins_cmd_update.py b/hermes_cli/plugins_cmd_update.py index fac7a644c9..a1c4ce5f15 100644 --- a/hermes_cli/plugins_cmd_update.py +++ b/hermes_cli/plugins_cmd_update.py @@ -77,6 +77,14 @@ def _reclone_plugin_update(target: Path, source: str, previous_revision: object, def update_one(name: str, target: Path, console, *, interactive: bool = True, force: bool = False) -> bool: + """:func:`_update_one_locked` under the plugin's one-updater lock (a terminal update, the gateway's + auto-apply and a Desktop click never pull the same plugin at once; the loser changes nothing).""" + from hermes_cli.plugins_auto_update import plugin_update_lock + with plugin_update_lock(target): + return _update_one_locked(name, target, console, interactive=interactive, force=force) + + +def _update_one_locked(name: str, target: Path, console, *, interactive: bool = True, force: bool = False) -> bool: """Update one installed plugin, printing progress and consent prompts; True when it moved. Every update is scanned. A caution verdict on a git update asks ``Update anyway? [y/N]`` when @@ -315,12 +323,21 @@ def dashboard_update_user_plugin(name: str, *, accept_capabilities: bool = False (retry with *accept_capabilities*), and a git update the security scan blocks returns ``{"ok": False, "scan_blocked": True, "scan_verdict", "scan_findings"}`` (retry a caution verdict with *force* after the user saw the findings; a dangerous verdict never updates).""" - from hermes_cli import plugins_cmd_catalog as catalog - from hermes_cli.plugins_activation import activate_plugin_now - from hermes_cli.plugins_cmd_install import scan_blocked_result + from hermes_cli.plugins_auto_update import plugin_update_lock target = _pc()._user_installed_plugin_dir(name) if target is None: return {"ok": False, "error": f"Plugin '{name}' was not found under {_pc()._plugins_dir()}."} + try: + with plugin_update_lock(target): + return _dashboard_update_locked(name, target, accept_capabilities=accept_capabilities, force=force) + except _pc().PluginOperationError as exc: # the lock is busy: another updater owns this plugin + return {"ok": False, "error": str(exc)} + + +def _dashboard_update_locked(name: str, target: Path, *, accept_capabilities: bool, force: bool) -> dict[str, Any]: + from hermes_cli import plugins_cmd_catalog as catalog + from hermes_cli.plugins_activation import activate_plugin_now + from hermes_cli.plugins_cmd_install import scan_blocked_result sidecar = catalog.catalog_install_record(target) try: if sidecar: diff --git a/hermes_cli/subcommands/plugins.py b/hermes_cli/subcommands/plugins.py index 26a0eec646..a7691513d1 100644 --- a/hermes_cli/subcommands/plugins.py +++ b/hermes_cli/subcommands/plugins.py @@ -77,6 +77,16 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None: help="Apply the update even when the security scan returns a caution verdict (a dangerous verdict " "still blocks). Without it, a terminal asks first") + plugins_auto = plugins_subparsers.add_parser( + "auto-update", aliases=["autoupdate"], + help="Show or set unattended plugin updates (plugins.auto_apply)", + description=( + "With auto-update on, the gateway's update check (every plugins.auto_update_check_hours) " + "applies the catalog and tracked Git updates `hermes plugins update` would apply, through the " + "same security scan. Updated code loads in new sessions; a widened catalog pin or new " + "capabilities are never accepted unattended. Off by default.")) + plugins_auto.add_argument("state", nargs="?", choices=["on", "off"], help="Omit to show the current setting") + plugins_adopt = plugins_subparsers.add_parser( "adopt", help="Adopt a self-cloned plugin dir into provenance tracking", diff --git a/hermes_cli/update_cmd_maint.py b/hermes_cli/update_cmd_maint.py index bfbd2afb41..2f2535ea57 100644 --- a/hermes_cli/update_cmd_maint.py +++ b/hermes_cli/update_cmd_maint.py @@ -507,6 +507,14 @@ def _invalidate_live_plugin_catalog_caches() -> None: invalidate_live_cache_for_home(profile_home) +def _report_catalog_plugin_updates() -> None: + from hermes_cli.backup import _sibling_profile_homes + from hermes_cli.plugins_auto_update import report_catalog_updates_after_hermes_update + from hermes_cli.update_cmd import get_hermes_home + home = get_hermes_home() + report_catalog_updates_after_hermes_update(home, _sibling_profile_homes(home)) + + def _print_bundled_skills_sync_report() -> None: """Run ``sync_skills`` (copies new, updates changed, respects user deletions) and print its summary.""" from tools.skills_sync import sync_skills @@ -1043,6 +1051,10 @@ def _run_post_update_maintenance( # rest of the cache TTL (#119340). with _best_effort('Live plugin catalog cache invalidation failed: %s'): _invalidate_live_plugin_catalog_caches() + # New catalog pins are not applied by this update: name them per profile and re-arm each + # profile's plugin check so an opted-in (plugins.auto_apply) gateway applies them next tick. + with _best_effort('Plugin catalog update notice failed: %s'): + _report_catalog_plugin_updates() with _best_effort('Skills sync during update failed: %s'): print() diff --git a/scripts/ci/classify_changes.py b/scripts/ci/classify_changes.py index ee32ba1882..b5110cde57 100644 --- a/scripts/ci/classify_changes.py +++ b/scripts/ci/classify_changes.py @@ -375,6 +375,7 @@ _UPDATE_DEPENDENCIES = ( "hermes_cli/_parser.py", "hermes_cli/gateway_multiplex_mode.py", "hermes_cli/plugin_catalog.py", + "hermes_cli/plugins_auto_update.py", # update_cmd_maint's catalog-pin notice after the code swap "hermes_cli/steward.py", "hermes_cli/observability/shared_metrics_update.py", "hermes_cli/main_install_repair.py", diff --git a/tests/hermes_cli/test_plugins_auto_update.py b/tests/hermes_cli/test_plugins_auto_update.py new file mode 100644 index 0000000000..0fbb247f33 --- /dev/null +++ b/tests/hermes_cli/test_plugins_auto_update.py @@ -0,0 +1,113 @@ +"""Unattended plugin updates: catalog installs ride ``plugins.auto_apply``, one updater per plugin, the +Desktop/TUI RPC switch + update-all, and the catalog notice after ``hermes update``.""" + +from __future__ import annotations + +import json +import threading + +import pytest + + +@pytest.fixture +def home(tmp_path, monkeypatch): + home = tmp_path / "hermes-home" + (home / "plugins").mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(home)) + return home + + +class _Row: + def __init__(self, name, klass, update_available=True): + self.name, self.klass, self.update_available = name, klass, update_available + self.needs_fixing = self.reason = self.min_hermes = None + self.current, self.latest = "a" * 40, "b" * 40 + + def to_json(self): + return {"name": self.name, "class": self.klass, "update_available": self.update_available} + + +def test_auto_apply_moves_catalog_installs_too(home): + """A catalog install is re-pinned by auto-apply like a git install; pip rows still never move.""" + from hermes_cli import plugins_cadence as cad + + applied = [] + rows = [_Row("git-plug", "git"), _Row("catalog-plug", "catalog"), _Row("pip-plug", "pip")] + cad.run_scheduled_check(run_checks_fn=lambda _dir: rows, plugins_dir=home / "plugins", + apply_updates_fn=applied.append, + config_get=lambda _s, key: True if key == "auto_apply" else 24) + assert applied == ["git-plug", "catalog-plug"] + + +def test_second_updater_of_the_same_plugin_is_refused_without_touching_it(home): + """The gateway's auto-apply tick holds the plugin; a Desktop click on Update changes nothing.""" + from hermes_cli.plugins_auto_update import plugin_update_lock + from hermes_cli.plugins_cmd_update import dashboard_update_user_plugin + + target = home / "plugins" / "busy-plug" + target.mkdir() + (target / "plugin.yaml").write_text("name: busy-plug\nversion: 1.0.0\n", encoding="utf-8") + held, release = threading.Event(), threading.Event() + + def holder(): + with plugin_update_lock(target): + held.set() + release.wait(10) + + thread = threading.Thread(target=holder) + thread.start() + try: + assert held.wait(5) + result = dashboard_update_user_plugin("busy-plug") + finally: + release.set() + thread.join(5) + assert result["ok"] is False and "already being updated" in result["error"] + assert (target / "plugin.yaml").read_text(encoding="utf-8").startswith("name: busy-plug") + + +def test_rpc_switch_and_update_all_round_trip(home, monkeypatch): + """Desktop's switch writes the profile's plugins.auto_apply; Update all reports each plugin and + never accepts a widened pin on the user's behalf.""" + from hermes_cli import plugins_cmd_update, plugins_updates + from tui_gateway import server + + def rpc(**params): + return server.handle_request({"id": "1", "method": "plugins.manage", "params": params})["result"] + + assert rpc(action="auto_update")["auto_apply"] is False + assert rpc(action="auto_update", enable=True)["auto_apply"] is True + assert "auto_apply: true" in (home / "config.yaml").read_text(encoding="utf-8") + + rows = [_Row("fresh", "git"), _Row("widened", "catalog"), _Row("pinned", "git", update_available=False)] + rows[2].reason = "pinned to an exact revision" + monkeypatch.setattr(plugins_updates, "run_checks", lambda _dir: rows) + replies = {"fresh": {"ok": True, "name": "fresh", "unchanged": False}, + "widened": {"ok": False, "consent_required": True, "error": "adds tools"}} + calls = [] + monkeypatch.setattr(plugins_cmd_update, "dashboard_update_user_plugin", + lambda name, **kw: calls.append((name, kw)) or replies[name]) + out = rpc(action="update_all") + assert [(r["name"], r["status"]) for r in out["results"]] == [("fresh", "updated"), ("widened", "needs_review")] + assert out["skipped"] == [{"name": "pinned", "reason": "pinned to an exact revision"}] + assert calls == [("fresh", {}), ("widened", {})] # no accept_capabilities / force + + +def test_hermes_update_names_catalog_pins_it_moved_and_rearms_the_check(home, monkeypatch, capsys): + from hermes_cli import plugin_catalog + from hermes_cli.plugins_auto_update import report_catalog_updates_after_hermes_update + + new_pin = "c" * 40 + entry = plugin_catalog.PluginCatalogEntry(name="cat-plug", repo="https://example.invalid/r", sha=new_pin, + description="d", maintainer="m") + monkeypatch.setattr(plugin_catalog, "load_catalog", lambda catalog_dir=None: [entry]) + (home / "plugins" / ".install-metadata.json").write_text(json.dumps({ + "cat-plug": {"catalog": {"name": "cat-plug", "sha": "a" * 40, "pin": "a" * 40}}, + "current": {"catalog": {"name": "cat-plug", "sha": new_pin, "pin": new_pin}}}), encoding="utf-8") + marker = home / "plugin-update-checks" / "last-run" + marker.parent.mkdir() + marker.write_text("1", encoding="utf-8") + + report_catalog_updates_after_hermes_update(home, []) + assert "1 plugin update(s) from the new catalog in this profile: cat-plug" in capsys.readouterr().out + assert not marker.exists() diff --git a/tui_gateway/contracts/tools_mcp_plugins.py b/tui_gateway/contracts/tools_mcp_plugins.py index f696988328..167ab7d191 100644 --- a/tui_gateway/contracts/tools_mcp_plugins.py +++ b/tui_gateway/contracts/tools_mcp_plugins.py @@ -626,6 +626,8 @@ class PluginsAction(WireEnum): settings = "settings" onboarding = "onboarding" presence = "presence" + update_all = "update_all" + auto_update = "auto_update" class PluginsManageParams(ProfileParams): @@ -639,7 +641,8 @@ class PluginsManageParams(ProfileParams): that widened the plugin after the user confirmed the ``delta``, + ``force`` to apply an update whose security scan returned caution after the user saw the findings); ``remove``: ``name`` (user installs only); ``settings``: ``key`` + ``values`` (``{setting_key: value}``, non-secret schema keys only); - ``presence``: ``names`` (catalog names, answered in that order).""" + ``presence``: ``names`` (catalog names, answered in that order); ``update_all``: no fields; + ``auto_update``: ``enable`` to set ``plugins.auto_apply`` (omit to read it).""" action: PluginsAction = PluginsAction.list key: str | None = None @@ -813,6 +816,27 @@ class PluginScanFinding(Result): description: str +class PluginUpdateAllStatus(WireEnum): + updated = "updated" + current = "current" + failed = "failed" + needs_review = "needs_review" + + +class PluginUpdateAllRow(Result): + """One plugin ``update_all`` tried. ``needs_review``: nothing changed because the update widens the + plugin or its scan returned caution; the plugin's own ``update`` walks that review.""" + + name: str + status: PluginUpdateAllStatus + detail: str = "" + + +class PluginUpdateAllSkip(Result): + name: str + reason: str + + class PluginsManageResult(Result): """``list`` → ``plugins`` + counts; ``toggle`` → ``ok``/``unchanged``/``restart_required``/``name`` (the canonical key written)/``plugin``; ``install`` → ``hermes_cli.plugins_cmd.dashboard_install_plugin``'s @@ -876,6 +900,14 @@ class PluginsManageResult(Result): path: str | None = None paired_with_agent: bool | None = None unpaired_from: str | None = None + # ``update_all`` → one row per plugin it tried, the ones it could not move (with why) and how many + # were already current. + results: list[PluginUpdateAllRow] | None = None + skipped: list[PluginUpdateAllSkip] | None = None + current: int | None = None + # ``auto_update`` → the profile's ``plugins.auto_apply`` and ``plugins.auto_update_check_hours``. + auto_apply: bool | None = None + check_hours: float | None = None method("plugins.manage", params=PluginsManageParams, result=PluginsManageResult, diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index fe5254de80..17507fea6b 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -1891,6 +1891,24 @@ def _portable_server_names(plugin_dir: Path, key: str) -> set[str]: return {name_for(key, server) for server in package.mcp_servers} +def _plugins_update_all(rid, params): + """Check every installed plugin and update each one ``hermes plugins update`` would move. Nothing is + accepted on the user's behalf: a widened catalog pin or a caution scan comes back ``needs_review``.""" + _ensure_plugin_activation_listener() + return _ok(rid, _tools_mod("hermes_cli.plugins_auto_update").update_all_unattended()) + + +def _plugins_auto_update(rid, params): + """Read ``plugins.auto_apply`` for the scoped profile, or set it when ``enable`` is given.""" + auto = _tools_mod("hermes_cli.plugins_auto_update") + if params.get("enable") is None: + return _ok(rid, auto.auto_update_state()) + try: + return _ok(rid, auto.set_auto_update(bool(params.get("enable")))) + except _tools_mod("hermes_cli.plugins_cmd").PluginOperationError as e: + return _err(rid, 4021, str(e)) + + def _plugins_onboarding(rid, params): """Catalog plugins curated for the onboarding card that this OS runs, each with its app state.""" return _ok(rid, {"onboarding": _tools_mod("hermes_cli.plugin_catalog_presence").onboarding_entries()}) @@ -1904,7 +1922,8 @@ def _plugins_presence(rid, params): _PLUGINS_ACTIONS = {"list": _plugins_list, "onboarding": _plugins_onboarding, "presence": _plugins_presence, "toggle": _plugins_toggle, "install": _plugins_install, "inspect": _plugins_inspect, "install_desktop": _plugins_install_desktop, "remove_desktop": _plugins_remove_desktop, - "update": _plugins_update, "remove": _plugins_remove, "settings": _plugins_settings} + "update": _plugins_update, "remove": _plugins_remove, "settings": _plugins_settings, + "update_all": _plugins_update_all, "auto_update": _plugins_auto_update} @_scoped_rpc("plugins.manage", 5026, catch_resolve=False) @@ -1913,7 +1932,8 @@ def _(rid, params: dict) -> dict: ``list`` → {plugins, user_count, bundled_count}; ``toggle`` flips ``key``/``name`` per ``enable``; ``install`` git-clones ``identifier``/``repo`` or a curated ``catalog_name`` (``force``, ``enable`` default True); ``inspect`` reports which halves that source ships, installing nothing; ``update`` re-pins a catalog install or pulls a git install (``force`` accepts a caution scan); ``remove`` deletes - a user install by ``name``; ``settings`` writes manifest-declared ``values`` for ``key``.""" + a user install by ``name``; ``settings`` writes manifest-declared ``values`` for ``key``; ``update_all`` + updates every plugin with an update; ``auto_update`` reads or (with ``enable``) sets ``plugins.auto_apply``.""" return _run_action(rid, params, _PLUGINS_ACTIONS, "plugins")