Commit Graph

114 Commits

Author SHA1 Message Date
teknium1
72d18ce59f fix(plugins): subdirectory installs download only that folder
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.
2026-09-23 21:29:21 -07:00
Gille
5dbbc9a9c0 fix(cli): make plugin clone timeout configurable 2026-09-23 21:29:21 -07:00
John Paul Soliva
16db511ed5 perf(config): route the config and manifest loaders through fast_safe_load
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)
2026-09-23 21:26:49 +05:30
alt-glitch
d275e422dc fix: plugins installed a second apart both keep their install record
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.
2026-09-23 11:45:29 +05:30
teknium1
21d0b12958 feat(plugins): late-loaded plugins wire their platform handlers live (#87770)
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.
2026-09-22 09:50:22 -07:00
Siddharth Balyan
adefd99671 Portable plugins declare the application each MCP server needs; install refuses on an unsupported host (NS-942) (#119049)
* feat: enforce portable server declarations

* test: cover portable plugin host gates
2026-09-22 17:54:41 +05:30
teknium1
db8dcbe94c fix: catalog re-pins ask before widening a plugin; annotated-tag pins keep reviewed trust
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).
2026-09-22 01:00:09 -07:00
teknium1
a8b3fe60ac fix(plugins): restart_required on every toggle surface; trim salvage to invariant tests
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.
2026-09-22 00:13:50 -07:00
teknium1
643dea487b fix(plugins): canonical activation keys + remove/toggle bookkeeping across CLI, dashboard and RPC
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.
2026-09-22 00:13:50 -07:00
teknium1
92e6cc189b fix(update,plugins): address autostash entries by sha / bare index, never stash@{N}
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.
2026-09-22 00:06:29 -07:00
teknium1
4a564706da fix(plugins): subdir installs stay updatable via re-install from the recorded source
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.
2026-09-22 00:06:29 -07:00
teknium1
77f80d31ee fix(plugins): catalog provenance is installer-owned; kill list covers update/enable/load; re-pin keeps user files
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.
2026-09-21 23:17:05 -07:00
sy0u1ti
b79b530eec fix(windows): delete read-only Git trees instead of failing with WinError 5
shutil.rmtree stops at the first entry it cannot unlink, and Git leaves loose object files read-only on Windows (r--r--r-- trees on POSIX package installs behave the same). Checkpoint clear, MCP catalog uninstall/reinstall and git-installed plugin removal all deleted clones with a bare rmtree, so each aborted mid-tree and left partial state.

Add utils.rmtree_readonly: clear the write bit on the failing entry and its parent, retry that one operation, and keep rmtree semantics for every other failure (both the 3.11 onerror and 3.12+ onexc callback shapes).
2026-09-21 08:58:37 -07:00
tobenwarrior
dff92f3d78 fix(plugins): peel an annotated-tag pin before verifying the checkout
A pin is validated as 40 hex, but that does not make it a commit: a tag
object has a sha of its own, and an entry recorded from 'git rev-parse <tag>'
names the tag object rather than the commit it points at. Git resolves the
ref and detaches at the commit, so the revision guard compared a commit
against a tag-object sha and refused a CORRECT checkout — the entry was
uninstallable, with no way through (see the kiro-acp catalog entry).

Peel the pin to its commit before comparing. The guard keeps its teeth: HEAD
must still be exactly the commit the pin names, and a checkout that lands
anywhere else is still rejected. An unresolvable pin falls back to the raw
string, so a genuinely wrong sha fails exactly as before.

Tests: an annotated-tag pin installs at its commit (and records the commit,
not the tag object, so 'plugins update' drift checks stay exact); a
checkout that lands on another commit is still rejected. Red on base:
'Checked-out revision "..." does not match requested commit "..."'.

(cherry picked from commit 66baa043b70c4f66ae59c0a1147e74ba694140ef)
2026-09-20 15:56:55 -07:00
webdevtodayjason
118984d7a0 fix(plugins): share the loader's SUPPORTED_MANIFEST_VERSION with the installer (#85879)
The installer carried a private manifest-version cap of 1 while the runtime loader (and the
docs) accept manifest_version 2, so 'hermes plugins install' refused every v2 plugin the loader
happily loads. Read the shared constant instead. The alignment landed once in e834ee8971 and
was reverted by the follow-up 5bad3d54ee.

Salvaged from PR #85893 by @webdevtodayjason; rebased onto the decomposed plugins_cmd.py.
2026-09-20 14:29:39 -07:00
teknium1
b5d7a7b15d fix(config): hermes doctor and plugins enable read a list-literal platform_toolsets string
#116691 taught the runtime (`_get_platform_tools`) to read the
`'["browser", "terminal"]'` string an older `hermes config set` stored as
the user's real toolset selection, but the two other readers of the section
kept the old `isinstance(raw, list)` gate:

- `validate_platform_toolsets` (hermes doctor / startup warnings) reported
  "invalid toolset value ... falling back to 'hermes-cli'" for a value the
  runtime resolves to browser+terminal — a false warning.
- `plugins_cmd._toggle_plugin_toolset` skipped the string entry, so
  `hermes plugins enable <p>` never reached that platform.
- `nous_subscription._toolset_enabled` treated it as an empty list.

Move the list-literal parse into one function,
`toolset_validation.parse_platform_toolsets_value`, and route all four
readers through it; the plugin toggle re-saves the entry as a real list so
the string never persists past the next write. `_coerce_platform_toolsets_value`
keeps its once-per-platform warning for values that do not parse.

Why one parser: three readers with three notions of "configured" is exactly
how #115866 went unnoticed. Follow-up to #116691 (independent review finding).
2026-09-20 12:44:48 -07:00
funky-xamarin
09bcf17801 fix(plugins): only prompt for declared capabilities on enable 2026-09-20 11:52:45 -07:00
neron82
db64d1f109 fix(update): make intent-to-add entries stashable so hermes update can run
`git stash push` refuses a worktree holding an intent-to-add entry (`git add -N`): such an
entry carries the empty blob with zeroed stat data, so it is never "uptodate" and the whole
stash fails with

    error: Entry 'tests/...' not uptodate. Cannot merge.
    Cannot save the current worktree state

which aborts the update before the pull, on an index state editors create routinely when they
show a new file in diffs. The abort message names the proximate symptom ("commit, stash, or
clean up your local changes") and nothing points at the index entry, so the only way out is
git archaeology.

The autostash already repairs one neighbouring index state - unmerged entries from an
interrupted merge, two lines above - and this is the same class, so it is handled in the same
place: paths are read from porcelain (" A" is intent-to-add, "A " is staged, "??" untracked)
and promoted to real staged adds before the stash. Content is preserved; after the restore
they return as staged additions, which is the closest `git stash` can represent.

Both autostash sites are covered: `hermes update`'s and the plugin updater's
`_autostash_dirty_tree`, which had the same dead end and no test coverage at all.

Tests (2, behavioural against real git, both red before the fix):
  tests/hermes_cli/test_update_autostash.py::test_autostash_survives_intent_to_add_entries
  tests/hermes_cli/test_plugins_cmd.py::test_autostash_dirty_tree_promotes_intent_to_add_entries

Verified with scripts/run_tests.sh: base 79 passed / 2 failed (each with the reported error
reproduced); with the fix 81 passed / 0 failed.
2026-09-19 23:58:35 -07:00
jinli.yl
5bad3d54ee chore(plugins): pin ReMe catalog to merged integration 2026-09-19 11:58:06 -07:00
jinli.yl
e834ee8971 feat(plugins): list ReMe and align manifest install support 2026-09-19 11:58:06 -07:00
teknium1
9b55e7e6ac fix(plugins): attach stored git credentials only after an anonymous refusal, on every network verb
Widen the anonymous-first decision from #114545 to the whole class. The
clone-only wrapper (_clone_with_auth_fallback) folds into one runner,
git_credentials.run_git_with_credential_fallback, used by every site that
attached a stored credential unconditionally:

  - plugins_cmd._run_plugin_git -> clone, the pinned --ref fetch in
    _checkout_exact_revision, and the pull in _git_pull_plugin_dir
    (`hermes plugins update`), which the thread on #114526 showed still
    failing with "could not read Username ... terminal prompts disabled"
    after the clone succeeded anonymously
  - mcp_catalog._do_git_install (catalog MCP git installs)
  - profile_distribution._git_clone (profile distributions from a git URL)

Why the runner and not per-caller guards: the decision belongs to the
credential layer once; a rejected credential sent pre-emptively is what
turns a working anonymous clone into a 401 that git can only answer with
the prompt noninteractive_git_env disables. The credential-required
classifier also matches git's "returned error: 401" spelling (the picked
version needed a trailing space that git never prints) and bytes output.

Side effect: `gh auth token` is now shelled out only after a refusal, so
public installs no longer pay that subprocess at all.

Tests: the sibling verbs (fetch, pull) x (public, refused) and the negative
that a non-credential failure never resolves a credential; the SSH no-op
case from the pick is already asserted by the private-remote test. Docs:
website/docs/user-guide/features/plugins.md.

Fixes #114526
Co-authored-by: holny <holny@users.noreply.github.com>
2026-09-18 10:01:01 -07:00
holny
c082b01395 fix(plugins): clone anonymously first; only attach stored credential on 401/403/Username-prompt (#114526)
hermes plugins install was broken for every public catalog repo the moment the
user had `gh auth login`'d: _clone_plugin_repo unconditionally passed auth_url to
_run_plugin_git, which injected an Authorization: basic <gh-token> into every
clone attempt — even against public GitHub URLs. GitHub rejects the Basic
header on a public clone endpoint, git falls back to a Username prompt, and
GIT_TERMINAL_PROMPT=0 (set by noninteractive_git_env) blocks it with
"could not read Username ... terminal prompts disabled". The reporter's
smoking gun was `git ls-remote https://github.com/robbyczgw-cla/...` working
anonymously while the installer failed with that env.

Split the path: an anonymous clone runs first; only when it fails with a
credential-required error (Username prompt / 401 / 403 / auth failed) does
the fallback retry with the stored credential. Public repos stay on the
single anonymous attempt; private repos reach the fallback naturally when
anonymous access is refused — the existing #9886f6e53b private-repo path is
preserved.

Tests (tests/hermes_cli/test_plugin_private_repo_auth.py):
  - test_private_clone_falls_back_to_auth_after_credential_required_error
    (renamed from the unconditional-auth test; first attempt anonymous +
    "could not read Username" stubbed; fallback carries the Authorization)
  - test_public_clone_attempts_anonymously_when_credential_resolves
    (github.com URL + resolve_git_basic_auth returns x-access-token token;
    exactly one clone call, zero Authorization headers)
  - test_non_https_clone_skips_auth_header_even_when_credential_resolves
    (SSH URL guard — the https path is the no-op)

Domain suite: 473 tests across 32 hermes_cli/test_plugin*.py files pass;
plus test_noninteractive_git.py + test_mcp_catalog.py + test_plugins_cmd.py
+ test_plugin_install_ref.py + test_plugin_scanner_recursion.py (136 more).
ruff check clean on both changed files.
2026-09-18 10:01:01 -07:00
teknium1
a89ef71b10 refactor(plugins): kill-list annotation takes the pre-resolved list directly; trim to two invariants
Follow-up to the salvaged #113687 / #113682 commits:

- `removed_annotation(name, dir_path, removed_entries=None)` resolves the kill list once itself
  when no list is passed and matches against it; the `removed_annotation_batch()` wrapper and its
  name-keyed dict are gone (a hub row keyed by `name` could shadow a same-named nested plugin).
  Both surfaces (`plugins list`, `_merged_plugins_hub`) call `resolved_removed_entries()` once
  and pass the list per row.
- The negative cache is one module float deadline instead of a URL-keyed dict plus two helpers;
  the duplicated stale-cache read is one `_stale_live_cache()`.
- Tests trimmed to two invariants: failure memory honours the TTL and still serves a stale copy;
  hub rebuild + `plugins list` each cost one network attempt and still annotate an in-tree removal.
  The #113682 route test's fake gains the new third argument.
- Docs: the live-refresh section says a failed fetch is remembered for a minute.
2026-09-18 09:43:19 -07:00
Konstantin Khlopkov
88b3163742 fix(plugins): one kill-list resolution per plugins hub rebuild and CLI listing 2026-09-18 09:43:19 -07:00
teknium1
e21ccdd7dc feat(memory): a configured provider that left core is installed from the catalog automatically
Built-in memory providers are moving to their maintainers' repos and the plugin catalog. Their
name, memory.<name> config section, data directory and tool names stay the same, so the only
thing a user on the built-in form loses is the code path. Two hooks now fetch it:

- hermes update: every profile home sharing the venv whose memory.provider resolves nowhere gets
  the catalog plugin installed at its reviewed pin (kill list, dependency constraints, enable).
- agent init: when the provider resolves nowhere, one attempt per process (Desktop users never
  run `hermes update` by hand); honours security.allow_lazy_installs.

Offline / not in the catalog: a warning with the exact one-liner instead of DEBUG-only silence.
Also accepts `owner/repo#subdir` for plugin installs (the catalog's spelling; honcho ships its
plugin in a subdirectory of its main repo).

Live: bundled honcho removed, memory.provider=honcho, catalog entry staged → AIAgent() installs
plugins/honcho from plastic-labs/honcho#hermes-plugin-honcho with deps and loads it; config kept.
2026-09-17 20:35:22 -07:00
teknium1
9dda4332f8 fix(plugins): an installed directory plugin keeps its identity over a same-name pip entry point
The pyproject wrapper shape (#113851) depends on a pip package that often ships its own
hermes_agent.plugins entry point under the same name. Discovery appended entry points last with
"later wins", so after a catalog install the plugin row became source=entrypoint with no install
dir or catalog provenance: Desktop lost "installed from catalog @ version / update to ..." for
exactly the shape the catalog now recommends. Entry points no longer displace a directory plugin
of the same key, in the loader and in the CLI/RPC listing.

Live: catalog install of mnemosyne, row before = entrypoint / mnemosyne_hermes:register / no
catalog fields; after = user / ~/.hermes/plugins/mnemosyne / catalog 0.7.0 @ 95ef3be2; provider
still discovered. Test red on base.
2026-09-17 18:36:20 -07:00
teknium1
f253f25b78 feat(plugins validate): --install-deps installs the declaration before the capability probe
Catalog CI validates each pinned tree in a venv that has only hermes-agent, so any plugin whose
code arrives through pyproject dependencies (the wrapper shape from #113851) failed the probe with
"No module named ...". The flag runs the same constrained install `plugins install` would, then
probes; the workflow passes it. Live: mnemosyne pin fails without the flag, passes with it.
2026-09-17 11:21:42 -07:00
teknium1
96e8a23222 feat(plugins): install declared Python dependencies and re-apply them after hermes update
A directory plugin's pyproject.toml [project].dependencies (or manifest
python_dependencies) are now installed into the Hermes venv on install/enable/
update, resolved under a constraints file built from Hermes' own pinned
dependencies together with every enabled peer's declarations. A candidate that
cannot resolve is refused before its tree is moved into place; nothing is
installed and no other plugin is touched. --no-deps opts a single install out.

hermes update rebuilds the venv from Hermes' lock and strips everything else, so
its git, zip and both repair paths now re-apply the union of every profile's
enabled plugins. When the union no longer resolves, each plugin is resolved on
its own so only culprits are dropped (never an alphabetical neighbour); a
plugin-vs-plugin conflict peels non-memory plugins first because a Hermes that
boots without memory reads as data loss. Dropped plugins are disabled through
the real config writer with a loud message naming the fix.

python_runtime: external lets sidecar-venv plugins (Mnemosyne's shape) opt out
of the union; hermes-agent self-dependencies and direct-URL requirements are
never installed (the latter are surfaced for the user to install by hand).
2026-09-17 00:11:27 -07:00
teknium1
260da4ef62 fix: catalog installs no longer block on caution; admission runs the same scanner
Symptom: a third of catalog entries (23 of 71 pinned trees scanned today) could not be
installed from the Desktop. The install path runs plugin_guard on every clone and a
caution verdict needs a confirmation; the CLI prompts, the Desktop/dashboard path
passes no decision callback, so caution became "Security scan blocked".

Root cause: admission (hermes plugins validate + catalog CI) never ran the scanner, so a
pin could be approved by a human and still trip the installer.

Fix, both halves:
- validate_plugin_dir gains a "security scan" check: dangerous fails the entry, caution
  is reported as warnings so the reviewer reads the findings before merging the pin.
  pinned-source-validate in plugin-catalog-ci.yml therefore runs the identical scanner.
- _install_plugin_core accepts reviewed_pin=<catalog sha>; when the checked-out revision
  equals it, caution is accepted without a prompt. dangerous still blocks (a signature
  added after review is what the backstop is for). Raw-URL installs, --ref overrides
  and a checkout at any other revision keep the current behaviour.

Live: dashboard_install_plugin('weather') on origin/main -> BLOCKED caution; after -> ok.
2026-09-16 15:47:44 -07:00
teknium1
db25a7852e fix(plugins): install refuses to ship an unreadable plugin tree (#111804)
A clone can land unreadable (Windows ACL inheritance -> WinError 5, a
mode-000 file). Discovery now skips such a dir instead of aborting
(#112293), but the install that produced it still exited 0, so the user
got a plugin that silently never loads.

After the clone and before anything moves into place, walk the staged
tree and open every file / list every dir. On failure repair u+rX where
the OS honours mode bits; if still unreadable raise
PluginOperationError naming the file and the fix (icacls / chmod). The
staging dir is cleaned up, nothing is installed, exit is non-zero.

Fixes #111804 (its discovery half landed in #112293).
2026-09-15 21:47:18 -07:00
teknium1
22716fab2a fix(plugins): one unreadable plugin dir no longer aborts loader or list discovery
`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
2026-09-15 18:48:59 -07:00
teknium1
23036e20a6 fix(ux): plain-language, actionable user-facing messages (core)
Squashed integration of the user-facing message audit for this surface set.
Full per-finding receipts: /tmp/ux-audit/lanes/*-receipt.md (campaign artifacts).
2026-09-15 04:12:13 -07:00
Teknium
6d89c1afde feat(plugins): pin a plugin to a commit from Desktop, and show pins in every list
Only the CLI could install a plugin at an exact commit (`--ref <sha>`); the
Desktop "Install from Git" dialog, the TUI gateway `plugins.manage install`
method and the dashboard `/agent-plugins/install` endpoint all called
`_install_plugin_core` without a ref, so a team that wanted everyone on the
same private-plugin commit had to leave the app for a terminal.

- `dashboard_install_plugin(ref=)` threads the pin to `_install_plugin_core`;
  the 40-hex validation and HEAD verification are unchanged. The gateway
  method and dashboard body accept `ref`.
- Desktop dialog gains an optional "Pin to commit" field (custom sources only,
  client-side 40-hex check disables Install on anything shorter).
- `plugins.manage list` rows carry `pinned_sha`; the Desktop plugins tab shows
  a `pinned @ <sha8>` badge and `hermes plugins list` prints `git pinned@<sha8>`
  in Source, so a team can eyeball that everyone runs the same commit.
2026-09-10 05:09:15 -07:00
Teknium
9886f6e53b feat(plugins): install plugins from private git repos using the user's stored credentials
`hermes plugins install owner/private-repo` failed for every private repository
even when the same user could `git clone` it from their shell: the hardened
`noninteractive_git_env` disables credential helpers, askpass and global config
(so a hostile repo can't make our plumbing prompt or hang), which also blocks
the user's own stored credential. The result was "could not read Username" or a
60s hang on a GUI askpass, with no hint about how to authenticate.

New `hermes_cli/git_credentials.py` resolves a credential up front from sources
the user already owns — GITHUB_TOKEN/GH_TOKEN (profile-scoped), `gh auth token`,
then `git credential fill` against their configured helpers with prompting
disabled (any host) — and hands it to git as a one-shot
`http.<origin>/.extraheader` via the GIT_CONFIG_* env block. Nothing lands in
the URL, `.git/config` or install metadata. The same path covers
`plugins update`, catalog MCP git installs and profile-distribution staging,
which share the same hardened env and the same failure.

A private-repo clone with no credential now fails fast with an actionable hint.
2026-09-10 01:00:19 -07:00
Teknium
41b4555ed9 feat(plugins): catalog is the sole discovery system — re-port onto main's layout
- hermes_cli/plugins_cmd_catalog.py: new sibling owning resolution, the
  .hermes-catalog.json provenance sidecar, search/info/validate, re-pin on
  update, and the dashboard/TUI payload builders. plugins_cmd.py only
  gains the hooks (cmd_install catalog branch, cmd_update / dashboard
  update re-pin, dashboard_install_plugin catalog_name + kill list,
  dispatch entries); the community index (plugin_index.py) is gone.
- hermes_cli/plugin_catalog.py: catalog_dir parameter replaces the
  test-only HERMES_PLUGIN_CATALOG_DIR env var; live refresh reads ONE
  published document (/docs/api/plugin-catalog.json, 6h cache, in-tree
  fallback) instead of the unauthenticated GitHub contents API (60 req/h,
  1 request per entry); in-tree and live removals are unioned so a stale
  cache can never un-block.
- Catalog route lives in web_routers/dashboard_ui.py (the facade is off
  limits); _plugin_runtime_status shared from web_server_dashboard.py;
  hub rows carry removed_reason. TUI plugins.manage gains catalog_name
  install, catalog row fields and an update action.
- plugin_validate: the probe context honours ctx.get_config defaults
  (real plugins do int(ctx.get_config("timeout", 180)) in register()).
- Installed-state merge matches through the sidecar's catalog_name
  first — catalog names rarely equal manifest names.
- extract-plugins.py emits plugin-catalog.json; deploy-site triggers on
  plugin-catalog/** so entry merges republish it.
2026-09-09 04:38:01 -07:00
Teknium
0a5164cebe compat(plugins): tell users which installed plugins break on 2026-09-14, and stop loading them after
hermes_cli/plugin_compat.py is now the single source of truth for the compat window:
  COMPAT_REMOVAL_DATE = 2026-09-14; scan_plugin() statically finds `from F import n`, `import F` + `F.n`,
  alias forms and string targets against compat_manifest.json; compat_report() aggregates over the user's
  ENABLED external (non-bundled) plugins; disable_reason() decides the loader's skip.

Surfaces (all read from that one report):
  * CLI: yellow block under the banner naming plugins + date + `hermes plugins compat` (red + DISABLED after)
  * `hermes plugins compat [--json] [path]`: file:line, old -> new per hit; exit 1 while anything remains;
    `path` lets a plugin author scan their own checkout
  * `hermes doctor`: "Plugin import paths (removed Sep 14, 2026)" section next to the xAI retirement check
  * `hermes update`: post-update notice alongside the FTS/curator notices
  * Desktop: compat_report() writes HERMES_HOME/.plugin-compat-report.json (deleted when clean); Electron
    shows ONE warning dialog per distinct report after the backend is up and persists the dismissal in
    userData/plugin-compat-dismissed.json. A new affected plugin, or the date passing, is a new report.

From the date, PluginManager skips a hitting external plugin before importing it, with the reason in
LoadedPlugin.error ("uses N import path(s) removed on 2026-09-14; run `hermes plugins compat` ...") — the
same path a plugin with a broken register() takes, so nothing else is affected. Escape hatch:
plugins.allow_deprecated_imports: true (config_defaults), which only helps until the compat commit is
actually reverted.

Docs: COMPAT_MANIFEST.md (removal date, what-happens table, author instructions), plugin dev guide section.
Tests: tests/test_plugin_compat_notice.py (scanner forms, report scope, date gate + escape hatch, summary
text, report file lifecycle, loader skip via a real PluginManager), electron/plugin-compat-notice.test.ts
(show once, re-show on a different set or on the date passing, malformed file ignored).

Live A/B on this box with a demo plugin on old paths: before the date it loads and the banner/doctor/report
name it; with today=2026-09-14 it is skipped with the reason and the banner turns red; with the escape
hatch it loads again.
2026-09-04 01:28:31 -07:00
Teknium
2776813df3 compat(plugins): temporary import-path shims for external plugins — ONE commit, revert on schedule
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in
the focused modules that define them. This commit is the ONLY thing keeping the old paths alive,
so external plugins have time to update. It is deliberately a single, unsquashed commit:

    git revert <this sha>

removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on
these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does.

What it adds (see COMPAT_MANIFEST.md, compat_manifest.json):
- 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file
- 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import,
  so no import cycles; facades that already had `__getattr__` get a chained one
- 592 third-party/stdlib names the old modules used to expose, with their original import statements
- 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition
  tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them)
- 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/
  relay_runtime, tools/environments/modal_utils)
- private names (`_x`) get no pointer: they were never API (3,792 skipped)

Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves;
the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
2026-09-03 17:13:22 -07:00
Teknium
eeb7671e69 simplify(compat): hermes_cli small facades — drop 7 re-exports/aliases (+relay_runtime alias module), repoint 12 callers/tests 2026-09-03 13:05:57 -07:00
Teknium
e83816a4d1 review-fix(comments): restore lost #NNNN rationale comments across non-test source (mechanical sweep, condensed, code unchanged)
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.
2026-09-03 09:44:26 -07:00
Teknium
9e7b941940 refactor(hermes_cli): plugins_cmd — partial-based enabled/disabled accessors, shared _table builder, compact composite UI (nav table + color-pair loop), fold small helpers 2026-09-03 00:10:13 -07:00
Teknium
884fa31ca7 refactor(hermes_cli): collapse dashboard helper guards in plugins_cmd 2026-09-02 22:40:19 -07:00
Teknium
2868ea5922 refactor(hermes_cli): fold _configure_provider_category into the spec-driven picker (probe-verified) 2026-09-02 22:33:12 -07:00
Teknium
d127749b23 refactor(hermes_cli): inline single-use plumbing helpers (portable manifest read, pulled-revision record, entrypoint listing, timeout block) 2026-09-02 22:24:36 -07:00
Teknium
53cfea1801 refactor(hermes_cli): extract _autostash_dirty_tree from _git_pull_plugin_dir 2026-09-02 22:18:21 -07:00
Teknium
333560f563 refactor(hermes_cli): composite UI reuses curses_ui._addnstr and a shared row renderer (probe-verified identical draws) 2026-09-02 22:14:49 -07:00
Teknium
b044b04d6f refactor(hermes_cli): plugin_packs reuses plugins_cmd console/tty/yes/fail helpers; one-line section banners 2026-09-02 21:57:39 -07:00
Teknium
984a817867 refactor(hermes_cli): extract _rescan_after_update from cmd_update 2026-09-02 21:47:40 -07:00
Teknium
6849bacd1a refactor(hermes_cli): unify _sub_dict into plugin_capabilities._child_dict; merge requires_env normaliser 2026-09-02 21:40:39 -07:00
Teknium
59adc9c94e refactor(hermes_cli): one-line short def signatures in plugins_cmd (AST-identical) 2026-09-02 21:31:03 -07:00
Teknium
ca45d1ea0b refactor(hermes_cli): drop unused PluginPack/PackPluginEntry.to_dict, inline capability list printer 2026-09-02 21:23:15 -07:00