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.
This commit is contained in:
Teknium committed 2026-11-04 21:58:12 -08:00
1 parent c58e6de692
commit 5819226468
15 files changed
+583 -24

No files matched your search

+18 -2
View File
@@ -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<string, unknown> | 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
+123 -2
View File
@@ -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",
+7
View File
@@ -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
+2 -1
View File
@@ -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",
+4 -4
View File
@@ -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 <name>`, or auto_apply below (git-class plugins only,
# scan-gated by that same pipeline).
# explicit: `hermes plugins update <name>`, 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
+191
View File
@@ -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 <name>` 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)")
+11 -9
View File
@@ -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)
+16
View File
@@ -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),
+20 -3
View File
@@ -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:
+10
View File
@@ -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",
+12
View File
@@ -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()
+1
View File
@@ -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",
@@ -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()
+33 -1
View File
@@ -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,
+22 -2
View File
@@ -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")