The Desktop/TUI/dashboard toggle edited the saved list before the plugin
loaded, so a backend that scanned while the plugin was off found no toolset
key and left the toolset out: the plugin showed enabled with its tools off.
Enable now edits the list after activation; disable resolves the key before
the disable is written. hermes plugins enable/disable make the same edit.
Only saved lists are touched; a fresh config still gets no list.
- hermes_cli: _find_plugin_entry is the single resolver (key / manifest name / unique leaf /
catalog name via the installer-owned record). enable/disable/show/capabilities/update/remove,
the dashboard and plugins.manage settings all go through it, so the catalog name you
installed with keeps working (17 catalog entries install under another manifest name).
_catalog_install_on_disk reuses the same catalog lookup.
- Desktop hub: join agent rows to desktop halves by package folder (what Electron stamps),
not manifest name, so a self-cloned package is one row instead of two.
- Desktop: turning a unified package on (install dialog 'Enable after install' or the Agent
switch) also turns its opt-in desktop half on, unless the user switched it off. Off stays
per half. Capability consent is untouched.
- docs: plugin-catalog page.
With no saved platform_toolsets, _toggle_plugin_toolset seeded
platform_toolsets.cli: [<plugin_ts>]. A list naming only plugin keys
replaces the hermes-cli composite, so the model lost every built-in tool
(26 -> 1, no terminal). The seed is dropped: an unknown plugin toolset is
already on by default when no list is saved.
plugins remove now clears the plugin's toolset from saved lists even when
plugin.yaml has no provides_tools (the toggle already no-ops without a
toolset); before, hermes config check warned about an unknown toolset.
Docs: say what provides_tools actually drives.
This reverts commit 0f0b0a12aa.
#134189 refused plugin update, remove/uninstall and force-reinstall on every
platform whenever the profile gateway was live, so uninstalling a plugin
(notably on Windows, from Desktop) required shutting the gateway down. The
#70473 hazard it guarded (deferred imports in a loaded plugin hitting a
swapped checkout) does not justify blocking uninstall; it will be re-landed
scoped to the paths that need it.
Brings the onboarding branch up to main 0c2ff854a5. Where a Wave A PR
already landed a reviewed version of a piece of this branch (free tier
#134049, keyless web #134048, app resolver #134050, bundle and uninstall
#134051, catalog card and toolsets #134052, built-in tour #134054, stdin
guard #134055, retired key #126146), main's version wins. The PM long-path
files also take main's version: that work lands through #130430.
Kept from the branch on top of main:
- the setup and start_chat toolsets, declared with main's platforms key
(desktop only), and the setup_choose server request contract;
- the setup-profile marker file (.setup-profile.json) instead of the
profile.yaml role, as c3f85bc54f decided;
- the first-contact note's message argument beside main's internal flag;
- the setup-profile gate on the shared-metrics consent dialog;
- window sizing (window-growth) in place of chat-onboarding-window.
Desktop i18n follows main's per-section sibling files. The onboarding
strings go into them (modelMenu.localSetup, handoffTour.localTitle and
localText) and setupChoose/startChat stay in en.ts. guidedGreeting is
gone. The shared-metrics copy uses the branch's rewrite in English, with
the pre-opt-in sentence corrected for the fresh-install note
(d3674513c8); locales keep main's translations, with installId and
consentWindow folded into the new sending key.
The built-in tour reads local-model eligibility from the local-setup
offer store, the one place that rule lives on this branch. The gateway
contract and the onboarding skill docs pages are regenerated.
Git plugin update/remove/force-reinstall ran git pull or tree swaps
directly in the installed checkout while the messaging gateway could
still be running with that plugin's callbacks loaded. A callback that
defers a relative import until first invocation then fails with
ModuleNotFoundError even though the command reported success (#70473).
One shared guard (hermes_cli.plugins_cmd._refuse_live_gateway_mutation)
now fails every mutating path BEFORE any git operation when the active
profile's gateway is live:
- plugins update (CLI) and dashboard_update_user_plugin
- plugins remove / rm / uninstall, dashboard_remove_user_plugin and the
plugins.manage RPC (shared _remove_user_plugin tail)
- plugins install --force (a forced reinstall replaces a loaded
checkout; a brand-new install is not gated)
The liveness read is the canonical profile-scoped probe
(profiles._check_gateway_running -> resolve_gateway_liveness), never
mutating the profile's identity files; a probe failure fails open so an
uninstallable plugin cannot strand. The message gives the
stop/apply/start recovery sequence; --allow-live-gateway on
update/install/remove is the explicit CLI-idiom override.
Integration test (tests/hermes_cli/test_plugin_live_gateway_guard.py)
spawns a real child holding gateway.lock with a gateway-shaped command
line against a temp HERMES_HOME, so the guard is exercised through the
platform's actual liveness probe — not a mock. Stopped-gateway behaviour
is asserted unchanged.
An agent stores its session platform's gated toolsets in disabled_toolsets at load, so the tool_search listing, the bridge's search, MCP refreshes and delegate children drop manage_catalog too.
InstallPhase moves to the leaf hermes_cli/plugin_install_phase.py; the gateway contracts no longer import the installer.
The per-home enable lock moves from the plugins_cmd facade to plugins_cmd_install._enable_placed.
Reusing an installed catalog tree reads the install record through catalog_install_record, so older top-level catalog_name records match.
A failed row no longer redacts the Advanced choices (profile name, commit); only credentials are redacted.
The profiles.py role comment no longer says a role grants the setup toolset.
Toolsets that exist only for one kind of session now say so on their spec:
`platforms=frozenset({"desktop"})`. `TOOLSET_SESSION_PLATFORMS` is derived
from the specs that carry the key, `all` / `*` / no selection skip those
toolsets, and `toolsets.agent_tool_drops(agent)` removes their tools from any
agent whose own session platform is not listed. `agent_init._load_tools` and
`tools/mcp_tool_agent.py` (MCP refresh and prefix restore) call that one
helper. `tool_search._DIRECT_SURFACE_TOOLSETS` is derived from
`CLIENT_SURFACE_TOOLSETS` and `TOOLSET_SESSION_PLATFORMS`.
This replaces the profile-role gate: `profile_role_toolsets`, the role code in
`_with_session_toolsets` and the explicit-pin branch, the strip step in
`_select_tool_names`, and main's `setup` toolset (whose only tool moves out)
are deleted. `manage_catalog` moves into a new `catalog` toolset, which the
desktop client folds into every desktop chat like `project` and `desktop_ui`.
It is deferred behind tool_search by default (`config_defaults` defer list).
The shared-metrics schemas' toolset enum follows (`setup` out, `catalog` in).
The install card works outside the setup chat (#129650, with review fixes):
- Parallel installs: `_set_plugin_enabled` holds a per-home lock around the
read-then-admit section, so rows enabled at once no longer refuse each other.
- A catalog plugin already on disk and not enabled is enabled in place, so Try
again after a refused enable can finish.
- A failed catalog row counts as resolved (`contract.resolves`), so it no longer
holds the turn to the 300 s deadline. The snapshot carries `resolved`, and the
card reads it instead of a hand copy of the backend's rule.
- Each card draws its own operation by op id, because models reuse call ids.
- The row carries the approved non-secret choices (`approved`: force, enable,
ref) and the chosen profile, so Try again after the card settled repeats them.
- Install phases are ids (`InstallPhase`, wire `CatalogInstallPhase`) and the
installed-row notes are fields (`enabled`, `missing_env`, `server_errors`,
`already_installed`, `requires_hermes`); the card words them in English
strings (translations follow later). Failure reasons stay raw text, and
`detail` keeps the English notes for the model.
- Installs and search target the calling chat's own home, carried as a path;
the row's profile name is only a label, with no `'default'` fallback in
Python or TypeScript.
- `dashboard_install_plugin` is split into `_resolve_source`, `_place_tree` and
`_enable_placed`.
Desktop English strings for the card move to `en_catalog_install.ts` /
`types_catalog_install.ts`, which offsets the growth of `en.ts` and `types.ts`.
hermes.extension.install.count gains failure_class (closed per-kind set named at each failure exit
of the skills hub, plugin installer and MCP catalog emitters; raised exceptions classify by their
tagged class or TYPE, exception:<Type> collapses to exception, never the message) and registry
(the skills-hub adapter SOURCE_ID that resolved/served a skill; none/unresolved/other otherwise).
hermes.update.run gains failure_class derived from the FINAL receipt only (admission step,
stop_reason exception type, exit_code, stage marks, fleet states, gateway_restart). failed_stage no
longer reads 'other' for runs whose every mark passed: a 'partial' receipt (written only by the
post-restart verification) synthesizes a failed verify stage, and a failed run ending on a skipped
restart reads restart.
Package schema v4 is extended in place (canary-only): the pre-split field sets stay valid as the
second oneOf shape and in _LEGACY_METRIC_FIELDS so pending rows drain.
Third-party drivers (e.g. a Codex-style computer-use reimplementation) could
only replace cua-driver by patching core. computer_use now follows the
single-select provider model Hermes uses for memory providers, context
engines and cron schedulers:
- plugins/computer_use/__init__.py discovers bundled
plugins/computer_use/<name>/ then user $HERMES_HOME/plugins/<name>/
(marker: register_computer_use_provider / ComputerUseProvider) and loads
ONLY the provider computer_use.backend names (default "cua"), read per
call so each profile gets its own. Unselected providers are never
imported; listing reads plugin.yaml only. An unknown/unloadable name
returns an error naming it and the installed providers; no fallback.
- The built-in cua-driver is an ordinary bundled provider at
plugins/computer_use/cua/ (driver implementation stays in
tools/computer_use). tools/computer_use/backend.py gains
ComputerUseProvider (create_backend(permission_mode=...), is_available(),
optional doctor()).
- The tool's check_fn and backend construction go through the selected
provider; the model-facing schema is unchanged for every provider
(prompt-cache invariant). The test-only HERMES_COMPUTER_USE_BACKEND env
switch is gone (tests patch _new_backend).
- General plugin discovery treats computer-use providers as exclusive
(not eagerly imported, not listed as toggleable) and skips the bundled
computer_use/ category, same as memory/, context_engine/, cron_providers/.
- hermes tools and the Desktop toolset panel list installed providers next
to cua; computer-use doctor/status/permissions and the Desktop status
payload skip cua-driver/TCC probes when another provider is selected.
#133012 made status call every user `kind: model-provider` plugin enabled,
but providers/ discovery (_scan_home_layer) only loads
plugins/model-providers/<name>/ children and flat plugins/<name>/ children
whose kind is exactly `model-provider`. A provider in a category directory
(plugins/custom/foo/), or a flat one with a differently-cased kind, showed
ON in `hermes plugins list`, the TUI hub and Desktop while it never loaded.
Status now applies the loader's rule per location: flat children only when
the loader's own kind check (providers._declares_model_provider_kind)
accepts them; model-providers/ children when their kind is model-provider
(discovery imports any child there, but never registers a non-provider);
anywhere else, never.
Status was opening module:attr as a plugin directory. Windows rejects the
colon, so one pip plugin hid every other plugin on profiles that had not
already enabled it.
gate_manifest loads kind: model-provider plugins from any source without a
plugins.enabled entry, but _plugin_status only granted that default to
bundled plugins. A catalog-installed provider (claude-subscription-directsdk)
was live yet showed 'not enabled' in hermes plugins list and as a switched-off
row in the Desktop Plugins hub.
A non-interactive `hermes plugins install <name> --enable` had no path
forward (#122134): the y/N gate for Python deps refuses without a TTY,
the publication of an active replacement is declined the same way
("Reinstall declined: dependency install skipped (non-interactive)"),
and the suggested `hermes plugins enable` recovery cannot publish a
plugin that was never installed.
Add an explicit `--yes-deps` flag (mutually exclusive with `--no-deps`)
that carries the user's own answer through both consent gates: the
enable-time `_consent_python_deps` prompt and the publication-time
active-replacement veto in `publish_plugin`. The fail-closed default is
unchanged — an unattended install without the flag is still refused
rather than consented to on the user's behalf.
* fix(plugins): parallel catalog enables no longer refuse each other
The Desktop install card runs each approved row on its own thread. Each
thread read the config version, then committed much later under the PM
runtime lock, so every commit after the first saw a changed config and
was refused ("Plugin configuration changed since this selection was
read; retry."). A per-home lock now covers the read-then-admit section,
so enables in one process queue up. The PM version check stays: it
still refuses an edit made by another process.
* fix(connectors): the catalog card can enable a plugin already on disk
A refused enable leaves the published clone in place. "Try again", or
the model asking again, then re-ran a full clone that stopped at
"already exists", so the card could never turn the plugin on. A catalog
install (no force) of an entry whose tree is on disk with its catalog
record and is not enabled now skips the clone and runs the same enable
and live activation the normal install ends with.
A failed catalog row now counts as done. The operation settles when
every row is installed, skipped or failed, so the model gets the reason
at once instead of waiting out the 300 s deadline. A failed row keeps
its state and reason in the settled result. Try again still works while
another row keeps the card open.
* fix(desktop): each install card draws its own operation
Models reuse tool call ids (call_0 on every turn). Cards found their
operation by session and call id, so an older card redrew a newer
operation: it lost its Installed rows and showed a phantom
"Installing..." from a seq held over from the old operation.
The tool row now records the op id when connection.request arrives, and
the card draws that operation only. A row without an op id falls back to
the call id only when one known operation has it, and a running row
never takes a settled one. Settled install cards are kept, so each card
stays drawn on its own row. The held-answer seq is keyed by op id.
A failed catalog row counts as done, matching the backend. On a settled
card a failed plugin row keeps "Try again": it runs the host install,
which now enables a plugin already on disk.
* docs(connectors): manage_catalog says failed rows end the wait
The description said install blocks until every row is installed,
skipped, or the card closes. A failed row now ends the wait too, and
install turns on a catalog plugin that is on disk but not enabled, so
the model can ask again instead of editing files by hand.
* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule
The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:
- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
names, restored dead definitions) and the three re-export stub modules
(gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
`plugins.allow_deprecated_imports` escape hatch
An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.
hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).
In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.
* chore: retrigger CI (zero-job startup_failure phantom)
* test: drop resolution allowlist rows for the two deleted which() sites
hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
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.
Follow-up to the ported picker fix (#121623):
- Ticking a row re-enables it even when plugins.disabled holds the manifest
name (e.g. telegram-platform, written by pickers before #40190). The save
only dropped the key and its bare leaf, so the gate still matched the
manifest name and the plugin stayed off. It now purges every alias like
`hermes plugins enable/disable` (_apply_activation, shared with
_set_plugin_enabled), with one discovery scan per save.
- The success line counts the rows actually turned on/off instead of treating
every unticked row as "disabled"; the unused new_enabled return is gone.
- _entry_status shares the per-row status call between the picker
preselection and `hermes plugins list --enabled`.
No caller handled ResolutionConflict: enabling a plugin whose deps cannot
co-install with core surfaced uv's raw lock output (hashed workspace member
names) plus a "run hermes pm install" hint that cannot fix a conflict.
admit_plugin_set_change now turns ResolutionConflict into
DependencyConflict (an AdmissionRefused subclass, so every existing refusal
path still applies) whose message names the plugin being admitted, says it
conflicts with the pinned dependencies, and keeps the resolver's cause. The
CLI prints that message without the misleading retry hint; the dashboard
and TUI surfaces already relay str(exc). Selection and environment stay
unchanged as before.
plugins_cmd.py had grown to 2,858 lines, past the ~2,000-line gate. Move
each verb family into a plugins_cmd_<topic>.py sibling: git (install
metadata + git plumbing), install, update (plus adopt / trust-update-url /
check-updates), remove, capabilities, toggle (composite UI) and listing.
The facade keeps the shared primitives, enable/disable selection,
discovery and the dispatch table, and re-exports the names other modules,
tests and the old-updater surface import (978 lines now).
Siblings never import the facade at module level; they read facade names
through _pc() at call time, so monkeypatching plugins_cmd.<name> still
intercepts calls made from a sibling. No behaviour change. Tests that
imported three sibling-only helpers now import them from the defining
module, and two subprocess.run patches target subprocess directly.
A plugin that lives in a monorepo subdirectory (the Hindsight catalog entry:
vectorize-io/hindsight#hindsight-integrations/hermes) was cloned with every
file in the repository even at --depth 1: 170 MB for a 2 MB plugin folder.
On a slow link that exceeds any reasonable deadline, so `hermes update`'s
Hindsight migration failed with "Git clone timed out" every time.
Subdirectory installs now do a blobless clone (--filter=blob:none
--no-checkout) and a sparse checkout of just that folder, written as the
classic info/sparse-checkout file so older Git clients work too. Servers
without partial-clone support ignore the filter and fall back to the old
download.
Because a partial clone downloads file contents at checkout time, the
checkout step (pinned and unpinned) now gets the configured network
deadline and the credential fallback, same as clone and fetch. Every
timeout error names plugins.clone_timeout_seconds so users can find the
knob.
Existing test fakes of _clone_plugin_repo accept the new subdir argument.
utils.fast_safe_load already exists, is pinned by tests/test_fast_safe_load.py, and
its comment names exactly these payers: 'startup parses config.yaml and every plugin
manifest, so the slow path cost ~0.9 s of cold start'. The migration was started —
hermes_cli/config.py uses it eight times, hermes_cli/main.py and hermes_cli/plugins.py
too — but the file-level loaders it was written for were never converted.
The cost is config SIZE, and the size is the installer's doing: it seeds config.yaml
by copying cli-config.yaml.example, 120,897 bytes of mostly comments. Nothing caches
load_gateway_config() and it has 238 production call sites.
Profiled before assuming a cause — reader.forward 34 ms, scanner.scan_to_next_token
28 ms, reader.peek 13 ms: the pure-Python PyYAML scanner, nothing else.
Measured A/B on one realistic pass (gateway config + every bundled plugin
description), medians of five runs, __pycache__ cleared between arms:
48.8 ms -> 2.46 ms. Per path: load_gateway_config 48.3 -> 1.84 ms, managed
config.yaml 44.4 -> 1.03 ms, 105 plugin.yaml manifests 59.3 -> 5.9 ms.
Same parse, same restricted tag set, same result — only the loader changes. Drops the
three 'import yaml' statements the swap orphaned.
(cherry picked from commit a4efd6a4506043159310eba02c32b673c455b88b)
Each install read .install-metadata.json before its clone and wrote that
snapshot back after it, so the Desktop install card's second row erased the
first row's record (nvidia-app then showed source 'user'). Every writer now
re-reads the sidecar and changes only its own plugin's record under a
cross-process file lock; install rollback no longer rewrites a stale snapshot.
A plugin that finished loading after an adapter connected never got its platform
handlers (slash commands, button callbacks, inbound transforms) registered until a
gateway restart, silently. Three pieces, one seam shared by every surface:
1. Discovery listener: PluginManager.on_plugin_loaded(cb) fires from INSIDE
discover_and_load for the plugins a sweep newly loaded (diff of the loaded set),
with a per-plugin activation summary (hermes_cli/plugins_activation.py):
activated_now {gateway_commands, gateway_transforms, hooks, callbacks} vs
deferred {tools, prompt, mcp_servers}. Every mid-run load path now performs a real
discover_plugins(force=True): CLI install/enable (via the gateway), Desktop/TUI
plugins.manage install/toggle/update, dashboard REST install, tool-triggered
force re-discovery, the new `reload-plugins` control-socket verb. A non-forced
discover_plugins() short-circuits on _discovered, which is why reload.mcp after
a mid-run install used to reload the OLD server set.
2. Idempotent re-wire: BasePlatformAdapter.rewire_plugin_handlers() runs only
factories not yet wired on the live native client (keyed (plugin, qualname);
a force reload hands back new function objects). Telegram hoists late handlers
ahead of core's catch-all filters.COMMAND / CallbackQueryHandler (PTB dispatches
the first match per group) and re-wires on the transient-init rebuild; Slack
dedupes register_slack_action_handler per AsyncApp. The gateway runner
subscribes per served profile and re-wires on the loop.
3. Scope limit + honest messaging: handlers only. Tools/prompt stay deferred to
the next session (prompt-cache invariant), MCP servers to mcp.reload; the CLI
hint and plugins.manage results (activation, gateway_reloaded,
restart_required only when no gateway answered) say exactly that.
Annotated-tag pins (F8): a catalog `sha` recorded as `git rev-parse <tag>` names
the TAG object, while HEAD can only ever be the commit it points at. The scan
trust check compared HEAD against the unpeeled sha (so every tag-pinned entry
lost the reviewed-pin bypass and prompted on caution findings) and the sidecar
recorded the peeled commit, so `update_available` was true forever and every
`update` re-installed. The installer now peels the pin (`<sha>^{commit}`) for
trust, records `pin` on the catalog block only when the checkout satisfies it
(empty for an off-pin `--ref` install), and every at-pin check goes through
`at_catalog_pin(sidecar, entry_sha)` (repin, dashboard payload, TUI rows).
Re-pin consent (F10): `hermes plugins update` on a catalog install replaced the
tree without asking, even when the new pin declared new tools, hooks, Python
dependencies, host capabilities or a Desktop half. `repin_catalog_plugin` now
diffs the installed manifest against the staged clone BEFORE anything moves
(`_install_plugin_core(before_swap=...)`) and, on a widening:
- CLI: prints the delta and asks y/N (non-interactive → not applied, fail
closed); after a changed re-pin it runs the same `_run_capability_consent`
grant path as the git-pull `update`.
- `plugins.manage update` RPC and the dashboard REST route answer
`{ok: false, consent_required: true, delta, delta_lines}` with nothing
changed; a retry with `accept_capabilities: true` applies it. Desktop shows
the delta in its confirm dialog; the web dashboard uses `window.confirm`.
- Gateway contract regenerated (`accept_capabilities` param; `consent_required`,
`delta`, `delta_lines`, `error` result fields).
Catalog audit findings F8 and F10 (low severity, no issue filed).
Salvage of #66777 (@chrisyoung2005): the dashboard toggle toast now says a restart is needed
(#71595). Moved the ``restart_required`` stamp from a dashboard router wrapper into
dashboard_set_agent_plugin_enabled so the ``plugins.manage`` RPC (TUI/Desktop) carries it too;
contract + generated TS regenerated. The salvaged endpoint tests stubbed the toggle they were
checking — replaced by invariant tests that run the real commands against a temp HERMES_HOME
(all red on origin/main): platform disable gates the loader, alias toggle writes the canonical
key, status honours bundled defaults + memory.provider, remove forgets config / resets
memory.provider / unlinks a symlink only, disabled memory provider is not loaded.
test_deferred_platform_client_tools: bundled a2a is keyed ``platforms/a2a`` now.
Symptoms fixed (all live-reproduced on origin/main in a fake HERMES_HOME):
- `hermes plugins disable photon-platform` wrote `platforms/photon` while the loader keyed the
bundled adapter `photon-platform`, so the disable never applied (#27548). The loader now keys
bundled platforms `platforms/<dir>` like every other category (one call site in
plugins_discovery.py); the manifest name stays an accepted alias through gate_manifest.
- `plugins.manage toggle` / dashboard toggle wrote the raw identifier: enabling by bare leaf or
manifest name returned ok while a stale canonical key in plugins.disabled kept the plugin off.
dashboard_set_agent_plugin_enabled resolves the canonical key and purges every alias from the
opposing list (shared _activate_key, also used by cmd_enable/cmd_disable); the RPC reports the key.
- remove/uninstall (CLI, dashboard, RPC) left the plugin in plugins.enabled/disabled and
plugins.entries (#54336), left memory.provider dangling so the next agent init re-cloned the
provider from the catalog (uninstall silently reverted), and, for a symlink inside the plugins
dir, deleted the TARGET plugin and its metadata while the link stayed dangling. _remove_user_plugin
is the shared tail: unlink a link only, then forget config under every alias and reset
memory.provider (reported as cleared_memory_provider).
- `hermes plugins list` / dashboard hub / TUI hub called bundled backends, bundled platforms and the
live memory.provider "not enabled" (#73131, #82898): _plugin_status mirrors gate_manifest.
- A user-installed memory provider parked in plugins.disabled kept loading: load_memory_provider
honours the deny-list (name, dir name or manifest name) and says so once.
On native Windows the MSYS runtime re-parses git.exe's argv when the parent is a
non-MSYS process (always the case for python.exe) and strips the braces, so
`stash@{0}` reaches git as `stash@0` and is rejected. For `hermes plugins update`
the autostash apply failed every time and the user's edits sat in a stash the tool
claimed to have handled; for `hermes update` the drop failed and stash entries
piled up unbounded.
- plugins_cmd: `_autostash_dirty_tree` returns the autostash commit sha;
`_reapply_stash` applies that sha and drops positionally (bare `git stash drop`)
only while refs/stash is still that sha. No brace selector in any argv.
- update_cmd_stash: `_resolve_stash_selector` returns the bare index `N` git
accepts wherever `stash@{N}` is valid; the copy-pasteable guidance drops the
braces too.
Existing assertion change: `test_autostash_dirty_tree_promotes_intent_to_add_entries`
asserted `(True, "")`; the first element is now the stash sha, asserted against
`git rev-parse refs/stash`.
Fixes#87542. Credit: @PRATHAMESH75 (#87571) for the diagnosis and the parent-process table.
A plugin installed from a repository subdirectory ships only <clone>/<subdir>; the
.git stays behind in the temp clone, so `hermes plugins update <name>` (and the
dashboard update) refused with "not installed from git" forever. The install
metadata already records the canonical source (URL#subdir), so when the tree has
no .git the shared update core re-runs the installer from that source with
force=True and swaps the fresh tree in; revision bookkeeping is the installer's.
Pinned installs are still refused first, unchanged.
Fixes#65314. Credit: @webtecnica (#65337) for the report and first fix attempt.
Catalog trust bugs from the 2026-09-21 plugin audit (lane 3, F1-F6, F9):
- F1 (high): a URL-installed repo shipping its own .hermes-catalog.json rendered
as catalog:official everywhere and marked the real entry "installed". Provenance
now lives on the installer-owned .install-metadata.json record (catalog block
written by _install_plugin_core, sha = checked-out commit); read_catalog_sidecar
never reads the tree. Pre-fix installs are adopted once when the installer
record agrees (pinned at the sidecar sha, cloned from the entry's repo).
- F2: removed.yaml bypassed by git@/ssh:///http:///www. spellings. _normalize_repo
canonicalises to host/owner/repo (scheme, user, www., .git, slashes dropped).
- F6: removed.yaml consulted for INSTALLED plugins too: git-pull update, enable and
gate_manifest (load) refuse recalled plugins, offline (in-tree + cached live
list). --allow-removed is recorded on the install record and exempts it.
- F5: install NAME --ref X recorded the catalog pin, so list/TUI/update claimed
the reviewed pin while HEAD differed. Recorded sha is the checked-out one.
- F3: re-pin replaced the whole tree, losing the installer-created config.yaml,
data and user patches with no warning. Untracked/ignored files are carried
into the new tree; edits to tracked files are copied to
<HERMES_HOME>/plugins-backup/<name>-<sha8>/ with a warning.
- F4: a manifest rename between pins left the OLD dir installed and enabled.
The stale dir is removed and the enabled flag follows the new name.
- F9: dashboard payload `removed` list now includes live removals.
repin_catalog_plugin returns RepinResult(sha, changed, installed_name, warnings);
CLI, dashboard and TUI callers surface the warnings and the new name.
The saved update_url is what the gateway urlopen()s unattended every
check interval, and the feed decides which origin commit an update
selects. Install copied it into the row inside a try/except-pass with
no scheme check, so http:// (MITM to an old vulnerable commit),
file:// and ftp:// feeds were accepted.
One predicate (plugins_updates.https_update_url) is applied at install
(a refusal before any state exists — the manifest was already read, so
the re-read/except-pass is gone), in trust-update-url, and at
default_fetch itself so rows saved before this rule are refused too.
update_plugin published whatever the pulled pyproject/pip_dependencies
declared straight into the shared environment, while install/reinstall
ask "Prepare these with Hermes through PM now? [y/N]". Under
plugins.auto_apply that is unattended from the gateway.
Diff the declared install_requirements before vs staged: added ones go
through the same prompt (extracted as _consent_python_deps) when a
terminal user is present; the dashboard and auto-apply pass
interactive=False and get a refusal with nothing published — matching
the capability re-consent already in cmd_update.
Same seam rebuilds an accepted Node sidecar in the staged copy when
package.json/lock moved: publication swaps the whole tree, so a custom
pull carried a stale copied node_modules and a catalog re-pin dropped
it entirely.