From 50453f1682d6bce5afdd9166024275d52324e119 Mon Sep 17 00:00:00 2001 From: BIN Zhang Date: Mon, 16 Mar 2026 01:12:06 +0800 Subject: [PATCH 1/2] feat: validate plugin config schema --- README.md | 11 +++++++- demos/discovery.py | 28 +++++++++++++++++++ plugin_config.example.json | 1 + plugin_config.schema.json | 55 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 plugin_config.schema.json diff --git a/README.md b/README.md index 1a74335..ad56b51 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,21 @@ CrewAI-style runtime roles. The demo workflow can load registry extensions from builtin modules and external plugin packages. By default, the runtime looks for a local `plugin_config.json` file at the repository root if present. An example -template is available at [`plugin_config.example.json`](plugin_config.example.json). +template is available at [`plugin_config.example.json`](plugin_config.example.json), +and the config shape is defined by +[`plugin_config.schema.json`](plugin_config.schema.json). For temporary overrides, you can also point discovery at external plugin packages with `POP_PLUGIN_CONFIG_FILE`, `POP_PLUGIN_PACKAGES`, and `POP_PLUGIN_PACKAGE_PATHS`. +If you use file-based plugin config validation outside this repository, +install the optional schema dependency: + +```bash +pip install "pop-persona[schema]" +``` + ## Persona Layer Diagram ```mermaid diff --git a/demos/discovery.py b/demos/discovery.py index 6d95946..b1ab134 100644 --- a/demos/discovery.py +++ b/demos/discovery.py @@ -16,6 +16,7 @@ PLUGIN_PACKAGE_PATHS_ENV = "POP_PLUGIN_PACKAGE_PATHS" PLUGIN_CONFIG_FILE_ENV = "POP_PLUGIN_CONFIG_FILE" DEFAULT_PLUGIN_CONFIG_PATH = PROJECT_ROOT / "plugin_config.json" +PLUGIN_CONFIG_SCHEMA_PATH = PROJECT_ROOT / "plugin_config.schema.json" @dataclass(frozen=True) @@ -132,6 +133,7 @@ def load_plugin_config( payload = json.loads(config_path.read_text(encoding="utf-8")) if not isinstance(payload, dict): raise ValueError("Plugin config must be a JSON object.") + validate_plugin_config_payload(payload) base_dir = config_path.parent package_paths = normalize_plugin_paths(payload, base_dir) @@ -139,6 +141,32 @@ def load_plugin_config( return PluginDiscoveryConfig(config_path, package_names, package_paths) +def load_plugin_config_schema() -> dict[str, Any]: + return json.loads(PLUGIN_CONFIG_SCHEMA_PATH.read_text(encoding="utf-8")) + + +def validation_error_path(error: Any) -> str: + parts = [str(part) for part in getattr(error, "absolute_path", ())] + return ".".join(parts) if parts else "" + + +def validate_plugin_config_payload(payload: dict[str, Any]) -> None: + try: + import jsonschema + except ModuleNotFoundError as exc: + raise RuntimeError( + "Plugin config validation requires `jsonschema`. " + "Install `pop-persona[schema]` or add `jsonschema` to your environment." + ) from exc + + schema = load_plugin_config_schema() + try: + jsonschema.validate(instance=payload, schema=schema) + except jsonschema.ValidationError as exc: + path = validation_error_path(exc) + raise ValueError(f"Invalid plugin config at {path}: {exc.message}") from exc + + def configure_plugin_search_paths(paths: tuple[Path, ...]) -> tuple[Path, ...]: configured: list[Path] = [] for path in paths: diff --git a/plugin_config.example.json b/plugin_config.example.json index b851a76..3c3e3e5 100644 --- a/plugin_config.example.json +++ b/plugin_config.example.json @@ -1,4 +1,5 @@ { + "$schema": "./plugin_config.schema.json", "plugin_paths": [ { "path": "/absolute/path/to/pop_plugin_package_root", diff --git a/plugin_config.schema.json b/plugin_config.schema.json new file mode 100644 index 0000000..049573f --- /dev/null +++ b/plugin_config.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/joy7758/persona-object-protocol/plugin_config.schema.json", + "title": "POP Plugin Discovery Config", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "minLength": 1 + }, + "plugin_paths": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "package_name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "path" + ] + } + ] + } + }, + "plugin_packages": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "plugin_package_paths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } +} From edec32b089c62031affcbbea40f527c70c359807 Mon Sep 17 00:00:00 2001 From: Bin Zhang <138868899+joy7758@users.noreply.github.com> Date: Mon, 16 Mar 2026 01:26:05 +0800 Subject: [PATCH 2/2] feat: validate plugin config in ci (#23) --- .github/workflows/validate.yml | 4 ++ README.md | 1 + validation/scripts/validate_plugin_config.py | 59 ++++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 validation/scripts/validate_plugin_config.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 39022e8..7f03da7 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -23,6 +23,10 @@ jobs: python -m pip install --upgrade pip pip install -e ".[schema]" + - name: Validate plugin config example + run: | + python validation/scripts/validate_plugin_config.py + - name: Validate all example POP objects run: | for f in examples/*.json; do diff --git a/README.md b/README.md index ad56b51..8246efa 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ external plugin packages. By default, the runtime looks for a local template is available at [`plugin_config.example.json`](plugin_config.example.json), and the config shape is defined by [`plugin_config.schema.json`](plugin_config.schema.json). +The example config is also validated in CI so schema drift is caught during PR review. For temporary overrides, you can also point discovery at external plugin packages with `POP_PLUGIN_CONFIG_FILE`, `POP_PLUGIN_PACKAGES`, and diff --git a/validation/scripts/validate_plugin_config.py b/validation/scripts/validate_plugin_config.py new file mode 100644 index 0000000..a7677ef --- /dev/null +++ b/validation/scripts/validate_plugin_config.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_CONFIG_PATH = ROOT / "plugin_config.example.json" +DEFAULT_SCHEMA_PATH = ROOT / "plugin_config.schema.json" + + +def load_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Validate a plugin config file against the repository schema." + ) + parser.add_argument( + "config_path", + nargs="?", + type=Path, + default=DEFAULT_CONFIG_PATH, + help="Path to the plugin config file to validate.", + ) + parser.add_argument( + "--schema", + type=Path, + default=DEFAULT_SCHEMA_PATH, + help="Path to the plugin config schema file.", + ) + args = parser.parse_args() + + config_path = args.config_path.resolve() + schema_path = args.schema.resolve() + schema = load_json(schema_path) + payload = load_json(config_path) + + validator = Draft202012Validator(schema) + errors = sorted( + validator.iter_errors(payload), + key=lambda error: [str(part) for part in error.absolute_path], + ) + if errors: + first_error = errors[0] + path = ".".join(str(part) for part in first_error.absolute_path) or "" + raise ValueError(f"{config_path}: invalid at {path}: {first_error.message}") + + print(f"Validated {config_path.relative_to(ROOT)} against {schema_path.relative_to(ROOT)}") + + +if __name__ == "__main__": + main()