fix(plugins): re-pull plugin secret sources after discovery (#64177)

After plugins register SecretSource backends, reset the env-loader cache
and re-run load_hermes_dotenv when an enabled plugin secret source is
configured. Closes the first-process bootstrap gap where import-time env
load stale-outs plugin vaults (tommck / Community ask). Fail-open, no-op
without plugin sources.

Docs: first-process bootstrap timing on secret-source plugin guide.
Tests: unit coverage for noop / enabled re-pull / discover hook.
Part of #64182 plugin-interface expansion.
This commit is contained in:
Bartok9 2026-07-14 01:47:43 -04:00 committed by Teknium
parent 299996f7e0
commit 7a7e73d310
3 changed files with 158 additions and 9 deletions

View File

@ -859,15 +859,12 @@ class PluginContext:
ordering, mapped-vs-bulk precedence, conflict warnings, and
provenance; the source only fetches.
NOTE ON TIMING: plugin discovery happens later in startup than
the first ``load_hermes_dotenv()`` call, so a plugin-registered
source is not consulted by the initial env load of the process
that discovers it. It IS consulted by every subsequently
spawned Hermes process (gateway children, cron sessions,
subagents), and immediately after a
``reset_secret_source_cache()`` re-pull. Plugin sources are
therefore best for supplying credentials to the running fleet;
the bundled sources cover first-process bootstrap.
NOTE ON TIMING: ``load_hermes_dotenv()`` usually runs at import
*before* plugin discovery. After discovery completes, the plugin
manager re-pulls enabled plugin secret sources (``reset_secret_source_cache``
+ ``load_hermes_dotenv``) so the first process sees them (#64177).
Child processes that load env after plugins still work without that
re-pull. Failed re-pulls never block startup.
Contract requirements (rejected with a warning otherwise):
inherit from ``SecretSource``, ``api_version`` matching
@ -1373,10 +1370,64 @@ class PluginManager:
self._discovered = True
try:
self._discover_and_load_inner()
# Plugin secret sources register during discover; the initial
# load_hermes_dotenv() already ran at import time. Re-pull so the
# first process sees plugin backends (tracking #64177).
self._refresh_secret_sources_after_discovery()
except BaseException:
self._discovered = False
raise
def _refresh_secret_sources_after_discovery(self) -> None:
"""If any non-bundled secret source is enabled, reset cache and re-apply.
No-op when only built-in sources exist or no secrets config is enabled.
Fail-open: never raise into discover_and_load.
"""
try:
from agent.secret_sources.registry import list_sources
from hermes_cli.env_loader import load_hermes_dotenv, reset_secret_source_cache
except Exception:
return
try:
sources = list_sources()
except Exception:
return
builtin = {"bitwarden", "onepassword", "1password"}
plugin_names = [
getattr(s, "name", "") for s in sources if getattr(s, "name", "") not in builtin
]
if not plugin_names:
return
# Only re-pull when at least one plugin source appears enabled in config.
try:
from hermes_cli.config import load_config
cfg = load_config() or {}
secrets = cfg.get("secrets") or {}
except Exception:
secrets = {}
enabled_plugin = False
for name in plugin_names:
section = secrets.get(name)
if isinstance(section, dict) and section.get("enabled"):
enabled_plugin = True
break
if section is True:
enabled_plugin = True
break
if not enabled_plugin:
return
try:
reset_secret_source_cache()
load_hermes_dotenv()
logger.debug(
"Re-applied secret sources after plugin discovery for: %s",
", ".join(sorted(plugin_names)),
)
except Exception as exc:
logger.debug("secret source re-apply after discovery failed: %s", exc)
def _discover_and_load_inner(self) -> None:
"""The actual discovery sweep — see :meth:`discover_and_load`."""
manifests: List[PluginManifest] = self._collect_directory_manifests()

View File

@ -0,0 +1,84 @@
"""Tests for plugin secret-source first-process re-pull (#64177)."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from hermes_cli.plugins import PluginManager
def test_refresh_secret_sources_noop_without_plugin_sources(monkeypatch):
mgr = PluginManager()
called = {"reset": 0, "load": 0}
def _list():
return []
monkeypatch.setattr(
"agent.secret_sources.registry.list_sources", _list, raising=False
)
import agent.secret_sources.registry as reg
monkeypatch.setattr(reg, "list_sources", _list)
def _boom_reset():
called["reset"] += 1
def _boom_load(**kwargs):
called["load"] += 1
monkeypatch.setattr(
"hermes_cli.env_loader.reset_secret_source_cache", _boom_reset
)
monkeypatch.setattr("hermes_cli.env_loader.load_hermes_dotenv", _boom_load)
mgr._refresh_secret_sources_after_discovery()
assert called["reset"] == 0
assert called["load"] == 0
def test_refresh_secret_sources_repulls_when_plugin_enabled(monkeypatch):
mgr = PluginManager()
called = {"reset": 0, "load": 0}
class _Src:
name = "myvault"
import agent.secret_sources.registry as reg
monkeypatch.setattr(reg, "list_sources", lambda: [_Src()])
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"secrets": {"myvault": {"enabled": True}}},
)
def _reset():
called["reset"] += 1
def _load(**kwargs):
called["load"] += 1
monkeypatch.setattr(
"hermes_cli.env_loader.reset_secret_source_cache", _reset
)
monkeypatch.setattr("hermes_cli.env_loader.load_hermes_dotenv", _load)
mgr._refresh_secret_sources_after_discovery()
assert called["reset"] == 1
assert called["load"] == 1
def test_discover_and_load_invokes_refresh(monkeypatch):
mgr = PluginManager()
hits = {"n": 0}
monkeypatch.setattr(PluginManager, "_discover_and_load_inner", lambda self: None)
monkeypatch.setattr(
PluginManager,
"_refresh_secret_sources_after_discovery",
lambda self: hits.__setitem__("n", hits["n"] + 1),
)
mgr.discover_and_load()
assert hits["n"] == 1

View File

@ -12,6 +12,20 @@ Secret sources resolve provider credentials from an external secret manager (a v
The bundled set is deliberately closed, same policy as [memory providers](/developer-guide/memory-provider-plugin): PRs adding new vault backends under `agent/secret_sources/` are closed with a pointer to this guide. Publish your backend as a standalone plugin repo and share it in the Nous Research Discord (`#plugins-skills-and-skins`).
:::
## First-process bootstrap timing
`load_hermes_dotenv()` often runs at import time **before** plugins register.
Hermes then re-pulls secrets after plugin discovery when any **enabled**
plugin secret source is configured (`secrets.<name>.enabled: true`). That
closes the "replace Bitwarden with my vault" first-process gap (#64177).
- Re-pull is idempotent and fail-open (never blocks startup).
- Sources only supply env vars through the orchestrator; there is **no**
plugin API to dump other plugins' or the user's entire secret store beyond
what your source's own config allows.
- Reading `os.environ` after load is possible for any in-process code — the
trust boundary remains "enabled plugins run with agent privilege".
## What the framework owns vs. what you own
The orchestrator (`agent.secret_sources.registry.apply_all`) owns everything security- and precedence-sensitive, so a backend cannot get it wrong: