21f86655bcea93094d94e9688067d595e5c0c69f
58 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d0132b5821 |
fix(cli): give every wizard free-text prompt arrow-key and Ctrl+A/E editing
Widens #90327's line_input() to the whole bug class: all 46 bare input() free-text prompt sites across the setup wizards (model_setup_flows, setup, config, gateway, auth, auth_commands, plugins_cmd, skills_hub, bundles, setup_whatsapp_cloud, main) now route through line_input(), and the shared cli_output.prompt() / setup.prompt() helpers do too — so every CLI wizard gets cursor editing, not just the custom model prompt. Redirected stdin and missing prompt_toolkit keep builtin input() behavior. E2E: real PTY with raw escape bytes through line_input, cli_output.prompt, and setup.prompt (arrows + Ctrl+A/E edit correctly); redirected-stdin fallback verified. |
||
|
|
31402f630b |
fix(relay): complete native plugin cutover
Signed-off-by: Alex Fournier <afournier@nvidia.com> |
||
|
|
8afd98ef2a |
refactor(relay): remove legacy observability plugin
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com> |
||
|
|
0a079b946f |
fix(relay): retain legacy observability plugin
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com> |
||
|
|
e8644e05a3 |
refactor(relay): remove legacy observability plugin
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com> |
||
|
|
46c0c6ec42 | fix plugin list provider entries | ||
|
|
d44a295492 |
Inspired by Claude Cowork: security scanning for plugin install/update
Claude Cowork (Aug 6, 2026) added skill & plugin security scanning: third-party skills and plugins are automatically checked for malicious content on upload/edit, returning pass/warn/fail. Hermes already scans hub-installed skills (tools/skills_guard.py), but `hermes plugins install` cloned and activated arbitrary Git repos completely unscanned — and plugins run Python in-process, making them the more dangerous surface. - tools/plugin_guard.py: plugin-adapted scanner reusing the skills_guard pattern engine. Exempts the documented provider-plugin patterns (own requires_env API-key reads, HTTP calls with keys) on code files while keeping true threat signals (foreign credential-store access, reverse shells, destructive/persistence/obfuscation patterns, prompt injection in docs). Plugin-sized structural limits; VCS/venv dirs excluded. - hermes_cli/plugins_cmd.py: scan the temp clone before it is moved into ~/.hermes/plugins/. safe=install, caution=confirm (interactive prompt or --force), dangerous=blocked (--force does NOT override). Re-scan on `hermes plugins update`; a dangerous updated tree is deactivated until the user reviews the findings. Dashboard install path returns structured scan_blocked/scan_findings. - Config gate: plugins.scan_on_install (default true) in config.yaml. - Validated against all 60 bundled plugins: 57 safe, 3 caution (real sudo / curl|sh content in their docs), 0 false-positive blocks. - 15 new tests incl. E2E through _install_plugin_core with real git clones. |
||
|
|
90dacec87e | fix(plugins): report entrypoint capabilities in CLI | ||
|
|
46e20083d8 |
feat(plugins): plugin packs — declarative, shareable plugin sets (#64166)
Adds hermes-pack.yaml: a single YAML file pinning a set of plugins to exact 40-char commit SHAs with optional non-secret plugins.entries config seeds and a declared (not yet installed) skills list. CLI: - hermes plugins pack install <path|https-url> [--force]: mandatory review screen (plugins + refs + declared capabilities), one summary confirmation, then fan-out through the existing pinned install path. Per-plugin capability consent rides the standard #64228 flow — a pack never bulk-grants. Partial failures reported per plugin; non-zero exit when any fail. Interactive only (no --yes in v1). - hermes plugins pack export [--enabled-only] [--name]: pack YAML on stdout from install metadata (repo + exact SHA); local-only plugins become warning comments; secrets/capability grants stripped. - hermes plugins pack show <path|url>: dry-run view. Supply chain: refs must be exact 40-char SHAs (tags/branches rejected naming the entry, same rule as the community index); config seeds reject secret-shaped, capability, and allow_* keys; bare names resolve through the community index; https-only URL fetch with size cap. Tests: tests/hermes_cli/test_plugin_packs.py (36) — parse/validate, SHA enforcement, mocked install fan-out, consent-per-plugin assertion, export round-trip + sanitization, partial-failure exit code, parser wiring. No live network. Docs: user-guide plugins.md packs section (notes packs build on the manifest v2 fields per #64165) + cli-commands.md rows. Closes #64166 |
||
|
|
eb214ad148 |
Inspired by Factory Droid: plugin updates autostash local changes
Factory Droid v0.188.0 (Aug 4, 2026): 'Updating a plugin marketplace now succeeds when its checkout has local changes instead of failing.' Hermes had the same failure: users who tweak an installed plugin in place (config constants, small patches) hit 'Your local changes ... would be overwritten by merge' on every 'hermes plugins update <name>' and the dashboard update path — the plugin becomes permanently un-updatable until they hand-run git. _git_pull_plugin_dir() now autostashes before the pull and re-applies after, reusing the ref-compared stash discipline hermes update already uses for the main checkout (PR #70161): - clean tree → identical single pull, no behavior change - dirty tree → stash push --include-untracked (ref-compared so 'nothing saved' aborts before touching the checkout), pull, stash apply - clean re-apply → drop the stash entry, note in output - conflicted re-apply → reset to the updated revision (plugin stays importable, no conflict markers on disk) and KEEP the stash entry with recovery instructions - failed pull with a stash → restore the user's edits before reporting Covers both callers: cmd_update (CLI) and dashboard_update_user_plugin. Real-git E2E tests for all four paths + sabotage-verified (tests fail on the old single-pull implementation). |
||
|
|
17030939db |
feat(plugins): inter-plugin event bus with declared emits/listens
Give plugins a first-class, namespaced pub/sub event bus so plugin↔plugin interaction is a declared, testable contract instead of ad-hoc imports. Closes #64164 (sub-issue 03/14 of the plugin-interface expansion epic #64182). Additive-only: when no plugin calls emit/subscribe, behavior is unchanged. Interface (on PluginContext): - `ctx.emit(event, payload=None) -> int` publishes to subscribers and returns the count invoked. The namespace is FORCED to the plugin's own registry key (`manifest.key or manifest.name`): pass only the bare event name, delivered as `<key>:<event>`. Fail-closed — any name containing `:` (a `hermes:` reserved-core prefix, a foreign `other:` namespace, or an own-colon'd name) is rejected with a ValueError + logged warning naming the plugin. - `ctx.subscribe(full_event, callback)` registers an ordered listener for a fully-qualified `<plugin>:<event>`. Subscribing is unrestricted (any plugin may listen to any published event); only emitting is namespace-gated. Delivery mirrors invoke_hook: registration-order iteration, per-callback try/except isolation (one raising subscriber never breaks delivery to the rest), payload passed as `cb(**payload)`. A per-thread depth counter caps re-entrant emits at 8 — mutually-emitting plugins terminate cleanly with one logged warning, never an infinite loop or RecursionError. Discoverability: optional advisory `emits:`/`listens:` manifest fields (no manifest-v2 dependency; not enforced) are parsed and surfaced by a new `hermes plugins show <name>` (alias `info`) command. `get_plugin_subscriptions()` module accessor mirrors `get_plugin_auxiliary_tasks()`. Tests (tests/hermes_cli/test_plugin_event_bus.py, 22): two-plugin delivery + listener count; forced namespace (delivered as `b:ping`); spoof rejection (parametrized `hermes:x` / foreign / own-colon'd / `:x` / `x:` / empty) with no delivery; name-fallback when key empty; per-callback isolation; recursion cap termination + warning; manifest emits/listens parsed (present/absent/from yaml); module accessor; `plugins show` output. `pytest test_plugin_event_bus.py test_plugin_auxiliary_tasks.py` → 37 passed. I independently re-verified the namespace rejection and recursion-cap termination outside the test suite. Note: the reserved-name gate rejects any `:`-containing input rather than a bare-name denylist — a bare `core_event` is allowed and delivered under the plugin's own namespace. Say the word if a reserved bare-name list is wanted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013b1XyXitAxV7phGmKWigJX |
||
|
|
2e0183169c |
feat(plugins): community plugin index + hermes plugins search (#64181)
Static machine-readable community plugin index with fuzzy search and index-resolved installs, mirroring the Skills Hub catalog pattern (fetch → HERMES_HOME/cache with 24h TTL → bundled seed fallback). - hermes_cli/plugin_index.py: index fetch/cache/seed chain, fuzzy search (name/description/tags/author + typo tolerance), capability filter, bare-name resolution. Canonical URL overridable via plugins.index_url config key. - hermes_cli/data/plugin_index.json: bundled seed (offline fallback + format reference) with 5 real ecosystem plugins, each pinned to an exact commit SHA. - hermes plugins search [term] [--json] [--capability] [--refresh]: Rich table or JSON output, offline-safe, with an explicit 'indexed ≠ audited' footer. - hermes plugins install <name>: bare names (no slash, no URL scheme) resolve through the index to owner/repo[/subdir] @ pinned ref and hand off to the existing install path (ref wired through the #82029 exact-ref support). Ambiguous names list candidates and exit; explicit owner/repo and Git URL installs are untouched, and an explicit --ref always beats the index pin. - Docs: discovery section in user-guide plugins.md (format, submission workflow via PR to hermes-plugin-index, security framing) and reference/cli-commands.md rows. - Tests: tests/hermes_cli/test_plugin_index_search.py (38 tests, no live network) covering parsing, search, remote→cache→seed fallback, TTL, install resolution/ambiguity/passthrough, and --json output. |
||
|
|
bd6dcd4bd5 |
feat(plugins): manifest v2 — schema version, api_version, inter-plugin deps, pip-dependency declaration seam, config schema (#64165)
Additive plugin.yaml v2 fields (all optional; v1 manifests unchanged forever):
- manifest_version: manifest FILE-FORMAT version (absent = 1). Deliberately
split from api_version per the round-2 design correction. Newer-than-
supported versions load with a warning, unknown fields ignored.
- api_version: runtime plugin API generation the plugin targets (integer).
- requires_plugins: advisory inter-plugin deps ({id, version_range?}).
Missing dep = warn + still load (ctx.has_plugin() runtime probe added).
Load ORDER is dependency-respecting: graphlib topological sort, stable
alphabetical tiebreak; cycles warn and fall back to alphabetical.
- python_dependencies: declared pip requirements — VALIDATED AND SURFACED
ONLY (loader warning + install-time printout + doctor checks with a pip
install hint). Never auto-installed: the isolation design for the install
seam (#15220) is an explicitly deferred follow-up per the round-2 review.
- config_schema: JSON-schema-ish description of plugins.entries.<id>.settings
keys; validated at load, mismatches are actionable warnings naming the key
and expected type — never load failures.
- Formalized metadata: license, homepage, tags.
- Unknown manifest fields warn-don't-fail (debug-level for v1 manifests).
- hermes plugins doctor gains v2 checks: future manifest_version, invalid
api_version, dep declarations, unpinned/missing python_dependencies,
unknown config_schema types.
- Docs: manifest v2 reference table in the developer-guide plugins index,
including the explicit pip-seam isolation deferral and the note that
#64166 packs build on these fields.
- Tests: tests/hermes_cli/test_plugin_manifest_v2.py (19 tests) covering v1
regression, v2 parse, unknown-field warn, dep order, cycle fallback,
config_schema warnings, and the surfaced-not-installed pip seam.
|
||
|
|
b088535c78 |
feat(plugins): capability declarations + install/update consent flow (#64228)
Unify the scattered per-plugin trust gates into one declared, diffable
capability model with an install/update-time consent flow. Consent +
audit over host API surfaces — explicitly NOT a sandbox.
New module hermes_cli/plugin_capabilities.py:
- Canonical CAPABILITY_REGISTRY mapping each capability id 1:1 to an
EXISTING enforcing gate (no capability minted without a surface):
tools.override -> allow_tool_override
llm.provider_override -> llm.allow_provider_override
llm.model_override -> llm.allow_model_override
llm.agent_id_override -> llm.allow_agent_id_override
llm.profile_override -> llm.allow_profile_override
llm.task_override -> llm.allow_task_override
- plugin_capability_granted(plugin_id, capability): canonical check —
granted set OR deprecated legacy allow_* key; fail closed on unknown
ids and any unreadable/corrupt consent state; emits checked_by audit
log lines on every decision.
- record_consent() persists plugins.entries.<id>.granted_capabilities +
capabilities_consent {hash, granted_at} and mirrors grants into the
legacy keys so existing enforcement sites keep working unchanged.
- capability_set_hash / pending_capabilities / declared_set_changed
power the update-time re-consent diff.
Wiring:
- plugin.yaml manifest field `capabilities:` parsed into
PluginManifest.capabilities (unknown ids dropped with a warning).
- hermes plugins install: consent screen (one Y/n) when the manifest
declares capabilities; non-interactive installs proceed with
capabilities ungranted (fail closed).
- hermes plugins update: when the new version declares capabilities the
granted set lacks (hash diff), the additions are surfaced and require
re-consent — an update can never silently widen access.
- hermes plugins enable: consent screen replaces the standalone
tool-override prompt for capability-declaring plugins.
- hermes plugins capabilities [<id>]: declared vs granted per plugin,
flags grants held via deprecated legacy keys.
- PluginContext.has_capability() probing API so plugins degrade
gracefully; _tool_override_allowed migrated to the canonical
plugin_capability_granted path (reference migration; legacy
allow_tool_override still honored).
Tests: tests/hermes_cli/test_plugin_capabilities.py (38 tests) —
declaration parsing, consent grant/persist, update re-consent on added
capability, fail-closed on missing/corrupt state, legacy-gate backward
compat, consent CLI flow (grant / decline / non-interactive).
Docs: user-guide plugins.md consent section (with explicit not-a-sandbox
warning) + developer-guide plugin authoring capability note.
Salvages the intent of PR #37976 (@coygeek — require renewed review
before plugin updates), scoped to capability diffs.
Part of #64182.
|
||
|
|
6601330e0a | feat(plugins): install exact commit refs | ||
|
|
7a5062fbcd |
feat(plugins): add runtime-backed plugin Doctor
Validate plugin manifests, imports, hook signatures, and runtime registrations through the real plugin loader in an isolated temporary home. |
||
|
|
ed9eee5dc9 |
fix(gateway): report bundled auto-loading plugins as enabled
Bundled backends/platforms/providers load without a plugins.enabled
entry ('must just work'), but plugins.manage reported them 'not
enabled' — clients rendered running plugins with an OFF switch.
Surface the truthful default; explicit disable still wins.
|
||
|
|
a60b492e07 |
feat(gateway): key-addressed plugins.manage rows + portable MCP toolset fold-in
plugins.manage list rows now carry the canonical registry key and a portable flag (Agent Plugins v1 plugin.json packages), and toggles address the key — bare names collide across category dirs (image_gen/fal vs video_gen/fal), so name-addressed toggles flipped both. Portable packages' in-memory MCP servers also fold into enabled_mcp_server_names(); without that their tools registered with the MCP runtime but never reached the model's schema. |
||
|
|
ca78c6d7a6 | feat(plugins): load portable agent components | ||
|
|
58708c7066 |
fix(git): never block internal git calls on credential prompts
Port from openai/codex#34540 / #34612 ("detach non-interactive subprocesses from stdin"): internal git invocations that run with nobody attached — MCP catalog installs, plugin install/update, profile distribution staging, worktree base fetches, and the desktop review pane's git/gh backend — could hang on a credential prompt when a remote is private, misconfigured, or requires auth. git prompts on the inherited terminal (or via Git Credential Manager on Windows), so the operation silently waits until its timeout, or forever at sites without one (mcp_catalog clones have no timeout at all and inherit the parent terminal). - Add noninteractive_git_env() to hermes_cli/_subprocess_compat.py: GIT_TERMINAL_PROMPT=0 + GCM_INTERACTIVE=Never on a copy of the environment; GIT_ASKPASS/SSH_ASKPASS deliberately preserved so working non-interactive auth still succeeds. - Wire it + stdin=DEVNULL into: mcp_catalog._do_git_install (clone/ checkout), plugins_cmd (clone + pull), profile_distribution._git_clone, web_git._git/_gh (gh also gets GH_PROMPT_DISABLED=1), and cli.py's worktree base fetch helper. - Tests: env contract, a real-git E2E against a local 401 Basic-auth HTTP server proving fail-fast ("terminal prompts disabled") instead of a hang, and per-call-site plumbing assertions. Sabotage-verified: removing the env from web_git._git fails the site test. |
||
|
|
ea2499bcfe |
fix(update): close the stale-bytecode class with a launch-time checkout-fingerprint sweep
The stale-.pyc bug class (#6207, #60242, live WhatsApp report: gateway ImportError 'cannot import name parse_model_flags_detailed' after /update) has one shared shape: the checkout's .py files change while __pycache__ retains bytecode from the previous revision, and a later process trusts the stale .pyc. Update-time clears can never fully close this class: 'hermes update' always executes the PRE-pull updater code, so hardening added to it takes effect one update late — and a manual 'git pull' never runs the updater at all. Class fix: - Launch-time guard in main(): compare the checkout fingerprint (cheap file reads via _read_git_revision_fingerprint, no git subprocess) against a .bytecode-fingerprint stamp; sweep __pycache__ once when they diverge. Covers manual pulls, old updaters, ZIP restores — every entry point (CLI, gateway service, desktop backend) passes through main(). - Record the stamp at all three update-time clear sites (git path pre- install, git path post-install, ZIP path) so a normal update never triggers a redundant launch sweep. - Sibling site: 'hermes plugins update' + dashboard plugin update now clear __pycache__ under the plugin dir after git pull (plugin trees live outside the repo guard). E2E validated: reproduced the stale-pyc shadowing (same-size same-mtime source swap → old symbol wins in a fresh process), confirmed the launch sweep restores the new symbol, no-ops when the checkout is unchanged, and re-sweeps on the next pull. Sabotage-tested: reverting the sweep fails 4 of the new tests. |
||
|
|
c89481db5e |
fix: add explicit UTF-8 encoding to all subprocess text=True calls (#53428)
On Windows with Chinese locale (GBK), subprocess.run(text=True) without explicit encoding causes UnicodeDecodeError crashes. This fix adds encoding='utf-8', errors='replace' to all subprocess.run() and subprocess.Popen() calls that use text=True across 76 non-test Python files. Fixes #53428 (master tracker for Windows GBK locale crash). Note: credential_pool.py and electron changes excluded per reviewer request — those will be submitted as separate focused PRs. |
||
|
|
d4c3f98140 |
fix(dashboard): unblock basic auth plugin when setting password interactively (#54489) (#63786)
* fix(dashboard): unblock basic auth plugin during interactive password setup When the dashboard prompts for username/password on a non-loopback bind, also remove the bundled basic provider from plugins.disabled so discover_plugins(force=True) can register it (#54489). * test(dashboard): cover basic auth plugin blocked by plugins.disabled Regression harness for #54489: credentials in config are not enough when the bundled basic provider is on the deny-list. |
||
|
|
94cdd56b82 |
feat(plugins): surface entry-point plugins in hermes plugins list
Salvaged from #40346; re-verified on main, tightened, tested. Co-authored-by: tjboudreaux <tjboudreaux@users.noreply.github.com> |
||
|
|
27f74b26c5 |
fix(web): correct 'disabled plugin' diagnosis for web backends (#59573)
When a bundled web provider (firecrawl, tavily, exa, ...) is listed in
plugins.disabled, its provider never registers and the web_search/
web_extract dispatchers emitted the misleading "No web extract provider
configured. Set web.extract_backend to ..." — even though the backend was
configured correctly. The real fix is to re-enable the plugin.
- web_tools.py + web_search_registry.py: when the configured backend names
a disabled bundled web plugin, both dispatchers now point the user at the
actual cause (re-enable the plugin) instead of a wrong config hint.
- plugins_cmd.py cmd_enable: enabling by canonical key now also clears the
manifest-name alias (web-firecrawl) from plugins.disabled, so the
suggested command actually re-enables the plugin ('explicit disable wins'
matches on the name too).
- plugins_cmd.py cmd_toggle / _run_composite_ui / _run_composite_fallback:
the interactive 'hermes plugins' menu now persists the canonical key
(web/firecrawl), never the bare manifest name — the drift that put the
offending entry in plugins.disabled in the first place.
Follow-up to #59518 (which fixed web credential resolution, a different
cause). Fixes the disabled-plugin symptom reported after that PR.
|
||
|
|
bff61f558f |
feat(plugins): enable-time consent prompt for tool_override grant
Builds on memosr's sink-level opt-in gate (#29249). Enabling a non-bundled plugin now surfaces the privileged allow_tool_override decision at `hermes plugins enable` time instead of leaving the operator to discover the config key after a runtime rejection. - `hermes plugins enable <name>` prompts for non-bundled plugins: 'Allow this plugin to replace built-in tools?' Default is deny (blank Enter / non-interactive stdin / EOF all fail closed). - --allow-tool-override / --no-allow-tool-override flags for non-interactive and scripted use (and a future desktop checkbox). - Bundled plugins are trusted: never prompted, no entry written. - Writes plugins.entries.<key>.allow_tool_override, the same key the sink gate reads (manifest.key == discovery key), so consent and enforcement compose end to end. |
||
|
|
08890d77e6 |
fix(plugins): normalize browser-pasted GitHub repo URLs (#33539)
Accept common GitHub web URLs in `hermes plugins install` by normalizing repository views back to cloneable `.git` URLs, with focused parser coverage. |
||
|
|
f6f573ebaa |
feat(plugins): install from a subdirectory within a repo (#42963)
Support installing a plugin that lives in a subdirectory of a larger repo (docs/tests at root, plugin in a subdir) without forcing a dedicated single-plugin repo. Identifier syntax: owner/repo/path/to/plugin (shorthand + subpath) <url>.git/path/to/plugin (.git boundary on GitHub-style URLs) <url>#path/to/plugin (explicit fragment, any scheme) _resolve_git_url now returns (git_url, subdir); _install_plugin_core reads the manifest from and moves only the subdir, so root-level docs and tests no longer leak into ~/.hermes/plugins. _resolve_subdir_within guards against path traversal, missing dirs, and non-directories. Both the CLI (hermes plugins install) and the dashboard install endpoint inherit this for free since they share _install_plugin_core. Dashboard install hint + placeholder updated to advertise the subdir syntax. Co-authored-by: Austin Pickett <pickett.austin@gmail.com> |
||
|
|
2b89afec79 |
fix(plugins): alias-normalize enable/disable for nested category plugins (follow-up to #41076)
#41076 makes `hermes plugins list` discover nested category plugins (e.g. observability/nemo_relay). This adds the missing enable/disable mutation path so those plugins can actually be toggled, and fixes two incomplete-update breakages on the #41076 base. Before: `hermes plugins enable nemo_relay` -> "Plugin 'nemo_relay' is not installed or bundled." (exit 1), because cmd_enable/cmd_disable went through _plugin_exists(), which only checked top-level plugins/<name>/. Changes: - Add _resolve_plugin_key(): resolve a bare manifest/leaf name OR a full path-derived key (observability/nemo_relay) to the canonical key the runtime loader gates on, reusing #41076's _discover_all_plugins(). A bare leaf name ambiguous across two categories resolves to None rather than silently picking one. - cmd_enable/cmd_disable resolve first, persist the canonical key, and drop any stale legacy bare-name alias so the enabled/disabled lists can't drift into a contradictory state. _plugin_exists delegates to the same resolver. - Fix #41076 base breakages: _discover_all_plugins now returns 6-tuples, but web_server._merged_plugins_hub() still unpacked 5 (ValueError on the dashboard plugins-hub endpoint) and several test_plugins_cmd_list.py fixtures were still 5-tuples. Both updated; the hub status check is now key-aware. Verified e2e on the real CLI + runtime loader (isolated HERMES_HOME): `hermes plugins enable nemo_relay` writes observability/nemo_relay to config.yaml and the loader then loads it (enabled=True, error=None); a stale bare-name alias is cleared on disable; the dashboard _merged_plugins_hub() runs without crashing. Adds resolution + enable/disable tests; full tests/hermes_cli/test_plugins_cmd* + web_server plugin tests green. Follow-up to #41076 (#41066). Branched from that PR's head. |
||
|
|
ccacfdbd6d |
fix(plugins): discover nested category plugins in 'plugins list' (issue #41066)
_discover_all_plugins() previously did a flat iterdir() scan, missing all category-namespaced plugins (web/*, image_gen/*, browser/*, video_gen/*). Now recurses up to 2 levels deep, matching PluginManager._scan_directory_level(). Also fixes _plugin_status() to check both manifest name AND path-derived key against enabled/disabled sets, so category plugins like 'web/tavily' show correct status when enabled via config. |
||
|
|
51c68d4ab1 |
Add Hermes desktop app (#20059)
* feat: better composer etc * docs: add desktop and dashboard run instructions * fix(desktop): address security scan findings * fix(dashboard): resolve @nous-research/ui path under npm workspaces The sync-assets prebuild step shelled out to 'cp -r node_modules/@nous-research/ui/dist/fonts ...' with a path relative to apps/dashboard/. That works only when the dep is installed locally in the dashboard workspace, but 'npm install' at the repo root (the documented setup — see apps/desktop/README.md) hoists shared deps to the root node_modules under npm workspaces. The relative cp then fails with 'No such file or directory', sync-assets exits 1, the Vite build aborts, and 'hermes dashboard' surfaces a generic 'Web UI build failed' message. Replace the shell one-liner with scripts/sync-assets.cjs, which walks up from the dashboard directory looking for node_modules/ @nous-research/ui — working in both the hoisted (workspaces) and co-located (standalone) layouts. Also guards against a missing dist/fonts or dist/assets with a clearer error pointing at a rebuild of the UI package rather than silently copying nothing. * feat(desktop): support connecting to a remote Hermes backend Add HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN env vars that, when set, short-circuit the local-child spawn in startHermes() and connect the Electron renderer to an already- running 'hermes dashboard' server reachable over the network. Motivating use case: WSL2 users who want to run the Hermes core (agent loop, tools, filesystem access) inside their WSL distribution while rendering the Electron GUI on native Windows. Before this change, the desktop app always spawned a local Python child on the same host as the renderer, which doesn't cross the WSL/Windows boundary. The remote path reuses waitForHermes() as a liveness probe (/api/status is in the backend's public endpoint allowlist), so the connection is only returned once the backend is actually ready. WebSocket URL derivation picks ws:// or wss:// based on the input scheme. URL validation rejects non-http(s) schemes and requires both env vars together to avoid a half-configured connection that would silently fall through to the spawn path. No behaviour change when the env vars are unset — the default local-spawn flow is untouched. Typical usage: # in WSL2 hermes dashboard --tui --no-open --host 0.0.0.0 --port 9119 --insecure # on Windows set HERMES_DESKTOP_REMOTE_URL=http://localhost:9119 set HERMES_DESKTOP_REMOTE_TOKEN=<session token> set HERMES_DESKTOP_IGNORE_EXISTING=1 (launch Hermes desktop) * ci(desktop): automate desktop releases Add GitHub Actions release channels for signed desktop installers and document the stable/nightly download paths. * feat: file tabs * refactor(desktop): tighten right-rail tab close API Promote closeRightRailTab/closeActiveRightRailTab as the single public entry point. Drops the activeTabRef + handleCloseDocument indirection in ChatPreviewRail, the unused $rightRailHasContent atom, and the legacy dismissFilePreviewTarget alias. -70 LOC. * feat(desktop): polish composer pill toward reference look Solid foreground-on-background send/voice-conversation circle (black-on-white in light, white-on-black in dark) anchors the right edge as the primary CTA instead of the orange theme primary. Bumps the primary control to 2.125rem so it visually outranks the ghost mic/plus controls. Opens up the surface padding (0.625rem x / 0.5rem y) so the input row breathes around its controls, and nudges the corner radius from 20 to 24px for a slightly pill-ier silhouette. LiquidGlass distortion is preserved. * feat(desktop): add startup and onboarding flow Add phase-based desktop boot progress, fresh-install sandbox testing, and first-run provider credential onboarding so packaged installs can start cleanly without manual settings detours. * fix(desktop): gate prompts on provider setup Show the desktop provider onboarding flow before prompt submission when no inference provider is configured, preventing fresh installs from falling through to backend credential errors. * fix(desktop): surface provider onboarding from session warnings Propagate credential warnings through session runtime info and open desktop onboarding whenever a session reports no usable provider, so unconfigured installs cannot fall through to prompt errors. * fix(desktop): route gateway provider errors to onboarding The "No inference provider configured" auth error reaches the renderer through gateway error events, not the prompt.submit promise; the previous patch only caught the latter, so the error toast still surfaced and onboarding never opened. Also strip credential-shaped env vars from the test:desktop:fresh sandbox so the packaged backend can't see provider keys leaking from the launching shell. * fix(desktop): use strict runtime check to drive onboarding setup.status returned True whenever any provider auth state was discoverable, including indirect fallbacks like a gh-CLI Copilot token. That made desktop think the user was set up while the agent's actual resolve_runtime_provider call still raised AuthError, leaving the user with a useless toast and no onboarding. Add a setup.runtime_check gateway method that runs the same resolver the agent uses on session creation, and switch the desktop onboarding overlay and prompt precheck to use it. * feat(desktop): OAuth-first onboarding using existing dashboard provider API Replace the engineer-flavored API key form with a Sign-in-first onboarding overlay that uses the dashboard's existing /api/providers/oauth catalog and PKCE/device-code endpoints (Anthropic, Nous, OpenAI Codex, etc.). API key entry is now a fallback tab with friendly provider names instead of env var prefixes, and the loud raw resolver error is gone in favor of a one-line welcome message. * fix(desktop): polish onboarding provider list Reorder OAuth providers so Nous Portal is first, give the segmented Sign in / API key control equal column widths, and replace the engineer-flavored backend names like "Anthropic (Claude API)" / "MiniMax (OAuth)" with friendlier in-app titles. External-CLI providers now show a softer subtitle and an external-link icon instead of a chevron. * refactor(desktop): split onboarding overlay into store + view Move the OAuth state machine, runtime check, copy-to-clipboard, and api-key save into store/onboarding.ts (matching the boot.ts pattern), leaving the overlay as a presentation layer that subscribes via useStore. Tabs are now table-driven, child panels read flow from the store instead of prop-drilling, and the polling/PKCE/error/success branches share a small Status atom. * fix(desktop): external CLI providers + center mode tabs External-CLI providers (Claude Code, Qwen Code) now open an in-overlay panel with the CLI command, copy button, and an "I've signed in" recheck instead of firing an invisible toast. Center the Sign in / API key tab control so it sits under the heading instead of hugging the left edge. * fix(desktop): drop onboarding tabs for an inline link, group device-code waiting state Replace the Sign in / API key tab pair with an "I have an API key" footer link under the OAuth provider list, with a "Back to sign in" affordance inside the API key form. Group the device-code "Waiting for you to authorize..." status next to the Cancel button so the alignment matches the action. * refactor(desktop): tighten onboarding store + overlay Drop the dead isOnboardingBusy/BUSY set, factor the catch-fallback dance into safeReq, and share a single reloadAndConnect helper between PKCE submit, device-code success, external recheck, and api-key save. In the overlay, extract Step / CodeBlock / FlowFooter / CancelBtn / DocsLink atoms so the four sign-in panels share the same chrome instead of repeating it inline. Net effect: fewer literal divs, one place to touch the spacing, and the code-block + footer rows are reusable across future flows. * fix(desktop): mount onboarding from frame 1 to kill the FOUT Default onboarding.configured to null (unknown until the runtime check resolves) and have the onboarding overlay render whenever it's not yet confirmed true. The boot overlay now yields to it, so the very first paint is the Welcome card with a "While we get you set up..." progress strip instead of a flash of the chat shell between boot dismiss and onboarding mount. The picker swaps in cleanly once the gateway opens and the runtime check confirms the user is not configured. Already-configured users see the same prep card briefly while their existing runtime warms up, then the overlay dismisses without touching the chat shell. * fix(desktop): top-align empty sessions placeholder The "Start a chat to build your history." empty state used a min-h-35 grid place-items-center container, which floated the text in a tall dead zone. Render it as a flat paragraph that sits right under the section header like the empty pinned state does. * refactor(desktop): drop dead boot overlay Onboarding overlay subsumes the boot card now that it mounts from frame 1 and renders boot progress inline. The standalone DesktopBootOverlay is unreachable in every flow (yields whenever onboarding has not confirmed configured, dismisses once it has). * fix(desktop): hide pinned/recents sections until first session A fresh sidebar showed the Pinned and Recent chats headers with floating empty-state copy underneath. Drop both sections (and the now-orphan SidebarEmptySessionState) when there are no sessions yet — they reappear after the first chat. Skeletons during initial load are unchanged. * feat(gui): route embedded TUI through dashboard gateway (#21979) Inject HERMES_TUI_GATEWAY_URL into dashboard PTY sessions so embedded ui-tui instances attach to the in-process websocket gateway, with coverage for the new env wiring. * Add desktop remote gateway settings Make the desktop gateway connection configurable from settings so local remains the default while remote backends can be saved, tested, and applied without environment variables. * feat(gui): first-class Messaging page + gateway menu redesign - Add Messaging page to the desktop app with per-platform setup, status, and inline guidance. Catalog derives from gateway.config Platform enum + plugin registry, so every messaging adapter the CLI supports (Telegram, Discord, Slack, Mattermost, Matrix, WhatsApp, Signal, BlueBubbles, Home Assistant, Email, SMS, DingTalk, Feishu, WeCom, Weixin, QQ, Yuanbao, API server, Webhooks, plugins) shows up without per-platform code. - New REST endpoints: GET /api/messaging/platforms, PUT and POST /test on the same path. Secrets go through the existing .env pipeline; enable/disable writes config.yaml. - Replace gateway statusbar dropdown with a richer panel: status row, icon-only restart + system-panel actions, recent activity (with timestamps trimmed in display, full text on hover), platform list. - Auto-poll the messaging page every 6s (paused when hidden) so status updates without a manual check. - Drop Settings / Command Center from the sidebar nav (still reachable via shortcuts and the titlebar cog). - Flatten top corners on Messaging/Skills/Artifacts/Chat panes. - Share new StatusDot component across messaging + gateway menu. - Fix gateway/config.py so an explicit platforms.<name>.enabled=false in config.yaml is honored when env tokens are present. - pb-9 on the chat content area for breathing room above the composer. * Potential fix for pull request finding 'CodeQL / Clear-text logging of sensitive information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * pin electron version * hide application menu on non-mac systems * interpret compactPreview for non-string vlaues as JSON or an empty string * fix(desktop): keep composer contenteditable mounted across stacked toggle The composer rendered {input} inside two different parent fragments depending on `stacked`. When auto-expand flipped `stacked` (e.g. the moment typed text wrapped past two lines), React reconciled the two branches as different positions and unmounted/remounted the contenteditable. The fresh mount started empty, so any in-flight characters — most reliably reproduced by holding a key — were lost. Replace the conditional with a single CSS Grid whose template-areas swap on `stacked`. The three children (menu, input, controls) keep stable identities across the toggle; only their grid placement changes, which the browser handles without React tearing down the editor. * refactor(desktop): align install layout with install.ps1 / install.sh Make the desktop app's runtime layout match what scripts/install.ps1 and scripts/install.sh produce, so a desktop-only user and a CLI-only user end up with the same files in the same places and can share one install. Layout - ACTIVE_HERMES_ROOT = HERMES_HOME/hermes-agent (was: process.resourcesPath/hermes-agent, read-only) - VENV_ROOT = HERMES_HOME/hermes-agent/venv (was: userData/hermes-runtime) - desktop.log = HERMES_HOME/logs/desktop.log (was: userData/desktop.log) - HERMES_HOME default: %LOCALAPPDATA%\hermes on Windows, ~/.hermes elsewhere The packaged .app/.exe still ships a read-only payload at process.resourcesPath/hermes-agent (FACTORY_HERMES_ROOT). On first launch or after an installer-driven upgrade we sync factory -> active, then provision the venv and run pip install -e . against the active root. Key behaviors - Pin HERMES_HOME in the spawned Python's env so get_hermes_home() resolves to the same path resolveHermesHome() picked. Without this, Python falls back to ~/.hermes on every platform - fine on mac/linux, a split-state bug on Windows where our default is %LOCALAPPDATA%\hermes. - Detect developer installs by .git presence at ACTIVE; never overwrite a user's checkout via factory sync. - Marker at ACTIVE/.hermes-desktop-runtime.json (schema v4) tracks pyproject hash + factory version + runtime schema version. depsFresh fast-paths when nothing changed. - Dev (npm run dev) prefers SOURCE_REPO_ROOT over ACTIVE so devs run their local edits, not whatever's under HERMES_HOME. - Better error messages distinguish "no payload" from "no Python". - Preserve a legacy ~/.hermes on Windows when no %LOCALAPPDATA%\hermes exists, so users with prior pip/manual installs aren't orphaned. pyproject.toml - Promote fastapi, uvicorn[standard], ptyprocess (non-Windows), and pywinpty (Windows) to main dependencies. The dashboard backend (hermes dashboard) needs them at runtime; the previous lazy-import fallback was a footgun for fresh installs. - Empty the [pty] optional-extra; kept as a no-op back-compat alias for any existing pip install hermes-agent[pty] invocations. Drops the hardcoded BUNDLED_RUNTIME_REQUIREMENTS list in main.cjs - the desktop now installs whatever pyproject.toml says, single source of truth. Files - apps/desktop/electron/main.cjs: runtime layout, HERMES_HOME pin, factory->active sync, marker v4 - apps/desktop/scripts/test-desktop.mjs: track new venv location - apps/desktop/README.md: new Setup, Runtime Bootstrap, and Debugging sections - pyproject.toml: fastapi/uvicorn/pty backends in main dependencies; [pty] extra emptied Tested locally on Windows: npm run dev boots cleanly, sessions land at the new location, type-check + lint + test:desktop:platforms all pass. Verified end-to-end on a fresh Win11 VM via dist:win installer. Known gaps (filed as follow-ups, not in this PR): - Skills not seeded on packaged installs (sync_skills only runs in cmd_chat, not cmd_dashboard). Need to move to shared pre-dispatch. - Git Bash not bundled or detected; agent's terminal tool errors out with a useful message but desktop bootstrapper should pre-flight it. - install.ps1 / install.sh should be decomposed into composable phase libraries so the desktop bootstrapper can reuse them as a single source of truth across all install surfaces. * feat(desktop): theme polish, prose chat typography, composer chrome - DS tokens/midground, Backdrop, scoped scrollbars, typography plugin + prose - Composer liquid/radius utilities, thread font parity, tool/thinking cues - File tree label scale, preview flex, thread retry loading + streaming tests * feat(desktop): NSIS prereq detection page + auto-install via winget The packaged Windows installer now detects Python 3.11+ and Git for Windows at install time and offers to install missing prereqs via winget. Mirrors the prereq logic scripts/install.ps1 already runs for CLI installs, so desktop installer users get the same out-of-the-box experience as install.ps1 users. Why - Hermes' terminal tool calls bash.exe directly (tools/environments/ local.py); on Windows that's Git Bash from Git for Windows. Without it, the agent fails on the first terminal() call. - Hermes' Python runtime needs 3.11+. Without it, the desktop bootstrapper errors out at venv creation. - Both gaps surfaced on a fresh Windows 11 VM smoke test: VM had Python pre-installed but no Git, so the agent's first terminal call failed with "Git Bash isn't installed." - install.ps1 has had Install-Git + Install-Uv functions for ages. The desktop installer was the asymmetric outlier. How — NSIS prereq page - New file: apps/desktop/installer/prereq-check.nsh (plugged into electron-builder via build.nsis.include) - Real Wizard page using nsDialogs, inserted via customPageAfterChangeDir hook (between the Directory page and InstFiles). - Group boxes for Python and Git, each showing detection status. - Pre-checked install checkboxes when winget is available. - Auto-skips silently if both prereqs are already installed. - Falls back to manual download URLs when winget itself is missing. - Detection: - Python: probes `py -3.11`/`-3.12`/`-3.13`/`-3.14` via the Python launcher. Microsoft Store "Python stub" (no py.exe) is correctly classified as not-installed. - Git: `where git`. - winget: `where winget` (Win10 1809+ / Win11 with App Installer). - Install execution (in customInstall macro): - Python: nsExec::ExecToLog with `--scope user --silent`. Per-user install, no UAC prompt, output streams to install log. - Git: ExecShellWait via Windows ShellExecute. Critical because Git always installs per-machine and triggers UAC; ShellExecute preserves the foreground focus chain across non-elevated → elevated process spawns, so UAC actually comes to the foreground. nsExec::ExecToLog breaks the chain because winget runs hidden. - Both pass `--disable-interactivity --accept-package-agreements --accept-source-agreements` to suppress winget's own dialogs. - Verification: probes Git's standard install locations via FileExists rather than `where git`. NSIS's process inherits PATH at startup, so a freshly-installed Git won't be visible to `where` until restart. - Silent installs (/S) skip the prompts; managed deploys handle prereqs out-of-band via Group Policy / Intune. How — Electron-side safety net - New findGitBash() in main.cjs, parallel to findSystemPython(). Probes the same locations as tools/environments/local.py:_find_bash() so a positive result here means the agent's terminal tool will work. - ensureRuntime now throws a clear, actionable error on Windows when Git Bash isn't found, matching the existing "Python 3.11+ is required" error path. - Catches users the NSIS page doesn't: .msi installer users (NSIS prereq page doesn't run for MSI), `npm run dev` users, manual installers, anyone who unchecked the install boxes on the NSIS prereq page. - All gated on `IS_WINDOWS`; macOS / Linux unaffected. NSIS build issue (resolved) - electron-builder defaults to `-WX` (warnings as errors). NSIS optimizer emits "warning 6010: function not referenced" for our page functions because Page custom directives don't count as references in its static-analysis pass. The functions ARE called at runtime when NSIS invokes the page; the optimizer just can't see it statically. - Set `build.nsis.warningsAsErrors=false` in package.json so this spurious warning doesn't fail the build. (Documented option from electron-builder's nsisOptions.) Out of scope (filed for future work) - MSI prereq detection: Windows Installer custom actions are a different mechanism. Enterprise deploys typically handle prereqs via GP/Intune. - Bundle PortableGit + python-build-standalone in extraResources for zero-network installs. ~80MB increase. - Mac / Linux GUI prereq flows (different installer formats; Xcode CLT covers most macOS prereqs already; Linux is per-distro hard). Files - apps/desktop/installer/prereq-check.nsh (new, ~290 lines NSIS) - apps/desktop/package.json (build.nsis.include + warningsAsErrors) - apps/desktop/electron/main.cjs (findGitBash + preflight) - apps/desktop/README.md (Runtime prerequisites section) Cross-platform impact - macOS / Linux builds (dist:mac, dist:mac:dmg, dist:mac:zip): nsis config is ignored entirely; .nsh is dormant. - npm run dev: .nsh dormant; main.cjs preflight gated on IS_WINDOWS. - scripts/install.ps1, scripts/install.sh: no reference to any new files; CLI install paths untouched. - Hermes CLI / dashboard / gateway: no reference; runtime untouched. - All checks: node --check on main.cjs and test-desktop.mjs pass; npm run test:desktop:platforms 4/4 passing; node --test green. Tested - npm run dist:win produces signed .exe and .msi without errors. - Fresh Win11 VM (Python pre-installed, no Git): prereq page renders, Python check shows detected, Git checkbox pre-checked. Click Next → Git installs via winget with UAC prompt in foreground. - After install completes, Hermes launches and the agent's terminal tool can run bash commands. Verified Git Bash is detected at `C:\Program Files\Git\bin\bash.exe` by ensureRuntime's preflight. * feat: theme changes, composer tweaks, in app update ux, finesse * fix(cli): seed bundled skills on dashboard + gateway entrypoints `sync_skills(quiet=True)` was only being called from inside `cmd_chat`, which meant `hermes dashboard` (the desktop GUI's backend) and `hermes gateway` (Telegram/Discord/Slack/etc daemons) never seeded the bundled skill library into ~/.hermes/skills/. This surfaced as "No skills found" in the desktop GUI's skills panel on fresh installs, despite the agent having access to the full bundled library when invoked via `hermes chat`. scripts/install.ps1 worked around it by running skills_sync.py as part of Copy-ConfigTemplates, but that's not part of the desktop installer's bootstrap chain. Fix - Extract the skills-sync block from cmd_chat into a module-level `_sync_bundled_skills_quietly()` helper. - Call the helper from cmd_chat (preserving existing behavior), cmd_dashboard (after the --status/--stop early-return paths and fastapi import check, so we don't run skills_sync on management commands or when deps aren't installed), and cmd_gateway. Why these three entrypoints - cmd_chat: the user's primary CLI entrypoint - cmd_dashboard: the desktop GUI's backend; this is what `hermes dashboard --tui` invokes when the desktop bootstrapper spawns Hermes - cmd_gateway: long-running daemons where the user expects the agent to have full skill access Other entrypoints (cmd_config, cmd_doctor, cmd_login, cmd_status, etc.) are management commands that don't need skill discovery and were never running skills_sync in the first place — leaving them alone. Idempotence - tools/skills_sync.py is manifest-based: skipped skills cost milliseconds. Calling it from multiple entrypoints adds no real cost, and users running `hermes chat` then `hermes dashboard` get two fast no-ops on the second call. Failure handling - Helper wraps skills_sync in try/except. Skills are an enhancement, not a hard dependency — Hermes runs fine with an empty skills/ dir. Files - hermes_cli/main.py: + new helper `_sync_bundled_skills_quietly()` at module level + cmd_chat: replace inline block with helper call + cmd_dashboard: add helper call after fastapi import succeeds + cmd_gateway: add helper call before delegating to gateway_command * feat(desktop): hoisted todo widget, JSON tool summaries, history grouping & timer fixes - Hoist todo to first-class widget (shadcn checkboxes, brand colors, no tool-accordion). Header derives label from active task; non-active rows fade. - Replace raw JSON dumps with structured key/value summaries via formatToolResultSummary; nested error extraction for clearer failures. - Fix loaded-session grouping: stitch interleaved assistant/tool iterations into one bubble instead of orphaned synthetic messages. - Stable tool/thinking timers via keyed registry so unmount/scroll doesn't reset elapsed counts; gate "running" on real live thread state. - Reorganize chat-only assistant-ui components under components/chat/. * fix(desktop): address CodeQL alerts on PR #20059 - settings/helpers.ts: harden setNested against prototype pollution. POLLUTING_PATH_PARTS check is now applied at every assignment site (loop + leaf) and uses Object.defineProperty so CodeQL can see the guard inline rather than via a helper function call. - lib/markdown-preprocess.ts: rebuild the dangling-fence close regex from a fence-char + length instead of marker.replace(...). The marker is captured by `(`{3,}|~{3,})` so it can only be backticks or tildes, but CodeQL was tracing tainted input text into the RegExp source and flagging hostname dots from input as part of the pattern (false positive js/incomplete-hostname-regexp on the test fixture URLs). Reconstructing from a literal char breaks the dataflow. - scripts/notarize-artifact.cjs: drop args from the run() rejection message. Args carry --key-id / --issuer / key file path; the existing outer catch already squashes errors to a generic line, but CodeQL was flagging the args.join(' ') as clear-text logging of APPLE_API_KEY_ID. Composer DOM-text-as-HTML alerts (composer/index.tsx:379, :547) are already addressed in |
||
|
|
f32b66c758 | fix: improve plugins list usability | ||
|
|
9b5dae17a5 |
feat(context-engine): host contract for external context engines
Condenses the substance of PRs #16453, #17453, #16451, #17600, and #13373 into a minimal generic host contract that external context engine plugins (e.g. hermes-lcm) need to integrate cleanly. Drops scaffolding that duplicated existing infrastructure or had marginal value. Five concrete changes: 1. `_transition_context_engine_session()` on AIAgent — generic lifecycle helper that fires on_session_end → on_session_reset → on_session_start → optional carry_over_new_session_context. Engines implement only the hooks they need; missing hooks are skipped. Built-in compressor keeps its existing reset-only behavior because callers default to no metadata. `reset_session_state()` now optionally accepts previous_messages / old_session_id / carry_over_context and delegates to the transition helper when provided. (#16453) 2. `conversation_id` passed to `on_session_start()` — both the agent-init call site and the compression-boundary call site now forward `self._gateway_session_key` so plugin engines have a stable conversation identity that survives session_id rotation (compression splits, /new, resume). The key already existed on AIAgent; it just wasn't reaching engines. (#16453) 3. Canonical cache buckets forwarded to engines — the usage dict passed to `update_from_response()` now includes input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, and reasoning_tokens on top of the legacy prompt/completion/total keys. Engines can make decisions on cache-hit ratios and reasoning costs instead of only aggregates. ABC docstring updated. (#17453) 4. Plugin-registered context engines visible in the picker — `_discover_context_engines()` in plugins_cmd.py now also includes engines registered via `ctx.register_context_engine()` from plugin manifests, deduplicating by name so repo-shipped descriptions win on collision. (#16451) 5. `_EngineCollector.register_command()` — context engines using the standard `register(ctx)` pattern can now expose slash commands (e.g. `/lcm`). Routes to the global plugin command registry with the same conflict-rejection policy regular plugins use (no shadowing built-ins, no clobbering other plugins). Previously these calls hit a no-op and the slash commands silently never appeared. (#17600) Dropped from the original 5 PRs: - Compression boundary signal (`boundary_reason="compression"`) from #16453 — already on main at `agent/conversation_compression.py:412-424`, landed via the bg-review extraction. - `discover_plugins()` before fallback in run_agent.py from #16451 — redundant: `get_plugin_context_engine()` already routes through `_ensure_plugins_discovered()` which is idempotent. - Runtime identity diagnostics method + helpers from #13373 (+251 LOC) — operators can already read engine state via `engine.get_status()`; the diagnostics view added marginal value relative to its surface area. - The 553-LOC slash-command machinery from #17600 — replaced with a 20-LOC `register_command` method on the collector that reuses the existing plugin command registry instead of building a parallel one. Net: ~215 LOC of host-contract changes + 282 LOC of focused tests, vs ~1,176 LOC across the original 5 PRs. Co-authored-by: Tosko4 <1294707+Tosko4@users.noreply.github.com> Closes #16453. Closes #17453. Closes #16451. Closes #17600. Closes #13373. Related: stephenschoettler/hermes-lcm#68. |
||
|
|
ec4d6f1823 | fix(cli): show masked feedback for secret prompts | ||
|
|
8cf977c8b1 |
fix(plugins): widen _sanitize_plugin_name for category-namespaced names
Follow-up to PR #28832 — the dashboard plugin routes now accept slashed names like `observability/langfuse` and `image_gen/openai`, but `_sanitize_plugin_name` still rejected forward slash and so dashboard update + remove on those plugins fell through to '404 not found' even though they exist on disk. Adds an opt-in `allow_subdir=True` flag that: - Permits internal forward slashes (category-namespaced plugin keys emitted by `_discover_all_plugins`). - Strips leading and trailing slashes. - Still rejects `..` and backslash, and still asserts the resolved target lives inside `plugins_dir`. Opted in at the two read-paths that operate on installed plugins: `_require_installed_plugin` (CLI update/remove) and `_user_installed_plugin_dir` (dashboard update/remove). The install path keeps the default (`allow_subdir=False`) because freshly-cloned plugins always land top-level under `~/.hermes/plugins/<name>/`. Adds 6 targeted unit tests covering the new flag's allow/reject matrix. |
||
|
|
d3f62c6913 |
fix(cli): clamp curses color 8 for 8-color terminals (Docker)
curses.init_pair(N, 8, -1) uses extended color 8 ("bright black" /
dim gray) which does not exist on 8-color terminals (COLORS == 8,
valid range 0-7). This crashes the entire plugins UI, session
browser, and radio picker in Docker containers with:
curses.error: init_pair() : color number is greater than COLORS-1
Replace all 5 occurrences across plugins_cmd.py, main.py, and
curses_ui.py with min(8, curses.COLORS - 1), which falls back to
COLOR_WHITE (7) on 8-color terminals.
Closes #13688
|
||
|
|
5cbe0b1c4f |
test(plugins): cover _discover_all_plugins recursion + cross-link loader
Add a TestDiscoverAllPlugins class covering the six cases the recursive scan needs to handle: - flat plugin uses its manifest ``name:`` as the key - category-namespaced plugin keys off ``<category>/<dirname>`` even when the manifest ``name:`` is bare (regression test for the original bug — ``plugins/observability/langfuse/`` with ``name: langfuse`` must surface as ``observability/langfuse``, not ``langfuse``) - user-installed plugin overrides bundled on key collision - depth cap: anything below ``<root>/<category>/<plugin>/`` is ignored - bundled ``memory/`` and ``context_engine/`` are skipped (they have their own loaders), but user plugins under those category names are still scanned Also add an in-source comment next to the key derivation pointing at the loader's matching line (``PluginManager._parse_manifest`` in plugins.py:1027-1028), so future renames of one site flag the other. Both items raised in Copilot review on #27161. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
21be7025c5 |
refactor(plugins): drop dead bundled-source guard in _discover_all_plugins
The `if key in seen and source == "bundled": continue` check was unreachable: bundled is scanned before user, so `key in seen` can never be true while `source == "bundled"`. The "user overrides bundled" semantics are preserved automatically by the unconditional `seen[key] = …` on the user pass. Replaces the dead guard with a one-line comment explaining the overwrite semantics, so a future contributor adding a third source (e.g. project plugins) can see at a glance how ordering interacts with the dict-overwrite. Matches `PluginManager.discover_and_load`'s "user wins" rule. Spotted by Copilot in code review on #27161. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
9b82586c6b |
fix(plugins): surface category-namespaced plugins in hermes plugins list
`_discover_all_plugins()` in plugins_cmd.py did a flat scan of the bundled and user plugin directories — only direct children with a plugin.yaml were surfaced. Category directories like `observability/`, `image_gen/`, `platforms/`, `model-providers/`, `web/`, and `video_gen/` have no plugin.yaml of their own, so their nested plugins (`observability/langfuse`, `image_gen/openai`, etc.) never appeared in `hermes plugins list` or the interactive `hermes plugins` UI — even though the runtime loader (`PluginManager._scan_directory_level`) discovers them correctly and they do load at runtime. This broke the documented promise that bundled plugins appear in `hermes plugins list` and the interactive UI before being enabled, and made it look like `observability/langfuse` didn't exist. Refactor `_discover_all_plugins()` to mirror the loader's recursion (depth cap = 2, same skip set, user overrides bundled on key collision). Return the path-derived registry key (e.g. `observability/langfuse`) as the displayed name, matching what the user passes to `hermes plugins enable …` / writes under `plugins.enabled` in config.yaml. Also clarify the plugins docs: spell out that sub-category plugins surface by their `<category>/<plugin>` key in `hermes plugins list` / interactive UI, add an `observability/langfuse` example to the command reference, and include a nested entry in the interactive-UI mock. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
2ec8d2b42f |
chore: ruff auto-fix PLR6201 — tuple → set in membership tests (#23937)
Replace with for all literal-tuple membership tests. Set lookup is O(1) vs O(n) for tuple — consistent micro-optimization across the codebase. 608 instances fixed via `ruff --fix --unsafe-fixes`, 0 remaining. 133 files, +626/-626 (net zero). |
||
|
|
657874460f |
chore: ruff auto-fixes — collapsible-else-if, if-stmt-min-max, dict.fromkeys (#23926)
PLR5501 (collapsible-else-if): 28 instances — else: if: → elif: PLR1730 (if-stmt-min-max): 15 instances — if x<y: x=y → x=max(x,y) C420 (dict.fromkeys): 2 instances — dictcomp → dict.fromkeys PLR1704 (redefined-argument): 1 instance — reason → err_msg (shadow fix) C414 (unnecessary-list): 1 instance — sorted(list(x)) → sorted(x) 28 files, -44 net lines. All mechanical, zero logic changes. 17,211 tests pass, zero regressions. |
||
|
|
c8ede8aa1b |
fix(plugins): resolve Git binary for installs under minimal PATH
Resolve git via shutil.which with POSIX and Git-for-Windows fallbacks before clone and pull so Dashboard/API installs do not misreport Git as missing. Add regression tests for the resolver and pull subprocess invocation. |
||
|
|
cbce5e93fc |
codebase: add encoding='utf-8' to all bare open() calls (PLW1514)
Closes the last Python-on-Windows UTF-8 exposure by making every
text-mode open() call explicit about its encoding.
Before: on Windows, bare open(path, 'r') defaults to the system
locale encoding (cp1252 on US-locale installs). That means reading
any config/yaml/markdown/json file with non-ASCII content either
crashes with UnicodeDecodeError or silently mis-decodes bytes.
After: all 89 affected call sites in production code now pass
encoding='utf-8' explicitly. Works identically on every platform
and every locale, no surprise behavior.
Mechanical sweep via:
ruff check --preview --extend-select PLW1514 --unsafe-fixes --fix --exclude 'tests,venv,.venv,node_modules,website,optional-skills, skills,tinker-atropos,plugins' .
All 89 fixes have the same shape: open(x) or open(x, mode) became
open(x, encoding='utf-8') or open(x, mode, encoding='utf-8'). Nothing
else changed. Every modified file still parses and the Windows/sandbox
test suite is still green (85 passed, 14 skipped, 0 failed across
tests/tools/test_code_execution_windows_env.py +
tests/tools/test_code_execution_modes.py + tests/tools/test_env_passthrough.py +
tests/test_hermes_bootstrap.py).
Scope notes:
- tests/ excluded: test fixtures can use locale encoding intentionally
(exercising edge cases). If we want to tighten tests later that's
a separate PR.
- plugins/ excluded: plugin-specific conventions may differ; plugin
authors own their code.
- optional-skills/ and skills/ excluded: skill scripts are user-authored
and we don't want to mass-edit them.
- website/ and tinker-atropos/ excluded: vendored / generated content.
46 files touched, 89 +/- lines (symmetric replacement). No behavior
change on POSIX or on Windows when the file is ASCII; bug fix on
Windows when the file contains non-ASCII.
|
||
|
|
e2a4905606 |
feat(dashboard): add Plugins page with enable/disable, auth status, install/remove
- New PluginsPage.tsx: full plugin management UI (list, enable/disable, install from git, remove, git pull updates, provider picker) - Backend: dashboard_set_agent_plugin_enabled now also toggles the plugin's toolset in platform_toolsets so enabling actually makes tools visible in agent sessions - Backend: /api/dashboard/plugins/hub returns auth_required + auth_command per plugin (checks tool registry check_fn) - Frontend: auth_required shown as Badge + CommandBlock with copy-able auth command - Fix: Select overflow in providers card (min-w-0 grid cells, removed truncate/overflow-hidden that clipped dropdown) - Refactor: _install_plugin_core extracted for non-interactive reuse, PluginOperationError for structured error handling - i18n: en/zh/types updated with all new plugin page strings |
||
|
|
1f1608067c |
feat(gateway): unify setup flows, load platforms dynamically from registry
Merge the two gateway setup paths (hermes setup gateway + hermes gateway setup) to use a single _unified_platforms() list that merges built-in _PLATFORMS with dynamically registered plugin entries from platform_registry. - Add setup_fn field to PlatformEntry for plugin setup flows - _unified_platforms() merges built-ins with registry entries by key - setup_gateway() now uses unified list instead of hardcoded _GATEWAY_PLATFORMS tuple list - gateway_setup() uses same unified list, plugin entries appear alongside built-ins with no [plugin] suffix - _platform_status() handles plugin platforms via registry check_fn - Plugin platforms with setup_fn get called directly; plugins without get a generic env-var display fallback IRC and other plugin platforms now appear automatically in the setup menu when registered via platform_registry.register(). feat(gateway): surface disabled platform plugins in setup and auto-enable on select Platform plugins under plugins/platforms/* (IRC, etc.) were gated behind plugins.enabled, so `hermes gateway setup` wouldn't list them until the user ran `hermes plugins enable <name>` first. Now the setup menu always surfaces them as "plugin disabled — select to enable", and picking one adds it to plugins.enabled before running its setup flow. Along the way, unify the two gateway setup flows so `hermes setup gateway` and `hermes gateway setup` both read from the same platform list (built-in _PLATFORMS + platform_registry entries), dispatch through a single _configure_platform() helper, and share _platform_status(). Deletes the dead bespoke wrappers in setup.py (_setup_whatsapp, _setup_weixin, _setup_email, etc.) that duplicated logic now covered by the registry path or _setup_standard_platform. Also: - PlatformEntry gains a plugin_name field so the registry knows which plugin owns each entry (required for auto-enable). - PluginContext.register_platform auto-stamps plugin_name from the manifest so plugins don't have to pass it explicitly. - PluginManager now scans plugins/platforms/* as its own category root, one level below the bundled plugin scan. - Fix IRC plugin discovery: rename PLUGIN.yaml → plugin.yaml (the scanner is case-sensitive) and add the missing __init__.py that _load_directory_module requires. |
||
|
|
059980727a |
refactor(config): migrate remaining 33 cfg_get call sites (#17311)
Completes the cfg_get migration started in PR #17304. Covers the remaining hermes_cli/ and plugins/ config-access sites that the first PR intentionally left opportunistic. Migrated (33 sites across 14 files): hermes_cli/setup.py 13 sites (terminal.*, agent.*, display.*, compression.*, tts.*) hermes_cli/tools_config.py 7 sites (tts.*, browser.*, web.*, platform_toolsets.*) hermes_cli/plugins_cmd.py 3 sites (plugins.*, memory.*, context.*) plugins/memory/honcho/cli.py 3 sites (hosts.*) hermes_cli/web_server.py 1 site (dashboard.*) hermes_cli/skills_config.py 1 site (platform_disabled) hermes_cli/plugins.py 1 site (plugins.disabled) hermes_cli/status.py 1 site (terminal.backend) hermes_cli/mcp_config.py 1 site (mcp_servers.*) hermes_cli/webhook.py 1 site (platforms.webhook) plugins/memory/__init__.py 1 site (memory.provider) plugins/memory/hindsight/ 1 site (banks.hermes) plugins/memory/holographic/ 1 site (plugins.hermes-memory-store) run_agent.py 1 site (auxiliary.compression) The helper supports non-literal keys too, so e.g. cfg.get('hosts', {}).get(HOST, {}) becomes cfg_get(cfg, 'hosts', HOST, default={}) Migration bugs caught and fixed during this PR: 1. An AST-based batch rewrite naïvely captured the first word token in a chain, which corrupted 'self._config.get(...).get(...)' into 'self.cfg_get(_config, ...)' (dropping 'self.', creating a broken method call). Plugins/memory/hindsight caught it via its test suite. Fixed manually to 'cfg_get(self._config, ...)'. 2. Import-extension heuristic rewrote multi-line parenthesized imports ('from X import (\n A,\n B,\n)') as 'from X import cfg_get, (' — syntactically broken. Fixed by inserting cfg_get as the first name inside the parentheses. Combined with PR #17304, the cfg_get migration now covers: PR #17304 (first batch): 20 sites in tools/ + gateway/ PR #17317 (this one): 33 sites in hermes_cli/ + plugins/ + run_agent.py Total: 53 sites migrated. Remaining ~8 sites are either: - Function-call chains (e.g. '_load_stt_config().get(...).get(...)') that would need double-evaluation or a local binding to migrate cleanly — intentionally deferred. - JSON response-navigation (e.g. 'response_data.get('data',{}).get('web')) which is unrelated to config access and shouldn't use cfg_get. Verified: - 412/412 tests/plugins/ pass (including the hindsight test that caught the self.X regex bug before commit) - 3181/3189 tests/hermes_cli/ pass (8 pre-existing failures on main, verified by git-stash comparison) - Live 'hermes status' and 'hermes config' render correctly (exercise the migrated terminal.backend, tts.provider, browser.cloud_provider, compression.threshold, display.tool_progress sites) - Live 'hermes chat': 1 turn + /quit, zero errors in 11-line log window No semantic changes — cfg_get was already proven to be a 1:1 match for the original .get("X",{}).get("Y",default) pattern in PR #17304. |
||
|
|
6085d7a93e |
chore: remove unused imports and dead locals (ruff F401, F841) (#17010)
Mechanical cleanup across 43 files — removes 46 unused imports (F401) and 14 unused local variables (F841) detected by `ruff check --select F401,F841`. Net: -49 lines. Also fixes a latent NameError in rl_cli.py where `get_hermes_home()` was called at module line 32 before its import at line 65 — the module never imported successfully on main. The ruff audit surfaced this because it correctly saw the symbol as imported-but-unused (the call happened before the import ran); the fix moves the import to the top of the file alongside other stdlib imports. One `# noqa: F401` kept in hermes_cli/status.py for `subprocess`: tests monkeypatch `hermes_cli.status.subprocess` as a regression guard that systemctl isn't called on Termux, so the name must exist at module scope even though the module body doesn't reference it. Docstring explains the reason. Also fixes an invalid `# noqa:` directive in gateway/platforms/discord.py:308 that lacked a rule code. Co-authored-by: teknium1 <teknium@users.noreply.github.com> |
||
|
|
70111eea24 |
feat(plugins): make all plugins opt-in by default
Plugins now require explicit consent to load. Discovery still finds every plugin — user-installed, bundled, and pip — so they all show up in `hermes plugins` and `/plugins`, but the loader only instantiates plugins whose name appears in `plugins.enabled` in config.yaml. This removes the previous ambient-execution risk where a newly-installed or bundled plugin could register hooks, tools, and commands on first run without the user opting in. The three-state model is now explicit: enabled — in plugins.enabled, loads on next session disabled — in plugins.disabled, never loads (wins over enabled) not enabled — discovered but never opted in (default for new installs) `hermes plugins install <repo>` prompts "Enable 'name' now? [y/N]" (defaults to no). New `--enable` / `--no-enable` flags skip the prompt for scripted installs. `hermes plugins enable/disable` manage both lists so a disabled plugin stays explicitly off even if something later adds it to enabled. Config migration (schema v20 → v21): existing user plugins already installed under ~/.hermes/plugins/ (minus anything in plugins.disabled) are auto-grandfathered into plugins.enabled so upgrades don't silently break working setups. Bundled plugins are NOT grandfathered — even existing users have to opt in explicitly. Also: HERMES_DISABLE_BUNDLED_PLUGINS env var removed (redundant with opt-in default), cmd_list now shows bundled + user plugins together with their three-state status, interactive UI tags bundled entries [bundled], docs updated across plugins.md and built-in-plugins.md. Validation: 442 plugin/config tests pass. E2E: fresh install discovers disk-cleanup but does not load it; `hermes plugins enable disk-cleanup` activates hooks; migration grandfathers existing user plugins correctly while leaving bundled plugins off. |
||
|
|
436dfd5ab5 |
fix: no auto-activation + unified hermes plugins UI with provider categories
- Remove auto-activation: when context.engine is 'compressor' (default), plugin-registered engines are NOT used. Users must explicitly set context.engine to a plugin name to activate it. - Add curses_radiolist() to curses_ui.py: single-select radio picker with keyboard nav + text fallback, matching curses_checklist pattern. - Rewrite cmd_toggle() as composite plugins UI: Top section: general plugins with checkboxes (existing behavior) Bottom section: provider plugin categories (Memory Provider, Context Engine) with current selection shown inline. ENTER/SPACE on a category opens a radiolist sub-screen for single-select configuration. - Add provider discovery helpers: _discover_memory_providers(), _discover_context_engines(), config read/save for memory.provider and context.engine. - Add tests: radiolist non-TTY fallback, provider config save/load, discovery error handling, auto-activation removal verification. |
||
|
|
187e90e425 |
refactor: replace inline HERMES_HOME re-implementations with get_hermes_home()
16 callsites across 14 files were re-deriving the hermes home path
via os.environ.get('HERMES_HOME', ...) instead of using the canonical
get_hermes_home() from hermes_constants. This breaks profiles — each
profile has its own HERMES_HOME, and the inline fallback defaults to
~/.hermes regardless.
Fixed by importing and calling get_hermes_home() at each site. For
files already inside the hermes process (agent/, hermes_cli/, tools/,
gateway/, plugins/), this is always safe. Files that run outside the
process context (mcp_serve.py, mcp_oauth.py) already had correct
try/except ImportError fallbacks and were left alone.
Skipped: hermes_constants.py (IS the implementation), env_loader.py
(bootstrap), profiles.py (intentionally manipulates the env var),
standalone scripts (optional-skills/, skills/), and tests.
|