Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@
_PROVIDER_LIST_CACHE: list[ProviderProfile] | None = None
_discovered = False

# Import-free platform-adapter signal: every documented adapter does this
# import at module top (see gateway/platforms/*), while model-provider
# plugins never touch gateway.config. Scanned, never executed.
_PLATFORM_IMPORT_MARKER = "from gateway.config import Platform"

# Repo-root ``plugins/model-providers/`` — populated at discovery time.
_BUNDLED_PLUGINS_DIR = (
Path(__file__).resolve().parent.parent / "plugins" / "model-providers"
Expand Down Expand Up @@ -214,6 +219,49 @@ def _discover_entry_point_providers() -> None:
"entry-point provider %r skipped: not enabled in config", ep.name
)
continue
# Import-free ownership precheck (#98438): this group is shared with
# the general PluginManager, which classifies entry points WITHOUT
# importing them and loads platform plugins lazily. Importing a
# platform adapter here breaks it — documented adapters do
# ``from gateway.config import Platform`` at module top, and when
# this scan runs inside the ``gateway.config -> hermes_cli.config``
# import chain (provider env injection at the bottom of
# hermes_cli/config.py) ``gateway.config`` is still half-initialized,
# so the import fails and the platform silently disappears. Skip
# entry points that are provably NOT model providers: a
# ``<name>-platform`` name (the manager's platform-id convention), a
# memory-provider source signature, or the documented adapter import
# in the source (hand-written ``plugins.enabled`` entries may carry
# any name, so the suffix alone can miss them). Anything else falls
# through to the load below — a real provider must never be dropped
# just because its source could not be classified.
try:
from hermes_cli.plugins import (
_classify_entrypoint_value_kind,
_resolve_module_source,
)

value = getattr(ep, "value", "")
skip = ep.name.endswith("-platform") or (
value and _classify_entrypoint_value_kind(value) == "exclusive"
)
if not skip and value:
try:
module_name = str(value).split(":", 1)[0].strip()
skip = bool(module_name) and _PLATFORM_IMPORT_MARKER in (
_resolve_module_source(module_name)
)
except Exception:
skip = False # unresolvable source — fail open
if skip:
logger.debug(
"entry-point %r skipped by provider scan: platform or "
"memory-provider plugin owned by the PluginManager",
ep.name,
)
continue
except Exception:
pass # classification unavailable — keep the historical behavior
try:
loaded = ep.load()
except Exception as exc:
Expand Down
184 changes: 183 additions & 1 deletion tests/providers/test_entry_point_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ def _restore_real_discovery():


class _FakeEP:
def __init__(self, name, loader):
def __init__(self, name, loader, value=""):
self.name = name
self.group = "hermes_agent.plugins"
self.value = value
self._loader = loader

def load(self):
Expand Down Expand Up @@ -223,3 +224,184 @@ def register():
assert "impostor.test" not in (p.base_url or "")
finally:
_clear_provider_caches()


def test_platform_named_entry_point_not_loaded(monkeypatch):
"""A ``<name>-platform`` entry point must not be imported by this scan
(#98438).

Platform adapters are owned by the PluginManager, which loads them
lazily; importing one here runs its ``from gateway.config import
Platform`` while ``gateway.config`` may still be half-initialized (this
scan can run inside the ``gateway.config -> hermes_cli.config`` import
chain), killing the platform with a circular-import error.
"""
loads = []

def _platform_side_effect():
loads.append("stub-platform") # the import itself would already fail
return object()

fake_eps = _FakeEntryPoints(
[_FakeEP("stub-platform", _platform_side_effect, value="stubplat")]
)
import importlib.metadata as md

monkeypatch.setattr(md, "entry_points", lambda: fake_eps)
_enable(monkeypatch, "stub-platform")
_clear_provider_caches()
try:
providers._discover_providers()
assert loads == [] # never imported
finally:
_clear_provider_caches()


def test_memory_provider_entry_point_not_loaded(monkeypatch, tmp_path):
"""An enabled memory-provider entry point (``MemoryProvider`` source
signature) is not imported here — memory providers are owned by the
``plugins/memory`` discovery system."""
loads = []
# A resolvable top-level module whose source carries the memory-provider
# markers the PluginManager's import-free classifier looks for.
(tmp_path / "stub_mem_plugin.py").write_text(
"from plugins.memory import register_memory_provider\n"
"class StubMemoryProvider(MemoryProvider):\n"
" pass\n",
encoding="utf-8",
)
monkeypatch.syspath_prepend(str(tmp_path))

def _mem_side_effect():
loads.append("stub-mem")
return object()

fake_eps = _FakeEntryPoints(
[_FakeEP("stub-mem", _mem_side_effect, value="stub_mem_plugin")]
)
import importlib.metadata as md

monkeypatch.setattr(md, "entry_points", lambda: fake_eps)
_enable(monkeypatch, "stub-mem")
_clear_provider_caches()
try:
providers._discover_providers()
assert loads == [] # never imported
finally:
sys.modules.pop("stub_mem_plugin", None)
_clear_provider_caches()


def test_unresolvable_entry_point_still_loaded(monkeypatch):
"""Fail-open: an entry point whose source cannot be classified is still
loaded, so a real provider is never dropped because classification
failed (unresolvable module, thin re-export package, ...)."""
from providers.base import ProviderProfile

def _register_unresolvable():
def register():
providers.register_provider(
ProviderProfile(name="ep-unresolvable", base_url="https://c.test/v1")
)

return register

fake_eps = _FakeEntryPoints(
[
_FakeEP(
"ep-unresolvable",
_register_unresolvable,
value="no_such_module_zq",
)
]
)
import importlib.metadata as md

monkeypatch.setattr(md, "entry_points", lambda: fake_eps)
_enable(monkeypatch, "ep-unresolvable")
_clear_provider_caches()
try:
assert providers.get_provider_profile("ep-unresolvable") is not None
finally:
_clear_provider_caches()


def test_platform_import_marker_entry_point_not_loaded(monkeypatch, tmp_path):
"""A hand-written ``plugins.enabled`` entry whose name lacks the
``-platform`` suffix is still skipped when its source carries the
documented platform-adapter import (#98438).

The name convention only covers PluginManager-generated ids; the
``from gateway.config import Platform`` base import is the remaining
import-free platform signal (model-provider plugins never touch
gateway.config).
"""
loads = []
(tmp_path / "stub_platform_plugin.py").write_text(
"from gateway.config import Platform, PlatformConfig\n"
"class StubAdapter:\n"
" pass\n",
encoding="utf-8",
)
monkeypatch.syspath_prepend(str(tmp_path))

def _adapter_side_effect():
loads.append("stub-adapter") # the import itself would already fail
return object()

fake_eps = _FakeEntryPoints(
[
_FakeEP(
"stub-adapter",
_adapter_side_effect,
value="stub_platform_plugin",
)
]
)
import importlib.metadata as md

monkeypatch.setattr(md, "entry_points", lambda: fake_eps)
_enable(monkeypatch, "stub-adapter")
_clear_provider_caches()
try:
providers._discover_providers()
assert loads == [] # never imported
finally:
sys.modules.pop("stub_platform_plugin", None)
_clear_provider_caches()


def test_plain_plugin_without_markers_still_loaded(monkeypatch, tmp_path):
"""A source-bearing entry point with NO platform/memory markers keeps
the historical load path — the marker scan must not over-skip."""
from providers.base import ProviderProfile

(tmp_path / "stub_plain_plugin.py").write_text(
"# a plain plugin: no gateway import, no memory markers\n"
"def register():\n"
" pass\n",
encoding="utf-8",
)
monkeypatch.syspath_prepend(str(tmp_path))

def _plain_side_effect():
def register():
providers.register_provider(
ProviderProfile(name="ep-plain", base_url="https://d.test/v1")
)

return register

fake_eps = _FakeEntryPoints(
[_FakeEP("ep-plain", _plain_side_effect, value="stub_plain_plugin")]
)
import importlib.metadata as md

monkeypatch.setattr(md, "entry_points", lambda: fake_eps)
_enable(monkeypatch, "ep-plain")
_clear_provider_caches()
try:
assert providers.get_provider_profile("ep-plain") is not None
finally:
sys.modules.pop("stub_plain_plugin", None)
_clear_provider_caches()
Loading