fix(relay): guard native plugin ownership cutover

Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
This commit is contained in:
Bryan Bednarski
2026-08-11 11:50:55 -06:00
parent ad9fb060c5
commit 3fad83df31
10 changed files with 408 additions and 5 deletions
+30 -1
View File
@@ -18,6 +18,10 @@ from pathlib import Path
from typing import Any, Callable
from hermes_constants import get_hermes_home
from hermes_cli.relay_plugin_cutover import (
RELAY_PLUGINS_CONFIG_ENV,
configured_legacy_relay_env_vars,
)
logger = logging.getLogger(__name__)
@@ -27,7 +31,6 @@ LOGICAL_LLM_SCOPE = "hermes.logical_llm_call"
RUNTIME_SCHEMA_KEY = "hermes.relay.schema_version"
RUNTIME_SCHEMA_VERSION = "hermes.relay.runtime.v1"
RUNTIME_INSTANCE_KEY = "hermes.relay.runtime_instance"
RELAY_PLUGINS_CONFIG_ENV = "HERMES_NEMO_RELAY_PLUGINS_TOML"
RELAY_PLUGINS_EXECUTION_CONSUMER = "hermes.nemo_relay.plugins"
_PROFILE_KEY_CACHE: dict[str, str] = {}
@@ -265,6 +268,23 @@ class _ProcessRelayPluginConfiguration:
)
return False
try:
existing_report = relay.plugin.report()
except Exception:
logger.warning(
"Hermes could not determine whether a process-global Relay "
"plugin configuration is already active; refusing to replace it",
exc_info=True,
)
return False
if existing_report is not None:
logger.warning(
"A process-global Relay plugin configuration is already active "
"outside Hermes native ownership; leaving it unchanged and "
"disabling Hermes-managed Relay middleware for this process"
)
return False
try:
configured_inputs = _configured_plugin_inputs(relay)
if configured_inputs is None:
@@ -1894,6 +1914,15 @@ def _configured_plugin_inputs(
"""Load environment-selected plugin inputs, or leave plugins disabled."""
configured = os.environ.get(RELAY_PLUGINS_CONFIG_ENV, "").strip()
if not configured:
legacy_vars = configured_legacy_relay_env_vars(os.environ)
if legacy_vars:
logger.warning(
"Legacy NeMo Relay exporter variables are set but no %s was "
"provided. %s no longer activate Relay exporters; migrate the "
"exporter configuration to a Relay plugins.toml file.",
RELAY_PLUGINS_CONFIG_ENV,
", ".join(legacy_vars),
)
return None
config_path = Path(configured).expanduser()
@@ -9,6 +9,15 @@ Hermes execution remains available, while Relay scopes, middleware, plugins,
and subscribers are unavailable. The `hermes-agent[nemo-relay]` extra remains
as a no-op compatibility alias for existing installation commands.
> [!WARNING]
> This removes the Hermes `observability/nemo_relay` plugin. Existing users
> must remove `observability/nemo_relay` (or its legacy `nemo_relay` alias)
> from `plugins.enabled` and move exporter configuration into a Relay
> `plugins.toml` selected with `HERMES_NEMO_RELAY_PLUGINS_TOML`. The legacy
> `HERMES_NEMO_RELAY_ATOF_*` and `HERMES_NEMO_RELAY_ATIF_*` variables no
> longer activate exporters. Without the new variable, Hermes does not run
> Relay plugin discovery, configuration layering, middleware, or exporters.
Hermes requires NeMo Relay 0.7.1 or later within the 0.7 release line. That
release establishes the lossless provider-codec contract used for Anthropic
Messages, OpenAI Chat Completions, and OpenAI Responses requests.
+1 -1
View File
@@ -3557,7 +3557,7 @@ DEFAULT_CONFIG = {
},
# Config schema version - bump this when adding new required fields
"_config_version": 37,
"_config_version": 38,
}
# Optional environment variables that enhance functionality
+32
View File
@@ -815,6 +815,37 @@ def _migrate_to_37(results: Dict[str, Any], quiet: bool) -> None:
)
def _migrate_to_38(results: Dict[str, Any], quiet: bool) -> None:
# Version 37 → 38: the bundled observability/nemo_relay plugin was
# removed when Relay lifecycle ownership moved into the agent core.
_c = _cfg()
read_raw_config = _c.read_raw_config
_persist_migration = _c._persist_migration
from hermes_cli.relay_plugin_cutover import legacy_relay_plugin_keys
config = read_raw_config()
plugins = config.get("plugins")
if not isinstance(plugins, dict):
return
enabled = plugins.get("enabled")
removed = legacy_relay_plugin_keys(enabled)
if not removed or not isinstance(enabled, list):
return
plugins["enabled"] = [value for value in enabled if value not in removed]
config["plugins"] = plugins
_persist_migration(config)
message = (
"Removed legacy Relay plugin from plugins.enabled: "
f"{', '.join(removed)}. Configure native Relay plugins with "
"HERMES_NEMO_RELAY_PLUGINS_TOML."
)
results["warnings"].append(message)
if not quiet:
print(f" ⚠ {message}")
#: Registry of (target_version, migration_fn), strictly ascending. The driver
#: applies every entry whose target version is greater than the on-disk
#: observe earlier steps' writes via read_raw_config() (filesystem state).
@@ -839,6 +870,7 @@ MIGRATIONS: Tuple[Tuple[int, Callable[[Dict[str, Any], bool], None]], ...] = (
(35, _migrate_to_35),
(36, _migrate_to_36),
(37, _migrate_to_37),
(38, _migrate_to_38),
)
+51 -3
View File
@@ -525,6 +525,46 @@ def collect_deprecated_env_vars(env_map: dict | None) -> list[tuple[str, str]]:
return findings
def collect_relay_plugin_cutover_findings(
raw_config: dict | None,
env_map: dict | None,
) -> list[tuple[str, str]]:
"""Return actionable findings for the removed Hermes Relay plugin."""
from hermes_cli.relay_plugin_cutover import (
LEGACY_RELAY_EXPORT_ENV_VARS,
RELAY_PLUGINS_CONFIG_ENV,
configured_legacy_relay_env_vars,
legacy_relay_plugin_keys,
)
findings: list[tuple[str, str]] = []
if isinstance(raw_config, dict):
plugins = raw_config.get("plugins")
if isinstance(plugins, dict):
for key in legacy_relay_plugin_keys(plugins.get("enabled")):
findings.append(
(
f"plugins.enabled: {key}",
f"remove it and configure {RELAY_PLUGINS_CONFIG_ENV}",
)
)
effective_env = dict(env_map or {})
for name in (*LEGACY_RELAY_EXPORT_ENV_VARS, RELAY_PLUGINS_CONFIG_ENV):
if name not in effective_env and os.environ.get(name) is not None:
effective_env[name] = os.environ[name]
if not str(effective_env.get(RELAY_PLUGINS_CONFIG_ENV, "")).strip():
for name in configured_legacy_relay_env_vars(effective_env):
findings.append(
(
name,
f"move exporter settings to {RELAY_PLUGINS_CONFIG_ENV}; "
"this variable is now ignored",
)
)
return findings
def report_deprecated_config_and_env(
raw_config: dict | None = None,
env_map: dict | None = None,
@@ -535,18 +575,26 @@ def report_deprecated_config_and_env(
(empty when nothing deprecated is present). Does not mutate config/env and
does not append to the blocking ``issues`` list.
"""
findings = collect_deprecated_config_keys(raw_config)
findings.extend(collect_deprecated_env_vars(env_map))
deprecated = collect_deprecated_config_keys(raw_config)
deprecated.extend(collect_deprecated_env_vars(env_map))
relay_cutover = collect_relay_plugin_cutover_findings(raw_config, env_map)
findings = deprecated + relay_cutover
if not findings:
check_ok("No deprecated config keys or env vars")
return findings
for legacy, replacement in findings:
for legacy, replacement in deprecated:
check_warn(
f"Deprecated: {legacy}",
f"(use {replacement} instead)",
)
check_info(f"Replace {legacy} → {replacement} (warn-only; not auto-migrated here)")
for legacy, replacement in relay_cutover:
check_warn(
f"Breaking Relay migration: {legacy}",
f"({replacement})",
)
check_info(f"Migrate {legacy}: {replacement}")
return findings
+33
View File
@@ -71,6 +71,11 @@ from hermes_cli.plugin_capabilities import ( # noqa: F401 — re-exported
from hermes_cli.plugin_capabilities import (
parse_declared_capabilities as _parse_declared_capabilities,
)
from hermes_cli.relay_plugin_cutover import (
LEGACY_RELAY_PLUGIN_KEYS,
RELAY_PLUGINS_CONFIG_ENV,
legacy_relay_plugin_keys,
)
def get_bundled_plugins_dir() -> Path:
@@ -3892,6 +3897,14 @@ class PluginManager:
# don't collide even when both manifests say ``name: openai``.
disabled = _get_disabled_plugins()
enabled = _get_enabled_plugins() # None = opt-in default (nothing enabled)
stale_relay_keys = legacy_relay_plugin_keys(enabled)
if stale_relay_keys:
logger.warning(
"Removed Hermes plugin %s is still listed in plugins.enabled; "
"remove it and configure native Relay plugins with %s",
", ".join(stale_relay_keys),
RELAY_PLUGINS_CONFIG_ENV,
)
winners: Dict[str, PluginManifest] = {}
for manifest in manifests:
winners[manifest.key or manifest.name] = manifest
@@ -3902,6 +3915,26 @@ class PluginManager:
for manifest in winners.values():
lookup_key = manifest.key or manifest.name
# Relay lifecycle ownership now lives in the Hermes core. Loading
# an old user or entry-point copy would let plugin.initialize()
# compete for the same process-global Relay registries.
if (
lookup_key in LEGACY_RELAY_PLUGIN_KEYS
or manifest.name in LEGACY_RELAY_PLUGIN_KEYS
):
loaded = LoadedPlugin(manifest=manifest, enabled=False)
loaded.error = (
"removed — Relay lifecycle is owned by Hermes core; configure "
f"{RELAY_PLUGINS_CONFIG_ENV} instead"
)
self._plugins[lookup_key] = loaded
logger.warning(
"Refusing to load removed Hermes Relay plugin '%s'; %s",
lookup_key,
loaded.error,
)
continue
# Explicit disable always wins (matches on key or on legacy
# bare name for back-compat with existing user configs).
if lookup_key in disabled or manifest.name in disabled:
+62
View File
@@ -0,0 +1,62 @@
"""Shared migration guards for Hermes' native NeMo Relay ownership."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
RELAY_PLUGINS_CONFIG_ENV = "HERMES_NEMO_RELAY_PLUGINS_TOML"
LEGACY_RELAY_PLUGIN_KEYS = frozenset(
{
"nemo_relay",
"observability/nemo_relay",
}
)
LEGACY_RELAY_EXPORT_ENV_VARS = frozenset(
{
"HERMES_NEMO_RELAY_ATOF_ENABLED",
"HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY",
"HERMES_NEMO_RELAY_ATOF_FILENAME",
"HERMES_NEMO_RELAY_ATOF_MODE",
"HERMES_NEMO_RELAY_ATIF_ENABLED",
"HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY",
"HERMES_NEMO_RELAY_ATIF_FILENAME_TEMPLATE",
"HERMES_NEMO_RELAY_ATIF_AGENT_NAME",
"HERMES_NEMO_RELAY_ATIF_AGENT_VERSION",
"HERMES_NEMO_RELAY_ATIF_MODEL_NAME",
"HERMES_NEMO_RELAY_ATIF_SUBAGENT_EXPORT_MODE",
}
)
def legacy_relay_plugin_keys(values: Any) -> tuple[str, ...]:
"""Return removed Relay plugin identities present in a config value."""
if not isinstance(values, (list, tuple, set, frozenset)):
return ()
return tuple(
sorted(
{
value
for value in values
if isinstance(value, str) and value in LEGACY_RELAY_PLUGIN_KEYS
}
)
)
def configured_legacy_relay_env_vars(
env: Mapping[str, Any] | None,
) -> tuple[str, ...]:
"""Return non-empty legacy Relay exporter variables in *env*."""
if env is None:
return ()
return tuple(
sorted(
name
for name in LEGACY_RELAY_EXPORT_ENV_VARS
if env.get(name) is not None and str(env[name]).strip()
)
)
+67
View File
@@ -20,11 +20,15 @@ class _FakeRelay:
initialize_error: Exception | None = None,
dynamic_initialize_error: Exception | None = None,
activation_close_error: Exception | None = None,
active_report: Any = None,
report_error: Exception | None = None,
) -> None:
self.events: list[tuple[Any, ...]] = []
self.initialize_error = initialize_error
self.dynamic_initialize_error = dynamic_initialize_error
self.activation_close_error = activation_close_error
self.active_report = active_report
self.report_error = report_error
self.dynamic_plugin_specs: list[dict[str, Any]] = []
self.ScopeType = SimpleNamespace(Agent="agent")
self.plugin = SimpleNamespace(
@@ -32,6 +36,7 @@ class _FakeRelay:
initialize_with_dynamic_plugins=self._initialize_dynamic_plugins,
load_dynamic_plugin_activation_specs=self._load_dynamic_plugin_specs,
clear_async=self._clear_plugins_async,
report=self._report_plugins,
)
self.scope = SimpleNamespace(
push=self._scope_push,
@@ -74,6 +79,11 @@ class _FakeRelay:
async def _clear_plugins_async(self) -> None:
self.events.append(("plugin.clear_async",))
def _report_plugins(self) -> Any:
if self.report_error is not None:
raise self.report_error
return self.active_report
def _scope_push(self, name: str, scope_type: Any, **kwargs: Any) -> Any:
handle = ("scope", name, len(self.events))
self.events.append(("scope.push", name, scope_type, kwargs))
@@ -142,6 +152,63 @@ def test_relay_initializes_explicit_plugins_before_first_session_scope(
host.shutdown()
def test_foreign_active_plugin_configuration_is_left_unchanged(
explicit_static_config,
caplog,
):
foreign_report = {"diagnostics": [], "source": "embedding-host"}
relay = _FakeRelay(active_report=foreign_report)
with caplog.at_level("WARNING"):
host = relay_runtime.RelayRuntime(relay=relay, profile_key="profile")
try:
assert not host.managed_execution_enabled()
assert relay.active_report is foreign_report
assert relay.events == []
assert "already active outside Hermes native ownership" in caplog.text
assert "leaving it unchanged" in caplog.text
finally:
host.shutdown()
def test_unreadable_foreign_plugin_state_fails_safe(
explicit_static_config,
caplog,
):
relay = _FakeRelay(report_error=RuntimeError("report unavailable"))
with caplog.at_level("WARNING"):
host = relay_runtime.RelayRuntime(relay=relay, profile_key="profile")
try:
assert not host.managed_execution_enabled()
assert relay.events == []
assert "refusing to replace it" in caplog.text
finally:
host.shutdown()
def test_legacy_exporter_env_without_plugins_toml_warns_and_stays_disabled(
monkeypatch,
caplog,
):
monkeypatch.delenv(relay_runtime.RELAY_PLUGINS_CONFIG_ENV, raising=False)
monkeypatch.setenv("HERMES_NEMO_RELAY_ATOF_ENABLED", "1")
relay = _FakeRelay()
with caplog.at_level("WARNING"):
host = relay_runtime.RelayRuntime(relay=relay, profile_key="profile")
try:
assert not host.managed_execution_enabled()
assert relay.events == []
assert "no HERMES_NEMO_RELAY_PLUGINS_TOML was provided" in caplog.text
assert "HERMES_NEMO_RELAY_ATOF_ENABLED" in caplog.text
finally:
host.shutdown()
def test_initialization_failure_is_fail_open(explicit_static_config, caplog):
relay = _FakeRelay(initialize_error=RuntimeError("rejected config"))
+38
View File
@@ -25,6 +25,7 @@ from hermes_cli.plugins import (
resolve_plugin_command_result,
_portable_skill_namespace,
)
from hermes_cli.relay_plugin_cutover import RELAY_PLUGINS_CONFIG_ENV
from hermes_cli.middleware import (
VALID_MIDDLEWARE,
apply_llm_request_middleware,
@@ -120,6 +121,43 @@ def _make_plugin_dir(base: Path, name: str, *, register_body: str = "pass",
class TestPluginDiscovery:
"""Tests for plugin discovery from directories and entry points."""
def test_removed_relay_plugin_identity_cannot_be_reloaded(
self, monkeypatch, caplog
):
from hermes_cli import plugins as plugins_mod
manifest = PluginManifest(
name="nemo_relay",
key="observability/nemo_relay",
source="user",
)
manager = PluginManager()
monkeypatch.setattr(
manager,
"_collect_directory_manifests",
lambda: [manifest],
)
monkeypatch.setattr(manager, "_scan_entry_points", lambda: [])
monkeypatch.setattr(
plugins_mod,
"_get_enabled_plugins",
lambda: {"observability/nemo_relay"},
)
monkeypatch.setattr(plugins_mod, "_get_disabled_plugins", lambda: set())
loaded: list[PluginManifest] = []
monkeypatch.setattr(manager, "_load_plugin", loaded.append)
with caplog.at_level(logging.WARNING):
manager.discover_and_load()
state = manager._plugins["observability/nemo_relay"]
assert loaded == []
assert not state.enabled
assert state.error is not None
assert "Relay lifecycle is owned by Hermes core" in state.error
assert RELAY_PLUGINS_CONFIG_ENV in state.error
assert "Refusing to load removed Hermes Relay plugin" in caplog.text
def test_enabled_portable_plugin_registers_components(
self, tmp_path, monkeypatch
):
@@ -0,0 +1,85 @@
"""Regression tests for migration from the removed Hermes Relay plugin."""
from __future__ import annotations
import os
from unittest.mock import patch
import yaml
from hermes_cli.config import migrate_config
from hermes_cli.doctor import collect_relay_plugin_cutover_findings
from hermes_cli.relay_plugin_cutover import RELAY_PLUGINS_CONFIG_ENV
def test_v38_migration_removes_only_legacy_relay_plugin_keys(tmp_path):
config_path = tmp_path / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"_config_version": 37,
"plugins": {
"enabled": [
"keep-me",
"observability/nemo_relay",
"nemo_relay",
]
},
},
sort_keys=False,
),
encoding="utf-8",
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
results = migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert raw["_config_version"] == 38
assert raw["plugins"]["enabled"] == ["keep-me"]
assert any(
"observability/nemo_relay" in warning
and RELAY_PLUGINS_CONFIG_ENV in warning
for warning in results["warnings"]
)
def test_doctor_reports_stale_relay_plugin_key():
findings = dict(
collect_relay_plugin_cutover_findings(
{"plugins": {"enabled": ["observability/nemo_relay"]}},
{},
)
)
replacement = findings["plugins.enabled: observability/nemo_relay"]
assert "remove it" in replacement
assert RELAY_PLUGINS_CONFIG_ENV in replacement
def test_doctor_reports_legacy_exporter_env_without_new_config(monkeypatch):
monkeypatch.delenv(RELAY_PLUGINS_CONFIG_ENV, raising=False)
findings = dict(
collect_relay_plugin_cutover_findings(
{},
{"HERMES_NEMO_RELAY_ATIF_ENABLED": "true"},
)
)
assert "now ignored" in findings["HERMES_NEMO_RELAY_ATIF_ENABLED"]
assert RELAY_PLUGINS_CONFIG_ENV in findings["HERMES_NEMO_RELAY_ATIF_ENABLED"]
def test_doctor_does_not_warn_for_legacy_env_after_new_config_is_selected(
monkeypatch,
):
monkeypatch.delenv(RELAY_PLUGINS_CONFIG_ENV, raising=False)
findings = collect_relay_plugin_cutover_findings(
{},
{
RELAY_PLUGINS_CONFIG_ENV: "/tmp/plugins.toml",
"HERMES_NEMO_RELAY_ATIF_ENABLED": "true",
},
)
assert findings == []