Skip to content

fix(plugins): pin discovery to the process home, not the triggering request - #331

Merged
OmarB97 merged 1 commit into
mainfrom
fix/plugin-discovery-process-home-fork-20260802
Aug 2, 2026
Merged

fix(plugins): pin discovery to the process home, not the triggering request#331
OmarB97 merged 1 commit into
mainfrom
fix/plugin-discovery-process-home-fork-20260802

Conversation

@OmarB97

@OmarB97 OmarB97 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

What does this PR do?

Plugin discovery is lazy, and several tools call _ensure_plugins_discovered()
from inside an agent turntts_tool, web_tools, video_generation_tool,
browser_tool. The gateway's turn handler binds
set_hermes_home_override(session_profile_home) for the duration of a turn, and
discover_and_load resolved its user plugin dir with get_hermes_home().

So whichever session happened to trigger the first discovery decided the whole
process's plugin registry — permanently, since the manager is a singleton that
never rescans. In a backend serving several profiles, the launch profile could
lose its own plugins and inherit another profile's, depending only on which chat
ran a plugin-touching tool first.

Reproduced with two homes, discovery fired under a profile override:

first discovery ran under PROFILE override
  commands registered            -> ['profile-cmd']

launch profile asks for its OWN /launch-cmd (no override)
  handler found                  -> False   ❌ launch profile lost its own plugins
  /profile-cmd visible to launch -> True    ❌ other profile leaked in

With this change, the same probe returns launch-cmd registered, /launch-cmd
found, /profile-cmd not visible.

The fix binds the process home around the sweep. Binding, rather than changing
the one get_hermes_home() call
, because load_config() follows the override
too — pinning only the directory would have scanned one home while gating on
another profile's plugins.enabled allow-list. One consistent scope covers the
plugin dir, both allow-lists, and anything a plugin's own register() reads.

Why the process home is the right scope — and what this deliberately does not fix

Plugins register process-global capabilities: tools, hooks, middleware,
platforms, the context engine. Every session on the backend shares them, and
PluginManager holds no lock. Re-scoping discovery per request would mean
clearing and re-registering that state out from under live turns — a data race
that would break other profiles' in-flight sessions, not just a slow path.

So this PR makes the existing scope deterministic, in the spirit of #320
("make the goal store's launch-home scope deliberate, not accidental") and
matching the dashboard's user plugin dir, which already resolves via
get_process_hermes_home() for the same reason.

It does not give non-launch profiles their own plugins when one backend serves
many.
That needs a per-profile registry — a real design change — and I have left
it alone rather than half-doing it. The common deployment is unaffected either
way: a dedicated per-profile backend, and the slash worker, both export
HERMES_HOME at spawn, so the process home is that profile's home there. There
is a test pinning that.

Related Issue

No filed issue — found while auditing command.dispatch for profile scoping, the
audit behind #323, #324, #326, and #328.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • hermes_cli/plugins.pydiscover_and_load() binds
    set_hermes_home_override(get_process_hermes_home()) around
    _discover_and_load_inner(), released in a finally so it is restored on both
    the success and the raise path (the existing except that un-caches a failed
    sweep is preserved). Comment records why discovery is process-scoped.
  • hermes_cli/plugins.py — imports get_process_hermes_home,
    set_hermes_home_override, reset_hermes_home_override.
  • hermes_cli/plugins.py_discover_and_load_inner docstring notes it runs
    with the process home bound, so the get_hermes_home() and config reads inside
    it resolve to the launch home. The call at the user plugin dir is left as
    get_hermes_home() deliberately: with the binding in place it resolves
    correctly, and one mechanism scoping the whole sweep is easier to reason about
    than a mix of pinned and unpinned calls.
  • tests/hermes_cli/test_plugins.py — new TestDiscoveryIsScopedToTheProcessHome
    (4 tests).

