Files
hermes-agent/hermes_cli/plugin_host.py
teknium1 5fa55d1678 feat(plugins): run third-party plugins in a per-profile plugin host (plugins.isolation: host)
plugins.isolation: host imports every non-bundled plugin in a host process started
on demand per profile with only that profile's environment, and hands plugins a
RemotePluginContext; Hermes registers proxies for tools, hooks, commands, skills and
provider objects (subclasses of the ABC it checks). Re-entrant: handlers may call
ctx back into Hermes while Hermes waits on them. Every user-code import path routes
to the host (general, memory/context-engine/cron, model-provider profiles via a
cached credential-free extraction, dashboard APIs via an ASGI bridge) or refuses.
One table names what cannot cross (live gateway objects); the static audit shown
by `hermes plugins validate/show` reads it. A host that dies is restarted with its
plugins (bounded). Default stays in_process: nothing changes unless opted in.

Why: shared-pool deployments must never run one tenant's plugin Python in an
interpreter holding another tenant's data, without plugins becoming tenant-aware.
2026-10-03 10:15:11 -07:00

446 lines
21 KiB
Python

"""Parent side of the plugin host: one process per profile that runs its third-party plugins.
With ``plugins.isolation: host`` the loader hands a general Python plugin to :class:`PluginHost`
instead of importing it. The host process imports it and runs ``register(ctx)``; every ``ctx`` call
arrives here and is replayed on the plugin's real :class:`~hermes_cli.plugins.PluginContext`, with
the plugin's callables and provider objects swapped for proxies that call back into the host. From
then on the rest of Hermes sees ordinary registrations — the registry, hook dispatch, the ledger,
unload and ``hermes plugins list`` do not know the plugin lives elsewhere, and neither does the
plugin: its code is unchanged and never learns which process or tenant it serves.
Requests the host makes while Hermes is waiting on it (a tool handler calling ``ctx.dispatch_tool``)
run in the caller's context — same profile home, secret scope and session — via the wire's
``origin`` field; spontaneous ones (a ``spawn_task`` loop) run bound to this profile's home.
When the host dies every proxy raises :class:`PluginHostUnavailable`: tool calls return an error,
hooks fail like any raising callback, and Hermes itself keeps running. A fresh host is then started
and its plugins reloaded (bounded restarts; a plugin that killed the host while loading stays out).
"""
from __future__ import annotations
import asyncio
import contextvars
import inspect
import functools
import importlib
import itertools
import logging
import os
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any, Callable, Dict, Optional
from hermes_cli.plugin_host_wire import (
Channel, PluginHostUnavailable, PluginHostUnsupported, decode, encode, signature_from,
)
logger = logging.getLogger("hermes_cli.plugins")
_HOST_MODULE = "hermes_cli.plugin_host_child"
_START_TIMEOUT_SECS = 30.0
_SHUTDOWN_GRACE_SECS = 3.0
# A host that dies is restarted (its plugins reloaded) at most this many times per window; a plugin
# that kills the host while loading is not reloaded, so one bad plugin cannot crash-loop the rest.
_RESTART_BUDGET = 3
_RESTART_WINDOW_SECS = 600.0
class PluginHost:
"""One host process for one :class:`~hermes_cli.plugins.PluginManager` (one profile home)."""
def __init__(self, manager: Any):
self._manager = manager
self._home = Path(manager.home_path)
self._lock = threading.Lock()
self._proc: Optional[subprocess.Popen] = None
self._channel: Optional[Channel] = None
self._contexts: Dict[str, Any] = {}
self._handles: Dict[int, Any] = {}
self._handle_ids = itertools.count(1)
self._base_context = self._build_base_context()
self._loading: Optional[str] = None
self._stopping = False
self._restarts: list = []
self.info: Dict[str, Any] = {}
# -- lifecycle ----------------------------------------------------------------------------------
@property
def pid(self) -> Optional[int]:
return self._proc.pid if self._proc is not None else None
@property
def alive(self) -> bool:
return (self._channel is not None and self._channel.closed_reason is None
and self._proc is not None and self._proc.poll() is None)
def _build_base_context(self) -> contextvars.Context:
context = contextvars.copy_context()
def bind() -> None:
from hermes_constants import set_hermes_home_override
set_hermes_home_override(self._home)
from agent.secret_scope import build_profile_secret_scope, is_multiplex_active, set_secret_scope
if is_multiplex_active():
set_secret_scope(build_profile_secret_scope(self._home), profile_home=str(self._home))
context.run(bind)
return context
def _argv(self) -> list:
from hermes_cli.plugin_isolation import host_launcher
return [*host_launcher(), sys.executable, "-m", _HOST_MODULE]
def _env(self) -> Dict[str, str]:
from tools.environments.local import served_profile_child_env
env = self._base_context.run(served_profile_child_env, target_home=self._home, inherit_credentials=True)
repo_root = str(Path(__file__).resolve().parents[1])
env["PYTHONPATH"] = os.pathsep.join(p for p in (repo_root, env.get("PYTHONPATH", "")) if p)
env.setdefault("HERMES_PLUGIN_HOST_LOG_LEVEL", "WARNING")
from hermes_cli.plugin_isolation import HOST_PROCESS_ENV
env[HOST_PROCESS_ENV] = "1"
return env
def ensure_started(self) -> Channel:
with self._lock:
if self.alive:
return self._channel # type: ignore[return-value]
self._stopping = False
argv = self._argv()
proc = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=self._env(), cwd=str(self._home),
bufsize=0)
threading.Thread(target=_pump_stderr, args=(proc, self._home.name), daemon=True,
name=f"plugin-host-{proc.pid}-stderr").start()
channel = Channel(proc.stdout, proc.stdin, self._handle, # type: ignore[arg-type]
name=f"plugin-host[{proc.pid}]",
on_close=self._on_channel_close,
context_for_origin=self._context_for_origin).start()
self._proc, self._channel = proc, channel
try:
self.info = channel.call("hello", {}, timeout=_START_TIMEOUT_SECS)
except Exception as exc:
self.shutdown()
raise PluginHostUnavailable(f"plugin host failed to start ({' '.join(argv)}): {exc}") from exc
logger.info("Plugin host started for %s (pid %s)", self._home, proc.pid)
return channel
def _exit_reason(self) -> str:
proc = self._proc
code = proc.poll() if proc is not None else None
if proc is not None and code is None:
try:
code = proc.wait(timeout=0.5) # the pipe closes a moment before the exit status lands
except subprocess.TimeoutExpired:
pass
return (f"the plugin host for this profile exited (code {code}); Hermes restarts it and reloads "
f"its plugins automatically" if code is not None else "the plugin host is not running")
def _on_channel_close(self, reason: str) -> None:
if self._stopping:
return
keys, culprit = [k for k in self._contexts if k != self._loading], self._loading
logger.warning("Plugin host for %s exited (%s)%s", self._home, self._exit_reason(),
f" while loading plugin '{culprit}'" if culprit else "")
if keys:
threading.Thread(target=self._restart, args=(keys,), daemon=True,
name="plugin-host-restart").start()
def _restart(self, keys: list) -> None:
now = time.monotonic()
self._restarts = [t for t in self._restarts if now - t < _RESTART_WINDOW_SECS]
if len(self._restarts) >= _RESTART_BUDGET:
logger.error("Plugin host for %s died %d times in %.0fs; not restarting it again (plugins: %s)",
self._home, len(self._restarts), _RESTART_WINDOW_SECS, ", ".join(keys))
return
self._restarts.append(now)
time.sleep(0.5 * len(self._restarts))
manager = self._manager
for key in keys:
loaded = manager._plugins.get(key)
if loaded is None or not loaded.enabled:
continue
manager.unload(key)
manager._load_plugin(loaded.manifest)
def shutdown(self) -> None:
self._stopping = True
with self._lock:
proc, channel = self._proc, self._channel
self._proc = self._channel = None
if channel is not None and channel.closed_reason is None:
try:
channel.call("shutdown", {}, timeout=_SHUTDOWN_GRACE_SECS)
except Exception:
pass
channel.close("shutdown")
if proc is not None:
try:
proc.wait(timeout=_SHUTDOWN_GRACE_SECS)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=_SHUTDOWN_GRACE_SECS)
# -- loading ------------------------------------------------------------------------------------
def load(self, manifest: Any, ctx: Any, *, module_name: Optional[str], entrypoint: bool) -> str:
"""Import ``manifest``'s plugin in the host and replay its registrations onto ``ctx``."""
from hermes_cli.plugins import PluginContext, manifest_key
channel = self.ensure_started()
plugin_key = manifest_key(manifest)
self._contexts[plugin_key] = ctx
params = {
"plugin_key": plugin_key, "plugin_id": ctx.plugin_id, "name": manifest.name,
"path": manifest.path, "module_name": module_name, "entrypoint": entrypoint,
"profile_name": self._base_context.copy().run(lambda: ctx.profile_name),
"manifest": {k: getattr(manifest, k, None) for k in (
"name", "version", "description", "author", "source", "path", "key", "kind",
"skill_namespace")},
"ctx_methods": sorted(n for n in dir(PluginContext) if not n.startswith("_")),
}
self._loading = plugin_key
try:
result = channel.call("load", params)
except BaseException:
self._contexts.pop(plugin_key, None)
raise
finally:
self._loading = None
ctx.on_unload(functools.partial(self._unload, plugin_key))
return str((result or {}).get("module") or module_name or "")
def load_instance(self, plugin_dir: Path, *, module_name: str, base_ref: str, capture: str,
ctx: Any = None) -> Any:
"""Load a category plugin (memory provider, context engine, cron scheduler) in the host and
return a proxy that is an instance of ``base_ref``. ``ctx`` receives its other registrations."""
channel = self.ensure_started()
plugin_key = f"{capture}:{Path(plugin_dir).name}"
if ctx is not None:
self._contexts[plugin_key] = ctx
from hermes_cli.plugins import PluginContext
params = {
"plugin_key": plugin_key, "plugin_id": getattr(ctx, "plugin_id", Path(plugin_dir).name),
"name": Path(plugin_dir).name, "path": str(plugin_dir), "module_name": module_name,
"base": base_ref, "capture": capture, "forward": ctx is not None,
"manifest": {"name": Path(plugin_dir).name, "path": str(plugin_dir), "source": "user"},
"ctx_methods": sorted(n for n in dir(PluginContext) if not n.startswith("_")),
}
self._loading = plugin_key
try:
ref = channel.call("load_instance", params)
finally:
self._loading = None
if ref is None:
return None
return self._object_proxy(ref, _import_ref(base_ref))
def profile_call(self, plugin_dir: str, module_name: str, profile: str, attr: str,
args: tuple, kwargs: dict) -> Any:
"""Run a model-provider profile's overridden method / callable field in the host."""
self.ensure_started()
return self._call("profile_call", {"path": plugin_dir, "module_name": module_name, "profile": profile,
"attr": attr, "args": encode(list(args)), "kwargs": encode(kwargs)})
def asgi_request(self, plugin_name: str, dashboard_dir: str, api_file: str, method: str, path: str,
query: str, headers: list, body: bytes) -> Dict[str, Any]:
"""One dashboard ``/api/plugins/<name>/`` request, served by the plugin's router in the host."""
self.ensure_started()
return self._call("asgi", {"plugin": plugin_name, "dashboard_dir": dashboard_dir, "api_file": api_file,
"method": method, "path": path, "query": query,
"headers": [list(h) for h in headers], "body": encode(body)})
def _unload(self, plugin_key: str) -> None:
self._contexts.pop(plugin_key, None)
channel = self._channel
if channel is None or channel.closed_reason is not None:
return
errors = (channel.call("unload", {"plugin_key": plugin_key}) or {}).get("errors") or []
for error in errors:
logger.warning("Plugin '%s' on_unload callback failed in the plugin host: %s", plugin_key, error)
# -- parent -> host -----------------------------------------------------------------------------
def _call(self, method: str, params: Dict[str, Any]) -> Any:
channel = self._channel
try:
if channel is None:
raise PluginHostUnavailable("not started")
return decode(channel.call(method, params), self._resolve_ref)
except PluginHostUnavailable as exc:
raise PluginHostUnavailable(self._exit_reason()) from exc
def invoke(self, ref: int, args: tuple, kwargs: dict) -> Any:
return self._call("invoke", {"ref": ref, "args": encode(list(args)), "kwargs": encode(kwargs)})
def obj_invoke(self, ref: int, method: str, args: tuple, kwargs: dict) -> Any:
return self._call("obj_invoke", {"ref": ref, "method": method, "args": encode(list(args)),
"kwargs": encode(kwargs)})
def obj_getattr(self, ref: int, name: str) -> Any:
return self._call("obj_getattr", {"ref": ref, "name": name})
def obj_setattr(self, ref: int, name: str, value: Any) -> None:
self._call("obj_setattr", {"ref": ref, "name": name, "value": encode(value)})
# -- host -> parent -----------------------------------------------------------------------------
def _context_for_origin(self, origin: Optional[int]) -> contextvars.Context:
channel = self._channel
caller = channel.context_of(origin) if channel is not None else None
return (caller or self._base_context).copy()
def _handle(self, method: str, params: Dict[str, Any], _origin: Optional[int]) -> Any:
if method == "ctx":
return self._serve_ctx(params)
if method == "facade":
return self._serve_facade(params)
if method == "dispose":
handle = self._handles.pop(int(params["handle"]), None)
if handle is not None:
handle.dispose()
return None
raise ValueError(f"unknown plugin host request {method!r}")
def _plugin_ctx(self, params: Dict[str, Any]) -> Any:
ctx = self._contexts.get(str(params.get("plugin")))
if ctx is None:
raise LookupError(f"plugin {params.get('plugin')!r} is not loaded in this host")
return ctx
def _serve_ctx(self, params: Dict[str, Any]) -> Any:
from hermes_cli.plugin_isolation import (
HOST_OBJECT_BASES, HOST_SKIPPED_CTX_METHODS, HOST_UNSUPPORTED_CTX_METHODS,
)
ctx = self._plugin_ctx(params)
method = str(params.get("method") or "")
if (method.startswith("_") or method in HOST_UNSUPPORTED_CTX_METHODS
or method in HOST_SKIPPED_CTX_METHODS or method in {"on_unload", "spawn_task"}):
raise PluginHostUnsupported(f"ctx.{method}() cannot be called across the plugin host")
base = _import_ref(HOST_OBJECT_BASES[method]) if method in HOST_OBJECT_BASES else None
resolve = functools.partial(self._resolve_ref, base=base)
args = decode(params.get("args") or [], resolve)
kwargs = decode(params.get("kwargs") or {}, resolve)
result = getattr(ctx, method)(*args, **kwargs)
return self._encode_for_host(result)
def _serve_facade(self, params: Dict[str, Any]) -> Any:
from hermes_cli.plugin_isolation import HOST_REMOTE_FACADES
ctx = self._plugin_ctx(params)
facade, method = str(params.get("facade") or ""), str(params.get("method") or "")
if facade not in HOST_REMOTE_FACADES or method.startswith("_"):
raise PluginHostUnsupported(f"ctx.{facade}.{method} is not available in the plugin host")
target = getattr(getattr(ctx, facade), method)
if not callable(target):
return self._encode_for_host(target)
if params.get("probe"):
return {"__method__": True}
args = decode(params.get("args") or [], self._resolve_ref)
kwargs = decode(params.get("kwargs") or {}, self._resolve_ref)
result = target(*args, **kwargs)
if asyncio.iscoroutine(result):
result = asyncio.run(result)
return self._encode_for_host(result)
def _encode_for_host(self, value: Any) -> Any:
from hermes_cli.plugins_ledger import PluginRegistration
def refs(obj: Any) -> Optional[dict]:
if isinstance(obj, PluginRegistration):
handle_id = next(self._handle_ids)
self._handles[handle_id] = obj
return {"__handle__": handle_id, "kind": obj.kind, "key": obj.key}
return None
return encode(value, refs)
# -- proxies ------------------------------------------------------------------------------------
def _resolve_ref(self, ref: Dict[str, Any], base: Optional[type] = None) -> Any:
if "__callable__" in ref:
return self._callable_proxy(ref)
if "__object__" in ref:
if base is None:
raise PluginHostUnsupported(f"a {ref.get('type', 'plugin')} object cannot be passed here "
"across the plugin host")
return self._object_proxy(ref, base)
raise PluginHostUnsupported("registration handles are owned by the plugin host")
def _callable_proxy(self, ref: Dict[str, Any]) -> Callable[..., Any]:
ref_id = int(ref["__callable__"])
async def async_proxy(*args: Any, **kwargs: Any) -> Any:
return await asyncio.to_thread(self.invoke, ref_id, args, kwargs)
def sync_proxy(*args: Any, **kwargs: Any) -> Any:
return self.invoke(ref_id, args, kwargs)
proxy: Any = async_proxy if ref.get("async") else sync_proxy
signature = signature_from(ref.get("sig"))
if signature is not None:
proxy.__signature__ = signature # type: ignore[attr-defined]
proxy.__name__ = str(ref.get("name") or "callback")
proxy.__qualname__ = str(ref.get("qualname") or proxy.__name__)
# Tool override policy and hook attribution key on the defining module.
proxy.__module__ = str(ref.get("module") or __name__)
proxy.__hermes_plugin_host_ref__ = ref_id # type: ignore[attr-defined]
return proxy
def _object_proxy(self, ref: Dict[str, Any], base: type) -> Any:
ref_id = int(ref["__object__"])
host = self
live = frozenset(ref.get("live") or ())
namespace: Dict[str, Any] = {}
for name, meta in (ref.get("methods") or {}).items():
namespace[name] = _method_proxy(host, ref_id, name, meta)
for name, value in (ref.get("static") or {}).items():
decoded = decode(value, self._resolve_ref)
namespace[name] = staticmethod(decoded) if callable(decoded) else decoded
def __getattribute__(self_: Any, name: str) -> Any: # noqa: N807
if name in live:
return host.obj_getattr(ref_id, name)
return object.__getattribute__(self_, name)
def __setattr__(self_: Any, name: str, value: Any) -> None: # noqa: N807
if name in live:
host.obj_setattr(ref_id, name, value)
else:
object.__setattr__(self_, name, value)
namespace.update(__getattribute__=__getattribute__, __setattr__=__setattr__,
__module__=__name__, __hermes_plugin_host_ref__=ref_id,
__repr__=lambda self_: f"<plugin-host {ref.get('type')} #{ref_id}>")
cls = type(f"Hosted{ref.get('type') or base.__name__}", (base,), namespace)
cls.__abstractmethods__ = frozenset()
return object.__new__(cls)
def _method_proxy(host: PluginHost, ref_id: int, name: str, meta: Dict[str, Any]) -> Callable[..., Any]:
async def async_method(self_: Any, *args: Any, **kwargs: Any) -> Any:
return await asyncio.to_thread(host.obj_invoke, ref_id, name, args, kwargs)
def sync_method(self_: Any, *args: Any, **kwargs: Any) -> Any:
return host.obj_invoke(ref_id, name, args, kwargs)
method: Any = async_method if meta.get("async") else sync_method
method.__name__ = method.__qualname__ = name
signature = signature_from(meta.get("sig"))
if signature is not None: # callers introspect accepted kwargs; bound access drops ``self`` again
self_param = inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD)
method.__signature__ = signature.replace(parameters=[self_param, *signature.parameters.values()])
return method
def _import_ref(ref: str) -> type:
module, attr = ref.split(":")
return getattr(importlib.import_module(module), attr)
def _pump_stderr(proc: subprocess.Popen, label: str) -> None:
"""Plugin output and host logs go to the Hermes log, tagged with the host's profile."""
stream = proc.stderr
if stream is None:
return
for raw in iter(stream.readline, b""):
line = raw.decode("utf-8", "replace").rstrip()
if line:
logger.info("plugin host [%s]: %s", label, line)