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.
The multiplex migration preflight (and the duplicate-credential check doctor / gateway
status share) loads every profile's gateway config in that profile's scope, and
load_gateway_config() discovers plugins there. For a PARKED profile that imported its
plugins into the live host gateway on every boot and ran their register() side effects
(threads, DB handles) for a profile the operator took out of the host.
Decision (the thread's needs-decision): keep parked profiles in the inventory AND the
duplicate-credential guard — test_parked_profile_remains_in_migration_inventory stays as
written (parking must not hide a conflict) — but read a parked home's config under a new
suppress_plugin_discovery() contextvar that makes discover_plugins() a no-op without marking
the scope discovered, so the first real consumer after unparking still loads its plugins.
Trade-off recorded in _profile_gateway_config: only a parked profile's plugin-defined
platforms fall out of the guard (their credentials cannot be evaluated without importing the
plugin); builtin bot tokens still collide.
PR #123397 (JoaoMarcos44) located the site; it skipped parked homes from config loading
entirely, which flipped the tested guarantee, so this is a slim redo with credit.
(cherry picked from commit 0267e14a2c1d8bcbebaefa48011c43ec6b86c46f)
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.
With `chronos` in plugins.enabled every start warned
"Failed to load plugin 'chronos': 'PluginContext' object has no attribute
'register_cron_scheduler'": collect_directory_manifests skipped the bundled
memory/context_engine/model-providers categories but not cron_providers, and a
user-installed cron provider under ~/.hermes/plugins/<name> was never
auto-coerced to kind=exclusive. Cron providers activate through
plugins.cron_providers (cron.provider config), so the general manager must only
record them, exactly like memory providers.
Fixes#62951
credit: @wdmason #100362 (slim redo; #100396 is the consolidated duplicate)
Multi-harness plugin repos (e.g. obra/superpowers) ship one plugin.json
per OTHER agent harness inside .claude-plugin/, .codex-plugin/,
.cursor-plugin/, .devin-plugin/ and .kimi-plugin/. Those manifests can
never satisfy the Agent Plugins v1 schema, so every discovery pass
rejected each one and logged a warning — ~1,500 warnings/day on the
reporter's install (#101962).
Skip these well-known per-harness convention directories during
directory scanning. A plugin's real Hermes manifest
(.hermes-plugin/plugin.yaml or a top-level plugin.yaml/plugin.json) is
unaffected, and genuinely broken portable manifests still warn.
Fixes#101962
Name-based dunder skipping does not need an unreadable __pycache__.
Drop the chmod(0) setup so the test is portable, and log skipped
dunder paths at debug.
Walking __pycache__ and other dunder children let an unreadable
directory raise out of plugin discovery and fail unrelated tool
calls. Skip those names and treat iterdir/is_dir OSError as an
empty scan for that node.
Fixes#86996
- requires_hermes compared against stale editable-install dist metadata (0.21.0) while the
checkout ran 0.21.4, skipping plugins that required the release in use; hermes_cli.__version__
is now the source of truth, dist metadata only a fallback.
- PEP 440 pre/post suffixes glued to a segment (99.0.0rc1) made a clause permissive and an rc
running version disabled every gate; the segment parser drops the suffix.
- A plugin calling sys.exit() at import or in register() propagated SystemExit out of
discovery: the whole registry emptied and `hermes chat` exited 3 with no output. Load
isolation now covers SystemExit (KeyboardInterrupt still propagates).
- uv reads [tool.uv] exclude-newer from the cwd project only, so lazy/plugin dep installs
launched from $HOME, a gateway service or the Desktop backend were never quarantined; the uv
tier now runs from the checkout root when one exists.
- A flat user/project manifest naming a bundled key from a differently named directory no longer
displaces the bundled plugin (warn + skip); a same-named override is logged at INFO.
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.
Reconcile plugin declarations and validation through PM's atomic generation publication; preserve external runtimes, target markers, and conflict refusal. Keep one source-update completion owner and port upstream lifecycle changes to the PM desktop/runtime paths.
`scan_directory` (the PluginManager sweep every CLI/gateway/Desktop backend
runs at startup) and `plugins_cmd._scan_level` (`hermes plugins list`, the
dashboard plugins hub, the TUI plugin picker) probed `plugin.yaml` with
`Path.exists()` outside any error handling. `stat()` raises instead of
returning False when the plugin directory itself is unsearchable — Windows
ACLs (WinError 5, the #111804 report) or a POSIX mode-000 folder — so a
single bad plugin folder took every other plugin down with it and the
Desktop backend exited before announcing its port.
Both scans now warn and skip that one directory, matching the dashboard
manifest scan fixed in the preceding (salvaged) commit.
Part of #111804