How to Test

  1. Confirm the new test fails without the fix. Revert only
    hermes_cli/plugins.py and run
    pytest tests/hermes_cli/test_plugins.py -k TestDiscoveryIsScopedToTheProcessHome.
    1 failed, 3 passed.

    Only test_a_profile_scoped_request_does_not_capture_the_registry reproduces
    the bug. The other three are guards and pass either way by design, which is
    what you want from them:

    • ..._override_is_restored_after_discovery — the sweep must not leak its own
      binding into the caller's scope (nothing to leak before the fix).
    • ..._override_is_restored_when_the_sweep_raises — same on the error path,
      and asserts the failed sweep is still not cached as discovered.
    • ..._dedicated_profile_process_still_gets_its_own_plugins — proves this does
      not force every process onto one shared plugin set.
  2. Confirm all four pass with it.
    pytest tests/hermes_cli/test_plugins.py -q → 120 passed.

  3. Check for regressions across the core plugin suites:

    pytest tests/hermes_cli/test_plugins.py \
      tests/hermes_cli/test_plugin_auxiliary_tasks.py \
      tests/hermes_cli/test_plugin_cli_registration.py \
      tests/agent/test_plugin_llm.py tests/test_plugin_skills.py \
      tests/hermes_cli/test_dashboard_basic_auth_plugin_enable.py \
      tests/hermes_cli/test_dashboard_auth_plugin_hook.py \
      tests/hermes_cli/test_codex_runtime_plugin_migration.py -q
    

    302 passed, 0 failed. Baseline on an unpatched tree: 298 passed, 0
    failed.
    The delta is exactly the four new tests; both runs fully green.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — not claimed. The full suite is not green on this machine independent of this change. I ran the 8 core plugin suites above matched before and after (both fully green, 298 → 302). A wider sweep over all 58 test files that touch PluginManager / discover_plugins / _ensure_plugins_discovered is running in matched worktrees; I will post the comparison as a comment, and this should not merge before it lands.
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15 (Darwin 25.6.0), arm64

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A (rationale comment at the binding; _discover_and_load_inner docstring notes the bound scope)
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A (N/A — no config keys)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A (N/A — no change to the plugin authoring surface; register(ctx) and the manifest format are untouched)
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A (get_process_hermes_home() is the same platform-native resolver already used for dashboard plugins)
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A (N/A — plugin-registered tools are unchanged; only which home they are discovered from)

Screenshots / Logs

Included inline above — the two-home probe before and after.

…equest

Plugin discovery is lazy, and several tools call `_ensure_plugins_discovered()`
from inside an agent turn — `tts_tool`, `web_tools`, `video_generation_tool`,
`browser_tool`. The gateway's turn handler binds
`set_hermes_home_override(session_profile_home)` for the duration of a turn, and
`discover_and_load` resolved its user plugin dir with `get_hermes_home()`.

So whichever session happened to trigger the FIRST discovery decided the whole
process's plugin registry, permanently — the singleton never rescans. In a
backend serving several profiles the launch profile could lose its own plugins
AND inherit another profile's, depending only on which chat ran a
plugin-touching tool first. Reproduced with two homes: discovery fired under a
profile override, and afterwards the launch profile's own `/launch-cmd` was
gone while the other profile's `/profile-cmd` answered for it.

Bind the process home around the sweep. That covers the user plugin dir, the
enabled/disabled allow-lists (`load_config` follows the override too, so
changing only the directory would have scanned one home while gating on
another), and anything a plugin's own `register()` reads — one consistent scope
instead of a mix.

The process home is the only coherent scope here: plugins register
process-global capabilities (tools, hooks, middleware, platforms, the context
engine) that every session on the backend shares, and `PluginManager` has no
lock, so re-scoping per request would mean clearing registrations out from
under live turns. Same rationale as the dashboard's user plugin dir in
`hermes_cli/web_server.py` and the goal store in #320.

This does NOT give non-launch profiles their own plugins when one backend
serves many — that needs a per-profile registry and is left alone deliberately.
It makes the existing scope deterministic and correct for the per-profile
deployments that are the norm: a dedicated backend or slash worker exports
HERMES_HOME at spawn, so the process home IS that profile's home there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@OmarB97
OmarB97 merged commit 4919db8 into main Aug 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant