Compare commits

...

1 Commits

Author SHA1 Message Date
emozilla
b00ce25c97 fix(plugins): register dynamically-loaded modules in sys.modules before exec
Dashboard plugin API routes (web_server._mount_plugin_api_routes) and
gateway event hooks (gateway.hooks.HookRegistry.discover_and_load) both
loaded Python files via importlib.util.spec_from_file_location +
exec_module without registering the resulting module in sys.modules.

That breaks any plugin or hook handler that uses `from __future__ import
annotations` together with a Pydantic BaseModel / dataclass / anything
that introspects `__module__`: at first request Pydantic tries to
resolve string-form type hints against the defining module's namespace,
can't find it by name, and raises:

  PydanticUserError: TypeAdapter[...] is not fully defined;
  you should define ... and all referenced types,
  then call `.rebuild()` on the instance.

This is what broke the kanban dashboard's 'triage' button — POST
/api/plugins/kanban/tasks validated against CreateTaskBody (a Pydantic
model in a file using `from __future__ import annotations`) and
returned 500 on every click.

The fix, applied symmetrically to both loaders:

  1. Compute module_name once.
  2. Register the module in sys.modules BEFORE exec_module.
  3. On exec_module failure, pop the half-initialized stub so subsequent
     reloads don't pick up broken state.

GETs were unaffected because they don't build a body TypeAdapter, which
is why this only surfaced when users started POSTing.
2026-04-29 08:13:57 -07:00
2 changed files with 30 additions and 7 deletions

View File

@@ -21,6 +21,7 @@ Errors in hooks are caught and logged but never block the main pipeline.
import asyncio import asyncio
import importlib.util import importlib.util
import sys
from typing import Any, Callable, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
import yaml import yaml
@@ -97,16 +98,28 @@ class HookRegistry:
print(f"[hooks] Skipping {hook_name}: no events declared", flush=True) print(f"[hooks] Skipping {hook_name}: no events declared", flush=True)
continue continue
# Dynamically load the handler module # Dynamically load the handler module.
# Register in sys.modules BEFORE exec_module so Pydantic /
# dataclasses / typing introspection can resolve forward
# references (triggered by `from __future__ import annotations`
# in the handler). Without this, a handler that declares a
# Pydantic BaseModel for webhook/event payloads fails at first
# dispatch with "TypeAdapter ... is not fully defined".
module_name = f"hermes_hook_{hook_name}"
spec = importlib.util.spec_from_file_location( spec = importlib.util.spec_from_file_location(
f"hermes_hook_{hook_name}", handler_path module_name, handler_path
) )
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
print(f"[hooks] Skipping {hook_name}: could not load handler.py", flush=True) print(f"[hooks] Skipping {hook_name}: could not load handler.py", flush=True)
continue continue
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except Exception:
sys.modules.pop(module_name, None)
raise
handle_fn = getattr(module, "handle", None) handle_fn = getattr(module, "handle", None)
if handle_fn is None: if handle_fn is None:

View File

@@ -3107,13 +3107,23 @@ def _mount_plugin_api_routes():
_log.warning("Plugin %s declares api=%s but file not found", plugin["name"], api_file_name) _log.warning("Plugin %s declares api=%s but file not found", plugin["name"], api_file_name)
continue continue
try: try:
spec = importlib.util.spec_from_file_location( module_name = f"hermes_dashboard_plugin_{plugin['name']}"
f"hermes_dashboard_plugin_{plugin['name']}", api_path, spec = importlib.util.spec_from_file_location(module_name, api_path)
)
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
continue continue
mod = importlib.util.module_from_spec(spec) mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # Register in sys.modules BEFORE exec_module so pydantic/FastAPI
# can resolve forward references (e.g. models defined in a file
# that uses `from __future__ import annotations`). Without this,
# TypeAdapter lazy-build fails at first request with
# "is not fully defined" because the module namespace isn't
# reachable by name for string-annotation resolution.
sys.modules[module_name] = mod
try:
spec.loader.exec_module(mod)
except Exception:
sys.modules.pop(module_name, None)
raise
router = getattr(mod, "router", None) router = getattr(mod, "router", None)
if router is None: if router is None:
_log.warning("Plugin %s api file has no 'router' attribute", plugin["name"]) _log.warning("Plugin %s api file has no 'router' attribute", plugin["name"])