Files
hermes-agent/hermes_cli/plugins_settings.py
ethernet c13287c915 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/desktop/electron/main.ts
#	hermes_cli/backup.py
#	hermes_cli/config.py
#	hermes_cli/plugin_catalog.py
#	hermes_cli/plugins_cmd.py
#	hermes_cli/plugins_cmd_catalog.py
#	hermes_cli/plugins_discovery.py
#	hermes_cli/profiles.py
#	hermes_cli/update_cmd_deps.py
#	pyproject.toml
#	tests/gateway/test_dm_topics.py
#	tests/hermes_cli/test_config.py
#	tests/hermes_cli/test_plugins_cmd.py
#	tests/hermes_cli/test_update_autostash.py
#	tests/tools/test_lazy_deps.py
#	tools/lazy_deps.py
#	tools/skill_ledger.py
#	utils.py
#	website/docs/user-guide/security.md
2026-09-22 05:16:50 -04:00

141 lines
6.5 KiB
Python

"""Plugin-declared settings fields for the Desktop/TUI Plugins hub (#46600, #87934).
A ``plugin.yaml`` ``config_schema`` describes the keys under ``plugins.entries.<id>.settings``.
This module turns that schema into renderable form fields (type, current value, choices) and
writes edits back through :func:`hermes_cli.plugins_state.save_plugin_setting` — the same writer
``ctx.set_config`` uses, so the CLI, the plugin and the Desktop never disagree on where a
setting lives. Secrets are declared with ``type: secret`` and never touch ``config.yaml``: the
field carries the ``.env`` name (``env:`` or ``<PLUGIN>_<KEY>``) plus a presence flag, and the
client writes the value through the existing ``PUT /api/env`` credential route.
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any, Dict, List, Mapping, Optional
from hermes_cli.plugins_state import _plugin_relative_segments, _plugin_settings_entry, save_plugin_setting
logger = logging.getLogger(__name__)
# manifest ``type`` → wire field type the renderer keys its component table on.
_FIELD_TYPES: Dict[str, str] = {
"str": "string", "string": "string",
"int": "number", "integer": "number", "float": "number", "number": "number",
"bool": "boolean", "boolean": "boolean",
"list": "json", "array": "json", "dict": "json", "object": "json",
"secret": "secret",
}
# wire field type → Python types a saved value must have (bool is excluded from number on purpose).
_VALUE_TYPES: Dict[str, tuple] = {
"string": (str,), "enum": (str,), "number": (int, float), "boolean": (bool,), "json": (list, dict),
}
_ENV_NAME_CLEAN_RE = re.compile(r"[^A-Z0-9]+")
def _manifest_config_schema(plugin_dir: Optional[Path]) -> Mapping[str, Mapping[str, Any]]:
"""``config_schema`` mapping from ``<plugin_dir>/plugin.yaml``; ``{}`` when absent or malformed
(the loader already warned about malformed entries at load time)."""
if plugin_dir is None:
return {}
manifest = Path(plugin_dir) / "plugin.yaml"
if not manifest.is_file():
return {}
try:
from utils import fast_safe_load
data = fast_safe_load(manifest.read_text(encoding="utf-8-sig")) or {}
except Exception as exc: # unreadable manifest: no settings surface, never a failed list
logger.debug("plugin settings: cannot read %s: %s", manifest, exc)
return {}
raw = data.get("config_schema") if isinstance(data, Mapping) else None
if not isinstance(raw, Mapping):
return {}
return {str(k): v for k, v in raw.items() if isinstance(v, Mapping)}
def secret_env_name(plugin_id: str, key: str, spec: Mapping[str, Any]) -> str:
"""``.env`` variable a ``type: secret`` field is stored under: the manifest's ``env:`` or
``<PLUGIN_ID>_<KEY>`` upper-snaked (``image_gen/fal`` + ``api_key`` → ``IMAGE_GEN_FAL_API_KEY``)."""
declared = str(spec.get("env") or "").strip()
if declared:
return declared
return _ENV_NAME_CLEAN_RE.sub("_", f"{plugin_id}_{key}".upper()).strip("_")
def _field_type(spec: Mapping[str, Any]) -> str:
if spec.get("secret") is True:
return "secret"
kind = _FIELD_TYPES.get(str(spec.get("type") or "str").lower(), "string")
choices = spec.get("choices", spec.get("enum"))
if kind == "string" and isinstance(choices, list) and choices:
return "enum"
return kind
def plugin_settings_fields(plugin_id: str, plugin_dir: Optional[Path]) -> List[Dict[str, Any]]:
"""Renderable settings fields for one plugin: schema + the current value of each key.
Secret fields never carry the value — only ``env`` (where it lives) and ``has_value``.
"""
schema = _manifest_config_schema(plugin_dir)
if not schema:
return []
from hermes_cli.config import get_env_value, load_config_readonly
entry = _plugin_settings_entry(load_config_readonly() or {}, plugin_id) or {}
raw_current = entry.get("settings")
current: Mapping[str, Any] = raw_current if isinstance(raw_current, Mapping) else {}
fields: List[Dict[str, Any]] = []
for key, spec in schema.items():
try:
_plugin_relative_segments(key)
except ValueError:
continue # a key the plugin could never read through ctx.get_config
kind = _field_type(spec)
field: Dict[str, Any] = {
"key": key, "type": kind,
"label": str(spec.get("label") or spec.get("title") or key),
"description": str(spec.get("description") or ""),
"required": bool(spec.get("required")),
}
if kind == "secret":
env = secret_env_name(plugin_id, key, spec)
field.update({"env": env, "has_value": get_env_value(env) is not None})
else:
choices = spec.get("choices", spec.get("enum"))
if kind == "enum":
field["choices"] = [str(c) for c in choices]
if "default" in spec:
field["default"] = spec["default"]
field["value"] = current.get(key, spec.get("default"))
fields.append(field)
return fields
def save_plugin_settings(plugin_id: str, plugin_dir: Optional[Path], values: Mapping[str, Any]) -> List[str]:
"""Write ``values`` (``{key: value}``) for the plugin's schema keys; returns the keys written.
Raises ``ValueError`` on an unknown key, a type mismatch, an enum value outside ``choices`` or a
secret (secrets go to ``.env`` through the credential route, never ``config.yaml``);
``PermissionError`` propagates from the shared writer (managed installs / managed keys).
"""
schema = _manifest_config_schema(plugin_dir)
plan: List[tuple] = []
for key, value in values.items():
spec = schema.get(str(key))
if spec is None:
raise ValueError(f"{key!r} is not declared in the plugin's config_schema")
kind = _field_type(spec)
if kind == "secret":
raise ValueError(f"{key!r} is a secret; it is stored in .env, not config.yaml")
expected = _VALUE_TYPES[kind]
if not isinstance(value, expected) or (isinstance(value, bool) and bool not in expected):
raise ValueError(f"{key!r} should be {kind} (got {type(value).__name__})")
if kind == "enum" and value not in [str(c) for c in spec.get("choices", spec.get("enum"))]:
raise ValueError(f"{key!r} must be one of the declared choices")
plan.append((str(key), _plugin_relative_segments(str(key)), value))
for key, segments, value in plan:
save_plugin_setting(plugin_id, segments, value)
return [key for key, _segments, _value in plan]