mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
fix: harden plugin creator sharing validation (#22893)
# Summary Before this change, the sample plugin creator could emit placeholder-heavy manifests that fail workspace sharing, and it chose a repo-local marketplace implicitly whenever it ran from inside a git checkout. This PR makes generated plugins share-ready by default. It switches creation to the personal marketplace unless the caller explicitly opts into repo-local paths, adds a validator that mirrors the workspace plugin ingestion contract, and updates the skill prompt and docs to describe the real flow. The goal is to stop malformed generated plugins before they reach sharing and to make the default placement match the personal marketplace behavior users expect. ## Changes - Generate share-safe plugin manifests instead of `[TODO: ...]` placeholder payloads. - Default plugin and marketplace creation to `~/plugins` and `~/.agents/plugins/marketplace.json`. - Keep repo-local marketplace creation available through explicit `--path` and `--marketplace-path` arguments. - Add `validate_plugin.py` to check manifests, companion files, skill frontmatter, skill agent YAML, asset paths, and backend-shaped contracts before sharing. - Refresh the plugin creator skill text, reference docs, and default prompt to describe validation and the personal default. ## Design decisions - The validator tracks the workspace ingestion schema directly, including the required `defaultPrompt` alias handling and skill `agents/openai.yaml` checks. - The validator keeps one intentional extra preflight rule: leftover `[TODO: ...]` placeholders are rejected before sharing even when a single placeholder would not independently violate backend type validation. - Repo-local creation stays possible, but it is now explicit instead of cwd-sensitive. ## Testing Tests: targeted Python syntax checks, plugin skill validation, staged diff whitespace validation, 15 generated plugin smoke runs, backend manifest-schema acceptance for all 15 generated bundles, and a git-repo cwd regression proving the creator still writes to the personal marketplace by default.
This commit is contained in:
committed by
GitHub
Unverified
parent
8c14b08dd1
commit
d32cb2c6ac
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: plugin-creator
|
||||
description: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, and baseline placeholders you can edit before publishing or testing. Use when Codex needs to create a new personal plugin, add optional plugin structure, or generate or update personal or repo-root `.agents/plugins/marketplace.json` entries for plugin ordering and availability metadata.
|
||||
description: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, add optional plugin structure, or generate or update marketplace entries for plugin ordering and availability metadata.
|
||||
---
|
||||
|
||||
# Plugin Creator
|
||||
@@ -17,7 +17,8 @@ description: Create and scaffold plugin directories for Codex with a required `.
|
||||
python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py <plugin-name>
|
||||
```
|
||||
|
||||
2. Open `<plugin-path>/.codex-plugin/plugin.json` and replace `[TODO: ...]` placeholders.
|
||||
2. Edit `<plugin-path>/.codex-plugin/plugin.json` when the request gives specific metadata.
|
||||
The scaffold starts with valid defaults and must not contain `[TODO: ...]` placeholders.
|
||||
|
||||
3. Generate or update the personal marketplace entry when the plugin should appear in Codex UI ordering:
|
||||
|
||||
@@ -26,14 +27,12 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py <plugin-nam
|
||||
python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin --with-marketplace
|
||||
```
|
||||
|
||||
If the current Git repo already has `.agents/plugins/marketplace.json` and the user has not said
|
||||
whether the plugin is personal or shared with their team, ask before generating a marketplace entry.
|
||||
When they choose the repo marketplace, use:
|
||||
Only use a repo/team marketplace when the user specifically asks for that destination:
|
||||
|
||||
```bash
|
||||
python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \
|
||||
--path ./plugins \
|
||||
--marketplace-path ./.agents/plugins/marketplace.json \
|
||||
--path <repo-root>/plugins \
|
||||
--marketplace-path <repo-root>/.agents/plugins/marketplace.json \
|
||||
--with-marketplace
|
||||
```
|
||||
|
||||
@@ -48,17 +47,21 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \
|
||||
|
||||
`<parent-plugin-directory>` is the directory where the plugin folder `<plugin-name>` will be created (for example `~/code/plugins`).
|
||||
|
||||
5. Before handing back a generated plugin, run:
|
||||
|
||||
```bash
|
||||
python3 .agents/skills/plugin-creator/scripts/validate_plugin.py <plugin-path>
|
||||
```
|
||||
|
||||
## What this skill creates
|
||||
|
||||
- Default marketplace-backed scaffolds are personal: `~/plugins/<plugin-name>/` plus
|
||||
`~/.agents/plugins/marketplace.json`.
|
||||
- If the current Git repo already has `.agents/plugins/marketplace.json` and the user has not said
|
||||
personal vs team, ask which marketplace to update before generating a marketplace entry.
|
||||
- Creates plugin root at `/<parent-plugin-directory>/<plugin-name>/`.
|
||||
- Always creates `/<parent-plugin-directory>/<plugin-name>/.codex-plugin/plugin.json`.
|
||||
- Fills the manifest with the full schema shape, placeholder values, and the complete `interface` section.
|
||||
- Creates or updates the selected marketplace when `--with-marketplace` is set.
|
||||
- If the marketplace file does not exist yet, seed top-level `name` plus `interface.displayName` placeholders before adding the first plugin entry.
|
||||
- Fills the manifest with the validated schema shape that the ingestion path accepts.
|
||||
- Creates or updates `~/.agents/plugins/marketplace.json` when `--with-marketplace` is set.
|
||||
- If the marketplace file does not exist yet, seed a personal marketplace root before adding the first plugin entry.
|
||||
- `<plugin-name>` is normalized using skill-creator naming rules:
|
||||
- `My Plugin` → `my-plugin`
|
||||
- `My--Plugin` → `my-plugin`
|
||||
@@ -74,8 +77,10 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \
|
||||
|
||||
## Marketplace workflow
|
||||
|
||||
- Personal plugins use `~/.agents/plugins/marketplace.json`.
|
||||
- Repo/team plugins use `<repo-root>/.agents/plugins/marketplace.json`.
|
||||
- Personal creation defaults to `~/.agents/plugins/marketplace.json`.
|
||||
- Repo/team marketplace creation is opt-in through both `--path` and `--marketplace-path`, only
|
||||
when the user specifically requests it.
|
||||
- In either location, the generated source path remains `./plugins/<plugin-name>`.
|
||||
- Marketplace root metadata supports top-level `name` plus optional `interface.displayName`.
|
||||
- Treat plugin order in `plugins[]` as render order in Codex. Append new entries unless a user explicitly asks to reorder the list.
|
||||
- `displayName` belongs inside the marketplace `interface` object, not individual `plugins[]` entries.
|
||||
@@ -113,15 +118,15 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \
|
||||
```
|
||||
|
||||
- Use `--force` only when intentionally replacing an existing marketplace entry for the same plugin name.
|
||||
- If the selected marketplace file does not exist yet, create it with top-level `"name"`, an `"interface"` object containing `"displayName"`, and a `plugins` array, then add the new entry.
|
||||
- If the target marketplace file does not exist yet, create it with top-level `"name"`, an `"interface"` object containing `"displayName"`, and a `plugins` array, then add the new entry.
|
||||
|
||||
- For a brand-new marketplace file, the root object should look like:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "[TODO: marketplace-name]",
|
||||
"name": "personal",
|
||||
"interface": {
|
||||
"displayName": "[TODO: Marketplace Display Name]"
|
||||
"displayName": "Personal"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
@@ -144,7 +149,9 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \
|
||||
|
||||
- Outer folder name and `plugin.json` `"name"` are always the same normalized plugin name.
|
||||
- Do not remove required structure; keep `.codex-plugin/plugin.json` present.
|
||||
- Keep manifest values as placeholders until a human or follow-up step explicitly fills them.
|
||||
- Do not leave `[TODO: ...]` placeholders in plugin manifests.
|
||||
- Keep `apps` and `mcpServers` out of `plugin.json` unless their companion files are actually created.
|
||||
- Omit unsupported plugin manifest fields that validation rejects, including `hooks`.
|
||||
- If creating files inside an existing plugin path, use `--force` only when overwrite is intentional.
|
||||
- Preserve any existing marketplace `interface.displayName`.
|
||||
- When generating marketplace entries, always write `policy.installation`, `policy.authentication`, and `category` even if their values are defaults.
|
||||
@@ -176,3 +183,9 @@ After editing `SKILL.md`, run:
|
||||
```bash
|
||||
python3 <path-to-skill-creator>/scripts/quick_validate.py .agents/skills/plugin-creator
|
||||
```
|
||||
|
||||
Before handing back a generated plugin, run:
|
||||
|
||||
```bash
|
||||
python3 .agents/skills/plugin-creator/scripts/validate_plugin.py <plugin-path>
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
interface:
|
||||
display_name: "Plugin Creator"
|
||||
short_description: "Scaffold plugins and marketplace entries"
|
||||
default_prompt: "Use $plugin-creator to scaffold a plugin with placeholder plugin.json, optional structure, and a marketplace.json entry."
|
||||
default_prompt: "Use $plugin-creator to scaffold a valid plugin in the personal marketplace, then validate it before handing it back."
|
||||
icon_small: "./assets/plugin-creator-small.svg"
|
||||
icon_large: "./assets/plugin-creator.png"
|
||||
|
||||
@@ -97,7 +97,8 @@
|
||||
|
||||
# Marketplace JSON sample spec
|
||||
|
||||
`marketplace.json` depends on where the plugin should live:
|
||||
`marketplace.json` depends on where the plugin should live. New plugin creation defaults to the
|
||||
personal marketplace unless the caller explicitly requests a repo-local destination:
|
||||
|
||||
- Personal plugin: `~/.agents/plugins/marketplace.json`
|
||||
- Repo/team plugin: `<repo-root>/.agents/plugins/marketplace.json`
|
||||
@@ -166,8 +167,25 @@
|
||||
- Append new entries unless the user explicitly requests reordering.
|
||||
- Replace an existing entry for the same plugin only when overwrite is intentional.
|
||||
- Default new plugin creation to the personal marketplace.
|
||||
- If the current Git repo already has `.agents/plugins/marketplace.json` and the user has not said
|
||||
personal or team, ask which marketplace to update before creating the entry.
|
||||
- Use a repo/team marketplace only when the user specifically requests that destination.
|
||||
- Choose marketplace location to match the selected destination:
|
||||
- Personal plugin: `~/.agents/plugins/marketplace.json`
|
||||
- Repo/team plugin: `<repo-root>/.agents/plugins/marketplace.json`
|
||||
|
||||
### Plugin validation notes
|
||||
|
||||
- The validator mirrors the workspace plugin ingestion schema so generated plugins follow the same
|
||||
manifest contract from the start.
|
||||
- Plugin manifests must include real values for `name`, `version`, `description`,
|
||||
`author.name`, and the required `interface` fields.
|
||||
- `version` must use strict semver.
|
||||
- `websiteURL`, `privacyPolicyURL`, and `termsOfServiceURL` must be absolute `https://` URLs when
|
||||
present.
|
||||
- `composerIcon`, `logo`, and `screenshots` must point to real files inside the plugin archive when
|
||||
present.
|
||||
- `apps` and `mcpServers` should appear in `plugin.json` only when `.app.json` and `.mcp.json`
|
||||
actually exist.
|
||||
- Validation rejects unsupported manifest fields such as `hooks`, so the scaffold keeps them out of
|
||||
generated manifests.
|
||||
- Run `scripts/validate_plugin.py <plugin-path>` before handing back a generated plugin. It adds one
|
||||
intentional preflight check that rejects leftover `[TODO: ...]` placeholders.
|
||||
|
||||
@@ -16,7 +16,8 @@ DEFAULT_MARKETPLACE_PATH = Path.home() / ".agents" / "plugins" / "marketplace.js
|
||||
DEFAULT_INSTALL_POLICY = "AVAILABLE"
|
||||
DEFAULT_AUTH_POLICY = "ON_INSTALL"
|
||||
DEFAULT_CATEGORY = "Productivity"
|
||||
DEFAULT_MARKETPLACE_DISPLAY_NAME = "[TODO: Marketplace Display Name]"
|
||||
DEFAULT_MARKETPLACE_NAME = "personal"
|
||||
DEFAULT_MARKETPLACE_DISPLAY_NAME = "Personal"
|
||||
VALID_INSTALL_POLICIES = {"NOT_AVAILABLE", "AVAILABLE", "INSTALLED_BY_DEFAULT"}
|
||||
VALID_AUTH_POLICIES = {"ON_INSTALL", "ON_USE"}
|
||||
|
||||
@@ -40,49 +41,35 @@ def validate_plugin_name(plugin_name: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def build_plugin_json(plugin_name: str) -> dict:
|
||||
return {
|
||||
def display_name_from_plugin_name(plugin_name: str) -> str:
|
||||
return " ".join(part.capitalize() for part in plugin_name.split("-"))
|
||||
|
||||
|
||||
def build_plugin_json(plugin_name: str, *, with_mcp: bool, with_apps: bool) -> dict[str, Any]:
|
||||
display_name = display_name_from_plugin_name(plugin_name)
|
||||
payload: dict[str, Any] = {
|
||||
"name": plugin_name,
|
||||
"version": "[TODO: 1.2.0]",
|
||||
"description": "[TODO: Brief plugin description]",
|
||||
"version": "0.1.0",
|
||||
"description": f"{display_name} plugin",
|
||||
"author": {
|
||||
"name": "[TODO: Author Name]",
|
||||
"email": "[TODO: author@example.com]",
|
||||
"url": "[TODO: https://github.com/author]",
|
||||
"name": "Local developer",
|
||||
},
|
||||
"homepage": "[TODO: https://docs.example.com/plugin]",
|
||||
"repository": "[TODO: https://github.com/author/plugin]",
|
||||
"license": "[TODO: MIT]",
|
||||
"keywords": ["[TODO: keyword1]", "[TODO: keyword2]"],
|
||||
"skills": "[TODO: ./skills/]",
|
||||
"hooks": "[TODO: ./hooks.json]",
|
||||
"mcpServers": "[TODO: ./.mcp.json]",
|
||||
"apps": "[TODO: ./.app.json]",
|
||||
"skills": "./skills/",
|
||||
"interface": {
|
||||
"displayName": "[TODO: Plugin Display Name]",
|
||||
"shortDescription": "[TODO: Short description for subtitle]",
|
||||
"longDescription": "[TODO: Long description for details page]",
|
||||
"developerName": "[TODO: OpenAI]",
|
||||
"category": "[TODO: Productivity]",
|
||||
"capabilities": ["[TODO: Interactive]", "[TODO: Write]"],
|
||||
"websiteURL": "[TODO: https://openai.com/]",
|
||||
"privacyPolicyURL": "[TODO: https://openai.com/policies/row-privacy-policy/]",
|
||||
"termsOfServiceURL": "[TODO: https://openai.com/policies/row-terms-of-use/]",
|
||||
"defaultPrompt": [
|
||||
"[TODO: Summarize my inbox and draft replies for me.]",
|
||||
"[TODO: Find open bugs and turn them into tickets.]",
|
||||
"[TODO: Review today's meetings and flag gaps.]",
|
||||
],
|
||||
"brandColor": "[TODO: #3B82F6]",
|
||||
"composerIcon": "[TODO: ./assets/icon.png]",
|
||||
"logo": "[TODO: ./assets/logo.png]",
|
||||
"screenshots": [
|
||||
"[TODO: ./assets/screenshot1.png]",
|
||||
"[TODO: ./assets/screenshot2.png]",
|
||||
"[TODO: ./assets/screenshot3.png]",
|
||||
],
|
||||
"displayName": display_name,
|
||||
"shortDescription": f"Use {display_name} in Codex.",
|
||||
"longDescription": f"{display_name} adds a local Codex plugin scaffold.",
|
||||
"developerName": "Local developer",
|
||||
"category": DEFAULT_CATEGORY,
|
||||
"capabilities": [],
|
||||
"defaultPrompt": f"Help me use {display_name}.",
|
||||
},
|
||||
}
|
||||
if with_mcp:
|
||||
payload["mcpServers"] = "./.mcp.json"
|
||||
if with_apps:
|
||||
payload["apps"] = "./.app.json"
|
||||
return payload
|
||||
|
||||
|
||||
def build_marketplace_entry(
|
||||
@@ -112,7 +99,7 @@ def load_json(path: Path) -> dict[str, Any]:
|
||||
|
||||
def build_default_marketplace() -> dict[str, Any]:
|
||||
return {
|
||||
"name": "[TODO: marketplace-name]",
|
||||
"name": DEFAULT_MARKETPLACE_NAME,
|
||||
"interface": {
|
||||
"displayName": DEFAULT_MARKETPLACE_DISPLAY_NAME,
|
||||
},
|
||||
@@ -185,7 +172,7 @@ def create_stub_file(path: Path, payload: dict, force: bool) -> None:
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Create a plugin skeleton with placeholder plugin.json."
|
||||
description="Create a plugin skeleton with a validation-ready plugin.json."
|
||||
)
|
||||
parser.add_argument("plugin_name")
|
||||
parser.add_argument(
|
||||
@@ -193,7 +180,7 @@ def parse_args() -> argparse.Namespace:
|
||||
default=str(DEFAULT_PLUGIN_PARENT),
|
||||
help=(
|
||||
"Parent directory for plugin creation (defaults to <home>/plugins). "
|
||||
"Use <repo>/plugins when creating a repo/team plugin."
|
||||
"Pass an explicit repo path only when a repo/team plugin is intended."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--with-skills", action="store_true", help="Create skills/ directory")
|
||||
@@ -216,7 +203,7 @@ def parse_args() -> argparse.Namespace:
|
||||
default=str(DEFAULT_MARKETPLACE_PATH),
|
||||
help=(
|
||||
"Path to marketplace.json (defaults to <home>/.agents/plugins/marketplace.json). "
|
||||
"Use <repo>/.agents/plugins/marketplace.json for a repo/team plugin."
|
||||
"Pass a repo-rooted marketplace path only when a repo/team plugin is intended."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -252,7 +239,11 @@ def main() -> None:
|
||||
plugin_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
plugin_json_path = plugin_root / ".codex-plugin" / "plugin.json"
|
||||
write_json(plugin_json_path, build_plugin_json(plugin_name), args.force)
|
||||
write_json(
|
||||
plugin_json_path,
|
||||
build_plugin_json(plugin_name, with_mcp=args.with_mcp, with_apps=args.with_apps),
|
||||
args.force,
|
||||
)
|
||||
|
||||
optional_directories = {
|
||||
"skills": args.with_skills,
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a generated plugin against the plugin ingestion contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
TODO_MARKER = "[TODO:"
|
||||
SEMVER_RE = re.compile(
|
||||
r"^(0|[1-9]\d*)\."
|
||||
r"(0|[1-9]\d*)\."
|
||||
r"(0|[1-9]\d*)"
|
||||
r"(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\."
|
||||
r"(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?"
|
||||
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
|
||||
)
|
||||
HEX_COLOR_RE = re.compile(r"^#[0-9A-F]{6}$", re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Validate a local Codex plugin.")
|
||||
parser.add_argument("plugin_path", help="Path to the plugin root directory")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
plugin_root = Path(args.plugin_path).expanduser().resolve()
|
||||
errors = validate_plugin(plugin_root)
|
||||
if errors:
|
||||
print("Plugin validation failed:")
|
||||
for error in errors:
|
||||
print(f"- {error}")
|
||||
raise SystemExit(1)
|
||||
print(f"Plugin validation passed: {plugin_root}")
|
||||
|
||||
|
||||
def validate_plugin(plugin_root: Path) -> list[str]:
|
||||
errors: list[str] = []
|
||||
manifest_path = plugin_root / ".codex-plugin" / "plugin.json"
|
||||
manifest = load_json_object(manifest_path, errors)
|
||||
if manifest is None:
|
||||
return errors
|
||||
|
||||
reject_todo_markers(manifest, "$", errors)
|
||||
validate_manifest_shape(plugin_root, manifest, errors)
|
||||
return errors
|
||||
|
||||
|
||||
def load_json_object(path: Path, errors: list[str]) -> dict[str, Any] | None:
|
||||
if not path.is_file():
|
||||
errors.append("missing `.codex-plugin/plugin.json`")
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
errors.append("unable to read `.codex-plugin/plugin.json`")
|
||||
return None
|
||||
except json.JSONDecodeError:
|
||||
errors.append("`.codex-plugin/plugin.json` must be valid JSON")
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
errors.append("`.codex-plugin/plugin.json` must contain a JSON object")
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def reject_todo_markers(value: Any, path: str, errors: list[str]) -> None:
|
||||
if isinstance(value, str):
|
||||
if TODO_MARKER in value:
|
||||
errors.append(f"{path} still contains a `[TODO: ...]` placeholder")
|
||||
return
|
||||
if isinstance(value, list):
|
||||
for index, item in enumerate(value):
|
||||
reject_todo_markers(item, f"{path}[{index}]", errors)
|
||||
return
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
reject_todo_markers(item, f"{path}.{key}", errors)
|
||||
|
||||
|
||||
def validate_manifest_shape(
|
||||
plugin_root: Path,
|
||||
manifest: dict[str, Any],
|
||||
errors: list[str],
|
||||
) -> None:
|
||||
allowed_keys = {
|
||||
"id",
|
||||
"name",
|
||||
"version",
|
||||
"description",
|
||||
"skills",
|
||||
"apps",
|
||||
"mcpServers",
|
||||
"interface",
|
||||
"author",
|
||||
"homepage",
|
||||
"repository",
|
||||
"license",
|
||||
"keywords",
|
||||
}
|
||||
for key in sorted(set(manifest) - allowed_keys):
|
||||
errors.append(f"plugin.json field `{key}` is not accepted by plugin validation")
|
||||
|
||||
validate_optional_non_empty_string(manifest, "id", errors)
|
||||
require_non_empty_string(manifest, "name", errors)
|
||||
version = require_non_empty_string(manifest, "version", errors)
|
||||
if version is not None and SEMVER_RE.fullmatch(version) is None:
|
||||
errors.append("plugin.json field `version` must be strict semver")
|
||||
require_non_empty_string(manifest, "description", errors)
|
||||
|
||||
author = require_object(manifest, "author", errors)
|
||||
if author is not None:
|
||||
reject_unknown_fields(author, {"name", "email", "url"}, "author", errors)
|
||||
require_non_empty_string(author, "name", errors, prefix="author")
|
||||
validate_optional_non_empty_string(author, "email", errors, prefix="author")
|
||||
validate_optional_https_url(author, "url", errors, prefix="author")
|
||||
|
||||
validate_optional_contract_path(manifest, "skills", "skills", errors)
|
||||
validate_optional_contract_path(manifest, "apps", ".app.json", errors)
|
||||
validate_optional_contract_path(manifest, "mcpServers", ".mcp.json", errors)
|
||||
|
||||
if manifest.get("apps") is not None:
|
||||
validate_app_manifest(
|
||||
plugin_root / ".app.json",
|
||||
errors,
|
||||
)
|
||||
if manifest.get("mcpServers") is not None:
|
||||
validate_mcp_manifest(
|
||||
plugin_root / ".mcp.json",
|
||||
errors,
|
||||
)
|
||||
validate_skill_manifests(plugin_root, errors)
|
||||
|
||||
interface = require_object(manifest, "interface", errors)
|
||||
if interface is None:
|
||||
return
|
||||
reject_unknown_fields(
|
||||
interface,
|
||||
{
|
||||
"displayName",
|
||||
"shortDescription",
|
||||
"longDescription",
|
||||
"developerName",
|
||||
"category",
|
||||
"capabilities",
|
||||
"websiteURL",
|
||||
"privacyPolicyURL",
|
||||
"termsOfServiceURL",
|
||||
"brandColor",
|
||||
"composerIcon",
|
||||
"logo",
|
||||
"screenshots",
|
||||
"defaultPrompt",
|
||||
"default_prompt",
|
||||
},
|
||||
"interface",
|
||||
errors,
|
||||
)
|
||||
for field in (
|
||||
"displayName",
|
||||
"shortDescription",
|
||||
"longDescription",
|
||||
"developerName",
|
||||
"category",
|
||||
):
|
||||
require_non_empty_string(interface, field, errors, prefix="interface")
|
||||
if "defaultPrompt" not in interface and "default_prompt" not in interface:
|
||||
errors.append(
|
||||
"plugin.json field `interface.defaultPrompt` or `interface.default_prompt` is required"
|
||||
)
|
||||
capabilities = interface.get("capabilities")
|
||||
if not isinstance(capabilities, list) or not all(
|
||||
isinstance(value, str) and value.strip() for value in capabilities
|
||||
):
|
||||
errors.append("plugin.json field `interface.capabilities` must be an array of strings")
|
||||
for field in ("websiteURL", "privacyPolicyURL", "termsOfServiceURL"):
|
||||
validate_optional_https_url(interface, field, errors, prefix="interface")
|
||||
brand_color = interface.get("brandColor")
|
||||
if brand_color is not None and (
|
||||
not isinstance(brand_color, str) or HEX_COLOR_RE.fullmatch(brand_color) is None
|
||||
):
|
||||
errors.append("plugin.json field `interface.brandColor` must use `#RRGGBB`")
|
||||
for field in ("composerIcon", "logo"):
|
||||
validate_optional_asset_path(plugin_root, plugin_root, interface, field, errors)
|
||||
screenshots = interface.get("screenshots", [])
|
||||
if not isinstance(screenshots, list):
|
||||
errors.append("plugin.json field `interface.screenshots` must be an array")
|
||||
else:
|
||||
for index, raw_path in enumerate(screenshots):
|
||||
validate_asset_path(
|
||||
plugin_root,
|
||||
plugin_root,
|
||||
raw_path,
|
||||
f"interface.screenshots[{index}]",
|
||||
errors,
|
||||
)
|
||||
|
||||
|
||||
def require_object(
|
||||
payload: dict[str, Any],
|
||||
key: str,
|
||||
errors: list[str],
|
||||
) -> dict[str, Any] | None:
|
||||
value = payload.get(key)
|
||||
if not isinstance(value, dict):
|
||||
errors.append(f"plugin.json field `{key}` must be an object")
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def require_non_empty_string(
|
||||
payload: dict[str, Any],
|
||||
key: str,
|
||||
errors: list[str],
|
||||
*,
|
||||
prefix: str | None = None,
|
||||
) -> str | None:
|
||||
value = payload.get(key)
|
||||
field = f"{prefix}.{key}" if prefix is not None else key
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
errors.append(f"plugin.json field `{field}` must be a non-empty string")
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def validate_optional_non_empty_string(
|
||||
payload: dict[str, Any],
|
||||
key: str,
|
||||
errors: list[str],
|
||||
*,
|
||||
prefix: str | None = None,
|
||||
) -> None:
|
||||
value = payload.get(key)
|
||||
if value is None:
|
||||
return
|
||||
field = f"{prefix}.{key}" if prefix is not None else key
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
errors.append(f"plugin.json field `{field}` must be a non-empty string")
|
||||
|
||||
|
||||
def reject_unknown_fields(
|
||||
payload: dict[str, Any],
|
||||
allowed_keys: set[str],
|
||||
prefix: str,
|
||||
errors: list[str],
|
||||
) -> None:
|
||||
for key in sorted(set(payload) - allowed_keys):
|
||||
errors.append(f"plugin.json field `{prefix}.{key}` is not accepted by plugin validation")
|
||||
|
||||
|
||||
def validate_optional_https_url(
|
||||
payload: dict[str, Any],
|
||||
key: str,
|
||||
errors: list[str],
|
||||
*,
|
||||
prefix: str,
|
||||
) -> None:
|
||||
value = payload.get(key)
|
||||
if value is None:
|
||||
return
|
||||
parsed = urlparse(value) if isinstance(value, str) else None
|
||||
if parsed is None or parsed.scheme != "https" or not parsed.netloc:
|
||||
errors.append(f"plugin.json field `{prefix}.{key}` must be an absolute `https://` URL")
|
||||
|
||||
|
||||
def validate_optional_contract_path(
|
||||
payload: dict[str, Any],
|
||||
key: str,
|
||||
expected: str,
|
||||
errors: list[str],
|
||||
) -> None:
|
||||
value = payload.get(key)
|
||||
if value is None:
|
||||
return
|
||||
normalized = normalize_contract_path(value) if isinstance(value, str) else None
|
||||
if normalized != expected:
|
||||
errors.append(f"plugin.json field `{key}` must resolve to `{expected}`")
|
||||
|
||||
|
||||
def normalize_contract_path(raw_path: str) -> str | None:
|
||||
path = Path(raw_path)
|
||||
if path.is_absolute():
|
||||
return None
|
||||
normalized = path.as_posix().rstrip("/")
|
||||
return normalized or None
|
||||
|
||||
|
||||
def validate_app_manifest(path: Path, errors: list[str]) -> None:
|
||||
payload = load_companion_json_object(path, "`.app.json`", errors)
|
||||
if payload is None:
|
||||
return
|
||||
reject_companion_unknown_fields(payload, {"apps"}, "`.app.json`", errors)
|
||||
apps = payload.get("apps")
|
||||
if not isinstance(apps, dict):
|
||||
errors.append("`.app.json` field `apps` must be an object")
|
||||
return
|
||||
for key, value in apps.items():
|
||||
if not isinstance(value, dict):
|
||||
errors.append(f"`.app.json` app `{key}` must be an object")
|
||||
continue
|
||||
reject_companion_unknown_fields(value, {"id"}, f"`.app.json` app `{key}`", errors)
|
||||
app_id = value.get("id")
|
||||
if not isinstance(app_id, str) or not app_id.strip():
|
||||
errors.append(f"`.app.json` app `{key}` field `id` must be a non-empty string")
|
||||
|
||||
|
||||
def validate_mcp_manifest(path: Path, errors: list[str]) -> None:
|
||||
payload = load_companion_json_object(path, "`.mcp.json`", errors)
|
||||
if payload is None:
|
||||
return
|
||||
reject_companion_unknown_fields(payload, {"mcpServers"}, "`.mcp.json`", errors)
|
||||
servers = payload.get("mcpServers")
|
||||
if not isinstance(servers, dict):
|
||||
errors.append("`.mcp.json` field `mcpServers` must be an object")
|
||||
return
|
||||
for key, value in servers.items():
|
||||
if not isinstance(key, str) or not key.strip():
|
||||
errors.append("`.mcp.json` server names must be non-empty strings")
|
||||
if not isinstance(value, dict):
|
||||
errors.append(f"`.mcp.json` server `{key}` must be an object")
|
||||
|
||||
|
||||
def load_companion_json_object(
|
||||
path: Path,
|
||||
label: str,
|
||||
errors: list[str],
|
||||
) -> dict[str, Any] | None:
|
||||
if not path.is_file():
|
||||
errors.append(f"{label} is required when its plugin.json field is present")
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
errors.append(f"{label} must contain valid JSON")
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
errors.append(f"{label} must contain a JSON object")
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def reject_companion_unknown_fields(
|
||||
payload: dict[str, Any],
|
||||
allowed_keys: set[str],
|
||||
prefix: str,
|
||||
errors: list[str],
|
||||
) -> None:
|
||||
for key in sorted(set(payload) - allowed_keys):
|
||||
errors.append(f"{prefix} field `{key}` is not accepted by plugin validation")
|
||||
|
||||
|
||||
def validate_skill_manifests(plugin_root: Path, errors: list[str]) -> None:
|
||||
skills_root = plugin_root / "skills"
|
||||
if not skills_root.is_dir():
|
||||
return
|
||||
for skill_root in sorted(skills_root.iterdir(), key=lambda path: path.name):
|
||||
if skill_root.name.startswith(".") or not skill_root.is_dir():
|
||||
continue
|
||||
validate_skill_manifest(skill_root, errors)
|
||||
|
||||
|
||||
def validate_skill_manifest(skill_root: Path, errors: list[str]) -> None:
|
||||
skill_md_path = skill_root / "SKILL.md"
|
||||
if not skill_md_path.is_file():
|
||||
errors.append(f"skill `{skill_root.name}` is missing `SKILL.md`")
|
||||
return
|
||||
try:
|
||||
contents = skill_md_path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
errors.append(f"unable to read skill `{skill_root.name}`")
|
||||
return
|
||||
if not contents.startswith("---\n"):
|
||||
errors.append(f"skill `{skill_root.name}` must start with YAML frontmatter")
|
||||
return
|
||||
frontmatter_end = contents.find("\n---", 4)
|
||||
if frontmatter_end == -1:
|
||||
errors.append(f"skill `{skill_root.name}` frontmatter is not closed")
|
||||
return
|
||||
try:
|
||||
frontmatter = yaml.safe_load(contents[4:frontmatter_end])
|
||||
except yaml.YAMLError:
|
||||
errors.append(f"skill `{skill_root.name}` frontmatter must be valid YAML")
|
||||
return
|
||||
if not isinstance(frontmatter, dict):
|
||||
errors.append(f"skill `{skill_root.name}` frontmatter must be an object")
|
||||
return
|
||||
skill_name = frontmatter.get("name")
|
||||
if not isinstance(skill_name, str) or not skill_name.strip():
|
||||
errors.append(f"skill `{skill_root.name}` frontmatter field `name` must be non-empty")
|
||||
description = frontmatter.get("description")
|
||||
if not isinstance(description, str) or not description.strip():
|
||||
errors.append(
|
||||
f"skill `{skill_root.name}` frontmatter field `description` must be non-empty"
|
||||
)
|
||||
disable_model_invocation = frontmatter.get("disable-model-invocation")
|
||||
if disable_model_invocation is None:
|
||||
disable_model_invocation = frontmatter.get("disable_model_invocation")
|
||||
if disable_model_invocation not in (None, False):
|
||||
errors.append(
|
||||
f"skill `{skill_root.name}` frontmatter field `disable-model-invocation` must be false"
|
||||
)
|
||||
agent_yaml_path = skill_root / "agents" / "openai.yaml"
|
||||
if agent_yaml_path.is_file():
|
||||
validate_skill_agent_manifest(
|
||||
plugin_root=skill_root.parent.parent,
|
||||
skill_root=skill_root,
|
||||
agent_yaml_path=agent_yaml_path,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
def validate_skill_agent_manifest(
|
||||
*,
|
||||
plugin_root: Path,
|
||||
skill_root: Path,
|
||||
agent_yaml_path: Path,
|
||||
errors: list[str],
|
||||
) -> None:
|
||||
try:
|
||||
payload = yaml.safe_load(agent_yaml_path.read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
errors.append(f"unable to read skill `{skill_root.name}` agent YAML")
|
||||
return
|
||||
except yaml.YAMLError:
|
||||
errors.append(f"skill `{skill_root.name}` agent YAML must be valid YAML")
|
||||
return
|
||||
if not isinstance(payload, dict):
|
||||
errors.append(f"skill `{skill_root.name}` agent YAML must be an object")
|
||||
return
|
||||
|
||||
reject_skill_agent_unknown_fields(
|
||||
payload,
|
||||
{"interface", "policy", "dependencies"},
|
||||
skill_root,
|
||||
errors,
|
||||
)
|
||||
interface = payload.get("interface")
|
||||
if not isinstance(interface, dict):
|
||||
errors.append(f"skill `{skill_root.name}` agent field `interface` must be an object")
|
||||
return
|
||||
reject_skill_agent_unknown_fields(
|
||||
interface,
|
||||
{
|
||||
"display_name",
|
||||
"short_description",
|
||||
"icon_small",
|
||||
"icon_large",
|
||||
"brand_color",
|
||||
"default_prompt",
|
||||
},
|
||||
skill_root,
|
||||
errors,
|
||||
prefix="interface",
|
||||
)
|
||||
for field in ("display_name", "short_description"):
|
||||
value = interface.get(field)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
errors.append(
|
||||
f"skill `{skill_root.name}` agent field `interface.{field}` must be non-empty"
|
||||
)
|
||||
for field in ("icon_small", "icon_large"):
|
||||
validate_optional_asset_path(
|
||||
skill_root,
|
||||
plugin_root,
|
||||
interface,
|
||||
field,
|
||||
errors,
|
||||
prefix=f"skill `{skill_root.name}` agent field `interface",
|
||||
)
|
||||
brand_color = interface.get("brand_color")
|
||||
if brand_color is not None and (
|
||||
not isinstance(brand_color, str) or HEX_COLOR_RE.fullmatch(brand_color) is None
|
||||
):
|
||||
errors.append(
|
||||
f"skill `{skill_root.name}` agent field `interface.brand_color` must use `#RRGGBB`"
|
||||
)
|
||||
default_prompt = interface.get("default_prompt")
|
||||
if default_prompt is not None and (
|
||||
not isinstance(default_prompt, str) or not default_prompt.strip()
|
||||
):
|
||||
errors.append(
|
||||
f"skill `{skill_root.name}` agent field `interface.default_prompt` must be non-empty"
|
||||
)
|
||||
|
||||
policy = payload.get("policy")
|
||||
if policy is not None:
|
||||
if not isinstance(policy, dict):
|
||||
errors.append(f"skill `{skill_root.name}` agent field `policy` must be an object")
|
||||
else:
|
||||
reject_skill_agent_unknown_fields(
|
||||
policy,
|
||||
{"allow_implicit_invocation"},
|
||||
skill_root,
|
||||
errors,
|
||||
prefix="policy",
|
||||
)
|
||||
allow_implicit_invocation = policy.get("allow_implicit_invocation")
|
||||
if allow_implicit_invocation is not None and not isinstance(
|
||||
allow_implicit_invocation,
|
||||
bool,
|
||||
):
|
||||
errors.append(
|
||||
f"skill `{skill_root.name}` agent field "
|
||||
"`policy.allow_implicit_invocation` must be a boolean"
|
||||
)
|
||||
|
||||
dependencies = payload.get("dependencies")
|
||||
if dependencies is not None:
|
||||
if not isinstance(dependencies, dict):
|
||||
errors.append(
|
||||
f"skill `{skill_root.name}` agent field `dependencies` must be an object"
|
||||
)
|
||||
else:
|
||||
reject_skill_agent_unknown_fields(
|
||||
dependencies,
|
||||
{"tools"},
|
||||
skill_root,
|
||||
errors,
|
||||
prefix="dependencies",
|
||||
)
|
||||
|
||||
|
||||
def reject_skill_agent_unknown_fields(
|
||||
payload: dict[str, Any],
|
||||
allowed_keys: set[str],
|
||||
skill_root: Path,
|
||||
errors: list[str],
|
||||
*,
|
||||
prefix: str | None = None,
|
||||
) -> None:
|
||||
for key in sorted(set(payload) - allowed_keys):
|
||||
field = f"{prefix}.{key}" if prefix is not None else key
|
||||
errors.append(
|
||||
f"skill `{skill_root.name}` agent field `{field}` is not accepted by plugin validation"
|
||||
)
|
||||
|
||||
|
||||
def validate_optional_asset_path(
|
||||
base_dir: Path,
|
||||
allowed_root: Path,
|
||||
payload: dict[str, Any],
|
||||
key: str,
|
||||
errors: list[str],
|
||||
*,
|
||||
prefix: str = "interface",
|
||||
) -> None:
|
||||
raw_path = payload.get(key)
|
||||
if raw_path is None:
|
||||
return
|
||||
validate_asset_path(base_dir, allowed_root, raw_path, f"{prefix}.{key}", errors)
|
||||
|
||||
|
||||
def validate_asset_path(
|
||||
base_dir: Path,
|
||||
allowed_root: Path,
|
||||
raw_path: Any,
|
||||
field: str,
|
||||
errors: list[str],
|
||||
) -> None:
|
||||
label = field if field.startswith("skill `") else f"plugin.json field `{field}`"
|
||||
if not isinstance(raw_path, str) or not raw_path.strip():
|
||||
errors.append(f"{label} must be a non-empty relative path")
|
||||
return
|
||||
candidate = PurePosixPath(raw_path.replace("\\", "/"))
|
||||
if candidate.is_absolute() or any(part in {"", ".", ".."} for part in candidate.parts):
|
||||
errors.append(f"{label} must stay inside the plugin archive")
|
||||
return
|
||||
resolved_path = (base_dir / candidate.as_posix()).resolve()
|
||||
if not resolved_path.is_relative_to(allowed_root.resolve()):
|
||||
errors.append(f"{label} must stay inside the plugin archive")
|
||||
return
|
||||
if not resolved_path.is_file():
|
||||
errors.append(f"{label} points to a missing file")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user