refactor(cli/plugins): extract manifest parsing, discovery scanning/gating, and plugin state into plugins_manifest/plugins_discovery/plugins_state

This commit is contained in:
Teknium
2026-09-02 15:32:17 -07:00
parent 88b74d6ef0
commit 1fcc26e2ba
5 changed files with 1037 additions and 913 deletions
+64 -907
View File
File diff suppressed because it is too large Load Diff
+274
View File
@@ -0,0 +1,274 @@
"""Plugin discovery: directory scanning, entry-point manifests, and the enable/disable gate.
Split out of :mod:`hermes_cli.plugins`. Names that tests patch on the origin
(``get_bundled_plugins_dir``, ``_env_enabled``) are looked up lazily through it.
"""
from __future__ import annotations
import importlib.metadata
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any, List, Optional, Set
from hermes_constants import get_hermes_home
from hermes_cli.config import cfg_get
from hermes_cli.plugin_capabilities import VALID_CAPABILITY_IDS
from hermes_cli.plugin_capabilities import parse_declared_capabilities as _parse_declared_capabilities
from hermes_cli.plugins_manifest import (
PluginManifest,
_detect_kind_from_source,
_resolve_module_source,
parse_manifest_file,
portable_plugin_manifest,
)
from hermes_cli.relay_plugin_cutover import LEGACY_RELAY_PLUGIN_KEYS, RELAY_PLUGINS_CONFIG_ENV
logger = logging.getLogger("hermes_cli.plugins")
ENTRY_POINTS_GROUP = "hermes_agent.plugins"
ENTRY_POINT_CAPABILITIES_GROUP = "hermes_agent.plugin_capabilities"
def _select_entry_point_group(entry_points: Any, group: str) -> list:
"""Return one metadata entry-point group across supported Python APIs."""
if hasattr(entry_points, "select"):
return list(entry_points.select(group=group))
if isinstance(entry_points, dict):
return list(entry_points.get(group, []))
return [ep for ep in entry_points if ep.group == group]
def discover_entrypoint_manifests() -> List["PluginManifest"]:
"""Return metadata-only manifests for installed entry-point plugins.
Kind comes from an import-free source scan (memory/model providers route to their own
discovery). Capabilities come from the companion ``hermes_agent.plugin_capabilities`` group
(``<plugin-id>.<capability-id>`` entries pointing at the same object), so consent works without
importing plugin code. Failures are isolated per entry point.
"""
manifests: List[PluginManifest] = []
try:
eps = importlib.metadata.entry_points()
group_eps = _select_entry_point_group(eps, ENTRY_POINTS_GROUP)
capability_eps = _select_entry_point_group(eps, ENTRY_POINT_CAPABILITIES_GROUP)
except Exception as exc:
logger.debug("Entry-point scan failed: %s", exc)
return manifests
for ep in group_eps:
try:
capabilities = []
for capability in VALID_CAPABILITY_IDS:
declaration_name = f"{ep.name}.{capability}"
if any(
declaration.name == declaration_name
and declaration.value == ep.value
for declaration in capability_eps
):
capabilities.append(capability)
dist = getattr(ep, "dist", None)
metadata = getattr(dist, "metadata", None)
manifest = PluginManifest(
name=ep.name,
version=str(getattr(dist, "version", "") or ""),
description=(
str(metadata.get("Summary", "") or "")
if metadata is not None
else ""
),
source="entrypoint",
path=ep.value,
key=ep.name,
capabilities=_parse_declared_capabilities(
capabilities, ep.name
),
)
manifest.kind = _classify_entrypoint_value_kind(ep.value)
manifests.append(manifest)
except Exception as exc:
logger.debug("Entry-point manifest for %r skipped: %s", getattr(ep, "name", "?"), exc)
return manifests
def _classify_entrypoint_value_kind(value: str) -> str:
"""Classify an entry-point target by import-free source scan (unresolvable -> standalone)."""
try:
module_name = str(value).split(":", 1)[0].strip()
if not module_name:
return "standalone"
return _detect_kind_from_source(_resolve_module_source(module_name)) or "standalone"
except Exception:
return "standalone"
def _get_disabled_plugins() -> set:
"""Read ``plugins.disabled`` — a deny-list that wins over ``plugins.enabled``."""
try:
from hermes_cli.config import load_config
config = load_config()
disabled = cfg_get(config, "plugins", "disabled", default=[])
return set(disabled) if isinstance(disabled, list) else set()
except Exception:
return set()
def _get_enabled_plugins() -> Optional[set]:
"""Read the ``plugins.enabled`` allow-list (plugins are opt-in).
``None`` = key missing/malformed ("nothing enabled yet"; the first ``migrate_config`` run
grandfathers installed user plugins); ``set()`` = explicitly empty; else the allow-list.
"""
try:
from hermes_cli.config import load_config
plugins_cfg = load_config().get("plugins")
enabled = plugins_cfg.get("enabled") if isinstance(plugins_cfg, dict) else None
return set(enabled) if isinstance(enabled, list) else None
except Exception:
return None
def scan_directory(
path: Path,
source: str,
*,
skip_names: Optional[Set[str]] = None,
prefix: str = "",
depth: int = 0,
) -> List[PluginManifest]:
"""Read manifests under *path*: flat ``<root>/<name>/plugin.yaml`` (key ``name``) or category
``<root>/<cat>/<name>/plugin.yaml`` (key ``cat/name``; a manifest-less directory recurses one
level, depth capped at two). *skip_names* ignores top-level names; portable ``plugin.json``
packages are accepted alongside YAML manifests."""
manifests: List[PluginManifest] = []
if not path.is_dir():
return manifests
for child in sorted(path.iterdir()):
if not child.is_dir():
continue
if depth == 0 and skip_names and child.name in skip_names:
continue
manifest_file = child / "plugin.yaml"
if not manifest_file.exists():
manifest_file = child / "plugin.yml"
if manifest_file.exists():
manifest = parse_manifest_file(manifest_file, child, source, prefix)
if manifest is not None:
manifests.append(manifest)
continue
portable_file = child / "plugin.json"
if portable_file.exists() or portable_file.is_symlink():
try:
manifests.append(portable_plugin_manifest(child, source, prefix))
except Exception as exc:
logger.warning("Failed to parse %s: %s", portable_file, exc)
continue
if depth >= 1:
logger.debug("Skipping %s (no plugin.yaml, depth cap reached)", child)
continue
sub_prefix = f"{prefix}/{child.name}" if prefix else child.name
manifests.extend(scan_directory(child, source, prefix=sub_prefix, depth=depth + 1))
return manifests
def collect_directory_manifests() -> List[PluginManifest]:
"""Read directory manifests in full-discovery order (bundled top-level, bundled/platforms, user,
opt-in project) without loading or mutating anything, so startup probes share the exact
precedence/containment rules of the real discovery sweep."""
from hermes_cli import plugins as _origin # patched names resolve through the origin
manifests: List[PluginManifest] = []
# Excluded bundled top-level categories have their own discovery; platforms scan separately.
repo_plugins = _origin.get_bundled_plugins_dir()
logger.debug("Scanning bundled plugins: %s", repo_plugins)
bundled = scan_directory(
repo_plugins, "bundled",
skip_names={"memory", "context_engine", "platforms", "model-providers"},
)
logger.debug(" bundled (top-level): %d manifest(s)", len(bundled))
manifests.extend(bundled)
bundled_platforms = scan_directory(repo_plugins / "platforms", "bundled")
logger.debug(" bundled/platforms: %d manifest(s)", len(bundled_platforms))
manifests.extend(bundled_platforms)
user_dir = get_hermes_home() / "plugins"
logger.debug("Scanning user plugins: %s", user_dir)
user_manifests = scan_directory(user_dir, "user")
logger.debug(" user: %d manifest(s)", len(user_manifests))
manifests.extend(user_manifests)
if _origin._env_enabled("HERMES_ENABLE_PROJECT_PLUGINS"):
project_dir = Path.cwd() / ".hermes" / "plugins"
logger.debug("Scanning project plugins: %s", project_dir)
project_manifests = scan_directory(project_dir, "project")
logger.debug(" project: %d manifest(s)", len(project_manifests))
manifests.extend(project_manifests)
else:
logger.debug("Project plugins disabled (set HERMES_ENABLE_PROJECT_PLUGINS=1 to enable)")
return manifests
@dataclass(frozen=True)
class ManifestGate:
"""Routing verdict for one winning manifest (see :func:`gate_manifest`)."""
action: str # "load" (dependency-ordered pass) | "placeholder" | "load_now" | "defer"
enabled: bool = False
error: Optional[str] = None
log: Optional[tuple] = None # (level, message, *args)
def gate_manifest(
manifest: PluginManifest, disabled: Set[str], enabled: Optional[Set[str]]
) -> ManifestGate:
"""Decide how one winning manifest is handled. Gate order matters: legacy relay refusal, explicit
disable, category-owned kinds (exclusive / model-provider), bundled auto-loads (backend now,
platform deferred), then ``plugins.enabled`` opt-in (path-derived key or legacy bare name)."""
lookup_key = manifest.key or manifest.name
name = manifest.name
# Relay lifecycle is core-owned; an old plugin copy would compete for its registries.
if lookup_key in LEGACY_RELAY_PLUGIN_KEYS or name in LEGACY_RELAY_PLUGIN_KEYS:
error = (
"removed — Relay lifecycle is owned by Hermes core; configure "
f"{RELAY_PLUGINS_CONFIG_ENV} instead"
)
return ManifestGate(
"placeholder", error=error,
log=(logging.WARNING, "Refusing to load removed Hermes Relay plugin '%s'; %s", lookup_key, error),
)
if lookup_key in disabled or name in disabled:
return ManifestGate(
"placeholder", error="disabled via config",
log=(logging.DEBUG, "Skipping disabled plugin '%s'", lookup_key),
)
# Exclusive plugins (memory providers) have their own activation path; record only.
if manifest.kind == "exclusive":
return ManifestGate(
"placeholder", error="exclusive plugin — activate via <category>.provider config",
log=(logging.DEBUG, "Skipping '%s' (exclusive, handled by category discovery)", lookup_key),
)
# Model providers load via providers/__init__.py; a second import here would create two
# ProviderProfile instances and break the bundled-vs-user "last writer wins" override.
if manifest.kind == "model-provider":
return ManifestGate(
"placeholder", enabled=True,
log=(logging.DEBUG, "Skipping '%s' (model-provider, handled by providers/ discovery)", lookup_key),
)
# Bundled backends auto-load; selection among them is ``<category>.provider`` config.
if manifest.source == "bundled" and manifest.kind == "backend":
return ManifestGate("load_now")
# Bundled platforms register LAZILY: eagerly importing ~20 heavy SDKs added seconds to every
# `hermes` invocation. A deferred loader keeps every platform available on first use.
if manifest.source == "bundled" and manifest.kind == "platform":
return ManifestGate("defer")
if enabled is None or not (lookup_key in enabled or name in enabled):
return ManifestGate(
"placeholder",
error=f"not enabled in config (run `hermes plugins enable {lookup_key}` to activate)",
log=(logging.DEBUG, "Skipping '%s' (not in plugins.enabled)", lookup_key),
)
return ManifestGate("load")
+503
View File
@@ -0,0 +1,503 @@
"""Plugin manifest model and parsing (``plugin.yaml`` v1/v2, portable ``plugin.json``).
Split out of :mod:`hermes_cli.plugins`; validation warns and never fails a load.
"""
from __future__ import annotations
import hashlib
import importlib.util
import logging
from contextlib import suppress
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Mapping, Optional, Set, Union
from utils import fast_safe_load
from hermes_cli.plugin_capabilities import parse_declared_capabilities as _parse_declared_capabilities
try:
import yaml
except ImportError: # pragma: no cover – yaml is optional at import time
yaml = None # type: ignore[assignment]
logger = logging.getLogger("hermes_cli.plugins")
def _plugins_debug() -> bool:
from hermes_cli import plugins as _origin
return _origin._PLUGINS_DEBUG
_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform", "model-provider"}
def _portable_skill_namespace(key: str) -> str:
"""Return a readable, collision-resistant namespace for a portable plugin."""
slug = "".join(
ch if ch.isascii() and (ch.isalnum() or ch in "_-") else "-"
for ch in key.lower()
)
slug = slug.strip("-_") or "plugin"
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:8]
return f"agent-plugin-{slug}-{digest}"
def _display_author(value: object) -> str:
"""Normalize a manifest author value for the string PluginManifest field."""
if isinstance(value, Mapping):
return ", ".join(
str(value[field])
for field in ("name", "email", "url")
if value.get(field)
)
return "" if value is None else str(value)
# Manifest v2 parsing. Unknown plugin.yaml fields are forward-compat surface: warn (debug for v1
# files, warning for v2+) and continue loading.
_KNOWN_MANIFEST_FIELDS: Set[str] = {
# v1
"name", "version", "description", "author", "requires_env",
"provides_tools", "provides_hooks", "kind", "hooks", "label",
"optional_env", "platforms", "external_dependencies", "pip_dependencies",
"provides_browser_providers", "provides_web_providers",
# v2
"manifest_version", "api_version", "requires_plugins",
"python_dependencies", "config_schema", "license", "homepage", "tags",
# owned by sibling sub-issues but reserved so their manifests don't warn
"capabilities", "emits", "listens", "hermes", "depends",
}
# Highest manifest schema version this Hermes understands.
SUPPORTED_MANIFEST_VERSION = 2
_CONFIG_SCHEMA_TYPES: Dict[str, tuple] = {
"str": (str,),
"string": (str,),
"int": (int,),
"integer": (int,),
"float": (int, float),
"number": (int, float),
"bool": (bool,),
"boolean": (bool,),
"list": (list,),
"array": (list,),
"dict": (dict,),
"object": (dict,),
}
def _manifest_field_of_type(data: Mapping, key: str, field_name: str, typ, what: str):
"""Return ``data[field_name]`` when absent or of ``typ``; warn and return None otherwise."""
raw = data.get(field_name)
if raw is not None and not isinstance(raw, typ):
logger.warning("Plugin %s: %s must be %s; ignoring", key, field_name, what)
return None
return raw
def _parse_manifest_v2_fields(data: Mapping, key: str) -> Dict[str, Any]:
"""Validate/normalize manifest v2 fields into PluginManifest kwargs (warnings, never failures)."""
out: Dict[str, Any] = {}
# manifest_version — absent means v1 (supported forever).
raw_mv = data.get("manifest_version", 1)
try:
mv = int(raw_mv)
except (TypeError, ValueError):
logger.warning(
"Plugin %s: manifest_version %r is not an integer; treating as 1",
key, raw_mv,
)
mv = 1
if mv > SUPPORTED_MANIFEST_VERSION:
logger.warning(
"Plugin %s: manifest_version %d is newer than this Hermes "
"supports (%d); loading anyway and ignoring unknown fields",
key, mv, SUPPORTED_MANIFEST_VERSION,
)
out["manifest_version"] = mv
# api_version — plugin API generation (independent of manifest_version).
raw_api = data.get("api_version")
out["api_version"] = None
if raw_api is not None:
try:
out["api_version"] = int(raw_api)
except (TypeError, ValueError):
logger.warning("Plugin %s: api_version %r is not an integer; ignoring", key, raw_api)
# requires_plugins — list of {id, version_range?} (str shorthand ok).
deps: List[Dict[str, Any]] = []
for item in _manifest_field_of_type(data, key, "requires_plugins", list, "a list") or []:
if isinstance(item, str):
deps.append({"id": item, "version_range": None})
elif isinstance(item, Mapping) and isinstance(item.get("id"), str) and item["id"]:
vr = item.get("version_range")
deps.append({"id": item["id"], "version_range": str(vr) if vr is not None else None})
else:
logger.warning(
"Plugin %s: requires_plugins entry %r must be a plugin id "
"string or a {id, version_range} mapping; skipping", key, item,
)
out["requires_plugins"] = deps
# python_dependencies — validated and surfaced ONLY; never auto-installed.
pydeps: List[str] = []
raw_pydeps = _manifest_field_of_type(
data, key, "python_dependencies", list, "a list of requirement strings"
)
for item in raw_pydeps or []:
if isinstance(item, str) and item.strip():
pydeps.append(item.strip())
else:
logger.warning(
"Plugin %s: python_dependencies entry %r must be a non-empty "
"requirement string; skipping", key, item,
)
out["python_dependencies"] = pydeps
# config_schema — mapping of key -> {type?, default?, description?, required?}.
schema: Dict[str, Any] = {}
raw_schema = _manifest_field_of_type(data, key, "config_schema", Mapping, "a mapping")
for skey, spec in (raw_schema or {}).items():
if not isinstance(spec, Mapping):
logger.warning(
"Plugin %s: config_schema entry %r must be a mapping "
"(e.g. {type: str}); skipping", key, skey,
)
continue
stype = spec.get("type")
if stype is not None and str(stype).lower() not in _CONFIG_SCHEMA_TYPES:
logger.warning(
"Plugin %s: config_schema key %r declares unknown type %r "
"(known: %s); type check will be skipped for it",
key, skey, stype, ", ".join(sorted(_CONFIG_SCHEMA_TYPES)),
)
schema[str(skey)] = dict(spec)
out["config_schema"] = schema
# Standard metadata.
out["license"] = str(data.get("license") or "")
out["homepage"] = str(data.get("homepage") or "")
raw_tags = _manifest_field_of_type(data, key, "tags", list, "a list")
out["tags"] = [str(t) for t in (raw_tags or [])]
# Forward compat: unknown fields warn (never fail); v1 manifests only at debug.
unknown = sorted(set(data.keys()) - _KNOWN_MANIFEST_FIELDS)
if unknown:
log = logger.warning if mv >= 2 else logger.debug
log(
"Plugin %s: unknown manifest field(s) ignored: %s "
"(newer manifest schema or typo; plugin still loads)",
key, ", ".join(unknown),
)
return out
def validate_config_schema(plugin_id: str, schema: Mapping, settings: Mapping) -> List[str]:
"""Return actionable warning strings for settings vs config_schema mismatches (never raises)."""
warnings: List[str] = []
if not isinstance(schema, Mapping) or not isinstance(settings, Mapping):
return warnings
for skey, spec in schema.items():
if not isinstance(spec, Mapping):
continue
present = skey in settings
if not present:
if spec.get("required") and "default" not in spec:
warnings.append(
f"plugins.entries.{plugin_id}.settings.{skey} is required "
"by the plugin's config_schema but is not set"
)
continue
stype = spec.get("type")
expected = _CONFIG_SCHEMA_TYPES.get(str(stype).lower()) if stype else None
if expected is not None:
value = settings[skey]
# bool is an int subclass — don't let True satisfy int/float.
ok = isinstance(value, expected) and not (
isinstance(value, bool) and bool not in expected
)
if not ok:
warnings.append(
f"plugins.entries.{plugin_id}.settings.{skey} should be "
f"{stype} (got {type(value).__name__})"
)
return warnings
def resolve_plugin_load_order(manifests: Mapping[str, "PluginManifest"]) -> List[str]:
"""Return plugin keys in dependency order: B before A when A requires B; alphabetical ties.
A cycle warns and falls back to alphabetical order for all; a missing dependency warns once
but never removes the dependent plugin (loads never hard-fail on advisory deps).
"""
import graphlib
keys = sorted(manifests.keys())
by_name: Dict[str, str] = {}
for k in keys:
name = manifests[k].name
if name and name not in by_name:
by_name[name] = k
def _resolve_dep(dep_id: str) -> Optional[str]:
if dep_id in manifests:
return dep_id
return by_name.get(dep_id)
edges: Dict[str, Set[str]] = {k: set() for k in keys}
for k in keys:
for dep in manifests[k].requires_plugins:
dep_id = dep.get("id") if isinstance(dep, Mapping) else None
if not dep_id:
continue
resolved = _resolve_dep(dep_id)
if resolved is None:
logger.warning(
"Plugin %s requires plugin '%s' which is not enabled/"
"installed; loading anyway (probe availability at runtime "
"via ctx.has_plugin). Run `hermes plugins enable %s` if "
"it is installed.",
k, dep_id, dep_id,
)
continue
if resolved == k:
logger.warning("Plugin %s declares a dependency on itself; ignoring", k)
continue
edges[k].add(resolved)
sorter = graphlib.TopologicalSorter(edges)
try:
sorter.prepare()
except graphlib.CycleError as exc:
cycle = exc.args[1] if len(exc.args) > 1 else []
logger.warning(
"Plugin dependency cycle detected (%s); falling back to "
"alphabetical load order for all plugins",
" -> ".join(str(c) for c in cycle),
)
return keys
ordered: List[str] = []
while sorter.is_active():
ready = sorted(sorter.get_ready())
ordered.extend(ready)
sorter.done(*ready)
return ordered
def _detect_kind_from_source(source_text: str) -> Optional[str]:
"""Return the kind implied by source markers (mirrors plugins/memory ``_is_memory_provider_dir``).
Memory-provider markers -> ``exclusive``; ``register_provider`` + ``ProviderProfile`` ->
``model-provider``; else ``None``. Keeps both kinds out of the general manager's eager import.
"""
if "register_memory_provider" in source_text or "MemoryProvider" in source_text:
return "exclusive"
if "register_provider" in source_text and "ProviderProfile" in source_text:
return "model-provider"
return None
def _read_source_from_origin(origin: Optional[str], limit: int = 8192) -> str:
"""First ``limit`` chars of a module's source (``.pyc`` mapped back to ``.py``); "" on failure."""
if not origin:
return ""
if origin.endswith((".pyc", ".pyo")):
try:
origin = importlib.util.source_from_cache(origin)
except Exception:
return ""
if not origin.endswith(".py"):
return ""
try:
return Path(origin).read_text(encoding="utf-8", errors="replace")[:limit]
except Exception:
return ""
def resolve_module_origin(module_name: str) -> Optional[str]:
"""Return a module's source path WITHOUT importing it, or ``None``.
``find_spec`` on a dotted name imports the parent package, so only the top-level name uses it;
remaining segments are walked through ``submodule_search_locations`` by hand. Namespace/zipped/
extension modules return ``None``. Shared with ``plugins/memory/__init__.py``.
"""
parts = [p for p in module_name.split(".") if p]
if not parts:
return None
try:
spec = importlib.util.find_spec(parts[0])
if spec is None or not spec.origin:
return None
if len(parts) == 1:
return spec.origin
search_paths = spec.submodule_search_locations
if not search_paths:
return None
for i, part in enumerate(parts[1:], start=2):
found_origin = None
next_paths = None
for base in search_paths:
base = Path(base)
pkg_init = base / part / "__init__.py"
if pkg_init.is_file():
found_origin = str(pkg_init)
next_paths = [base / part]
break
mod_file = base / (part + ".py")
if mod_file.is_file():
found_origin = str(mod_file)
break
if found_origin is None:
return None
if i == len(parts) or next_paths is None:
return found_origin
search_paths = next_paths
return None
except Exception:
return None
def _resolve_module_source(module_name: str, limit: int = 8192) -> str:
"""First ``limit`` chars of a module's source without importing it ("" when unresolvable)."""
return _read_source_from_origin(resolve_module_origin(module_name), limit)
@dataclass
class PluginManifest:
"""Parsed representation of a plugin.yaml manifest."""
name: str
version: str = ""
description: str = ""
author: str = ""
requires_env: List[Union[str, Dict[str, Any]]] = field(default_factory=list)
provides_tools: List[str] = field(default_factory=list)
provides_hooks: List[str] = field(default_factory=list)
source: str = "" # "bundled", "user", "project", or "entrypoint"
path: Optional[str] = None
# ``standalone`` (default; opt-in via plugins.enabled) | ``backend`` (pluggable backend for a
# core tool; bundled auto-load, user-installed gated) | ``exclusive`` (one active provider,
# selected via <category>.provider; own discovery, general scanner skips) | ``platform``
# (gateway adapter; bundled auto-load, user-installed gated as untrusted code).
kind: str = "standalone"
# Path-derived registry key used by plugins.enabled/disabled and `hermes plugins list`:
# ``disk-cleanup`` for a flat plugin, ``image_gen/openai`` for a category plugin. Empty -> name.
key: str = ""
portable: bool = False
skill_namespace: str = ""
# Declared capability ids, normalized to KNOWN ids. Declaration is consent metadata, NOT a
# grant: live only via plugins.entries.<id>.granted_capabilities or the legacy allow_* key.
capabilities: List[str] = field(default_factory=list)
# Manifest v2 fields — all optional and additive. manifest_version versions the FILE FORMAT
# (v1 supported forever); api_version is the runtime plugin API generation (None = current).
manifest_version: int = 1
api_version: Optional[int] = None
# Advisory deps [{"id", "version_range"}]: missing ones warn but load; they order the load.
requires_plugins: List[Dict[str, Any]] = field(default_factory=list)
# Declared pip deps — VALIDATED AND SURFACED ONLY, never auto-installed.
python_dependencies: List[str] = field(default_factory=list)
# Schema for plugins.entries.<id>.settings; mismatches warn, never fail.
config_schema: Dict[str, Any] = field(default_factory=dict)
license: str = ""
homepage: str = ""
tags: List[str] = field(default_factory=list)
# Event-bus declarations, advisory (discoverability only): ``emits`` bare names published under
# ``<key>:``; ``listens`` fully-qualified ``<plugin>:<event>`` names.
emits: List[str] = field(default_factory=list)
listens: List[str] = field(default_factory=list)
def portable_plugin_manifest(child: Path, source: str, prefix: str) -> PluginManifest:
"""Build the manifest for a portable Agent Plugin directory (``plugin.json``); diagnostics warn."""
from hermes_cli.agent_plugins import read_agent_plugin_manifest
data, diagnostics = read_agent_plugin_manifest(child)
for diagnostic in diagnostics:
logger.warning("Agent Plugin '%s': %s", child, diagnostic.message)
key = f"{prefix}/{child.name}" if prefix else data["name"]
return PluginManifest(
name=data["name"],
version=data.get("version", ""),
description=data.get("description", ""),
author=_display_author(data.get("author", "")),
source=source,
path=str(child),
key=key,
portable=True,
skill_namespace=_portable_skill_namespace(key),
)
def _manifest_kind(data: Mapping, key: str, plugin_dir: Path) -> str:
"""Normalize ``kind``; undeclared memory/model providers are auto-detected from ``__init__.py``
so they route to their own discovery instead of the general manager."""
raw_kind = data.get("kind", "standalone")
if not isinstance(raw_kind, str):
raw_kind = "standalone"
kind = raw_kind.strip().lower()
if kind not in _VALID_PLUGIN_KINDS:
logger.warning(
"Plugin %s: unknown kind '%s' (valid: %s); treating as 'standalone'",
key, raw_kind, ", ".join(sorted(_VALID_PLUGIN_KINDS)),
)
kind = "standalone"
if kind == "standalone" and "kind" not in data:
init_file = plugin_dir / "__init__.py"
if init_file.exists():
with suppress(Exception):
detected = _detect_kind_from_source(
init_file.read_text(errors="replace", encoding="utf-8")[:8192]
)
if detected:
kind = detected
logger.debug("Plugin %s: detected %s, treating as kind='%s'", key, detected, detected)
return kind
def parse_manifest_file(
manifest_file: Path, plugin_dir: Path, source: str, prefix: str
) -> Optional[PluginManifest]:
"""Parse one ``plugin.yaml`` into a :class:`PluginManifest`; ``None`` (warned) on failure."""
try:
if yaml is None:
logger.warning("PyYAML not installed – cannot load %s", manifest_file)
return None
data = fast_safe_load(manifest_file.read_text(encoding="utf-8")) or {}
name = data.get("name", plugin_dir.name)
key = f"{prefix}/{plugin_dir.name}" if prefix else name
kind = _manifest_kind(data, key, plugin_dir)
logger.debug(
"Parsed manifest: key=%s name=%s kind=%s source=%s path=%s",
key, name, kind, source, plugin_dir,
)
return PluginManifest(
name=name,
version=str(data.get("version", "")),
description=data.get("description", ""),
author=_display_author(data.get("author", "")),
requires_env=data.get("requires_env", []),
provides_tools=data.get("provides_tools", []),
provides_hooks=data.get("provides_hooks", []),
source=source,
path=str(plugin_dir),
kind=kind,
key=key,
capabilities=_parse_declared_capabilities(data.get("capabilities"), name),
**_parse_manifest_v2_fields(data, key),
emits=data.get("emits") or [],
listens=data.get("listens") or [],
)
except Exception as exc:
logger.warning("Failed to parse %s: %s", manifest_file, exc, exc_info=_plugins_debug())
return None
+192
View File
@@ -0,0 +1,192 @@
"""Plugin-owned durable state and namespaced settings helpers (split out of hermes_cli.plugins)."""
from __future__ import annotations
import json
import os
import re
import threading
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Dict, Mapping
from hermes_constants import get_hermes_home
from hermes_cli.plugins_manifest import _portable_skill_namespace
_PLUGIN_SETTING_SEGMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
_PLUGIN_SETTING_RESERVED_ROOTS = frozenset({"model", "plugins", "security", "settings"})
_PLUGIN_STATE_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
_PLUGIN_STATE_QUOTA_BYTES = 10 * 1024 * 1024
_PLUGIN_STATE_LOCKS: Dict[str, threading.RLock] = {}
_PLUGIN_STATE_LOCKS_GUARD = threading.Lock()
def _plugin_relative_segments(key: str) -> tuple[str, ...]:
"""Validate/split a plugin-relative settings key; global paths, traversal, and core roots are
rejected before any config read."""
if not isinstance(key, str):
raise ValueError("Expected a plugin-relative config key string")
segments = tuple(key.split("."))
if (
not key
or "/" in key
or "\\" in key
or any(
not _PLUGIN_SETTING_SEGMENT_RE.fullmatch(segment) for segment in segments
)
or segments[0].lower() in _PLUGIN_SETTING_RESERVED_ROOTS
):
raise ValueError(
"Expected a plugin-relative config key such as 'endpoint' or "
"'retry.policy'; global, cross-plugin, and traversal paths are forbidden"
)
return segments
def _nested_plugin_value(root: object, segments: tuple[str, ...], default: Any) -> Any:
current = root
for segment in segments:
if not isinstance(current, Mapping) or segment not in current:
return default
current = current[segment]
return current
def _nested_plugin_mapping(segments: tuple[str, ...], value: Any) -> dict[str, Any]:
nested: Any = value
for segment in reversed(segments):
nested = {segment: nested}
return nested
def _plugin_data_namespace(plugin_id: str, skill_namespace: str) -> str:
"""Return one Windows-safe directory component for plugin-owned data."""
candidate = skill_namespace or plugin_id
if (
skill_namespace
and candidate.startswith("agent-plugin-")
and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,191}", candidate)
):
# Portable Agent Plugins already receive this exact PLUGIN_DATA path.
return candidate
# Fixed prefix avoids Windows reserved device names; digest prevents fold collisions.
return _portable_skill_namespace(candidate)
def _state_thread_lock(path: Path) -> threading.RLock:
key = str(path.resolve(strict=False))
with _PLUGIN_STATE_LOCKS_GUARD:
return _PLUGIN_STATE_LOCKS.setdefault(key, threading.RLock())
@contextmanager
def _locked_plugin_state(path: Path):
"""Serialize state read-modify-write across threads/processes (fcntl / msvcrt). The lock lives
in a sibling file because atomic replacement changes the target's inode."""
lock_path = path.with_name(f".{path.name}.lock")
thread_lock = _state_thread_lock(lock_path)
with thread_lock:
lock_path.parent.mkdir(parents=True, exist_ok=True)
with open(lock_path, "a+b") as handle:
if os.name == "nt": # pragma: no cover - exercised on Windows CI
import msvcrt
if handle.seek(0, os.SEEK_END) == 0:
handle.write(b"\0")
handle.flush()
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
if os.name == "nt": # pragma: no cover - exercised on Windows CI
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
class PluginState:
"""Atomic, quota-bounded JSON key/value state owned by one plugin."""
def __init__(self, plugin_id: str, skill_namespace: str = "") -> None:
self._data_namespace = _plugin_data_namespace(plugin_id, skill_namespace)
@property
def data_dir(self) -> Path:
"""Profile-scoped directory matching portable plugins' PLUGIN_DATA."""
return get_hermes_home() / "plugin-data" / self._data_namespace
@property
def path(self) -> Path:
return self.data_dir / "state.json"
@property
def quota_bytes(self) -> int:
return _PLUGIN_STATE_QUOTA_BYTES
@staticmethod
def _validate_key(key: str) -> None:
if (
not isinstance(key, str)
or not _PLUGIN_STATE_KEY_RE.fullmatch(key)
or ".." in key
):
raise ValueError(
"Plugin state keys must be 1-128 characters using letters, "
"numbers, '_', '-', '.', or ':' (without '..')"
)
def _read_unlocked(self) -> dict[str, Any]:
try:
with open(self.path, encoding="utf-8") as handle:
data = json.load(handle)
except FileNotFoundError:
return {}
except (OSError, ValueError) as exc:
raise RuntimeError(f"Cannot parse plugin state {self.path}: {exc}") from exc
if not isinstance(data, dict):
raise RuntimeError(f"Cannot parse plugin state {self.path}: root must be an object")
return data
def get(self, key: str, default: Any = None) -> Any:
"""Read a JSON value, returning *default* when the key is absent."""
self._validate_key(key)
with _locked_plugin_state(self.path):
return self._read_unlocked().get(key, default)
def set(self, key: str, value: Any) -> None:
"""Atomically set one JSON value without dropping concurrent updates."""
self._validate_key(key)
with _locked_plugin_state(self.path):
data = self._read_unlocked()
data[key] = value
try:
encoded = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
except (TypeError, ValueError) as exc:
raise ValueError(
f"Plugin state value for {key!r} is not JSON-serializable"
) from exc
if len(encoded) > self.quota_bytes:
raise ValueError(
f"Plugin state quota exceeded: {len(encoded)} bytes is greater "
f"than the {self.quota_bytes}-byte per-plugin quota"
)
from utils import atomic_json_write
atomic_json_write(self.path, data, mode=0o600)
+4 -6
View File
@@ -83,7 +83,7 @@ class TestDeclarationParsing:
def test_manifest_field_lands_on_parsed_manifest(self, tmp_path):
"""PluginManifest picks up ``capabilities:`` from plugin.yaml."""
from hermes_cli.plugins import PluginManager
from hermes_cli.plugins import parse_manifest_file
plugin_dir = tmp_path / "capplug"
plugin_dir.mkdir()
@@ -92,23 +92,21 @@ class TestDeclarationParsing:
"capabilities:\n - tools.override\n - bogus.capability\n",
encoding="utf-8",
)
mgr = PluginManager()
manifest = mgr._parse_manifest(
manifest = parse_manifest_file(
plugin_dir / "plugin.yaml", plugin_dir, "user", ""
)
assert manifest is not None
assert manifest.capabilities == ["tools.override"]
def test_manifest_without_capabilities_field(self, tmp_path):
from hermes_cli.plugins import PluginManager
from hermes_cli.plugins import parse_manifest_file
plugin_dir = tmp_path / "plainplug"
plugin_dir.mkdir()
(plugin_dir / "plugin.yaml").write_text(
"name: plainplug\n", encoding="utf-8"
)
mgr = PluginManager()
manifest = mgr._parse_manifest(
manifest = parse_manifest_file(
plugin_dir / "plugin.yaml", plugin_dir, "user", ""
)
assert manifest is not None