chore: address plugin manifest review

Signed-off-by: barlind <tobias@barlind.se>
This commit is contained in:
barlind
2026-06-18 00:41:56 -07:00
committed by Bret Mogilefsky
parent e76927524a
commit 04db4a233b
6 changed files with 89 additions and 80 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ If the plugin slug or type is missing, ask once.
**Common to all types:**
- `plugins/<id>/plugin.json` — schema-valid manifest. Set `id`, `name`, `version: "0.1.0"`, `license: "AGPL-3.0-only"`, and `standards: ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"]` by default (ask if a different license is desired).
- `plugins/<id>/plugin.json` — schema-valid manifest. Set `id`, `name`, `version: "0.1.0"`, `license: "AGPL-3.0-only"`, and `standards: ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"]` by default.
**`type=visualization`** — adds:
- `"type": "visualization"` and `"script": "screen.js"` to manifest
+1 -1
View File
@@ -34,5 +34,5 @@ Thanks for the PR! A few quick reminders before you hit submit:
- [ ] DCO sign-off on every commit (`Signed-off-by:` trailer)
- [ ] Conventional-commit subject (`feat(scope):`, `fix(scope):`, `docs:`, `chore:`)
- [ ] `CHANGELOG.md` `[Unreleased]` section updated (skip for chore/docs)
- [ ] `CHANGELOG.md` `[Unreleased]` section updated
- [ ] Documentation updated if behaviour or contracts changed
+2 -2
View File
@@ -180,7 +180,7 @@ Supported declaration fields include:
- `roles`: `owner`, `coordinator`, `provider`, `observer`, `requester`, `transformer`, `handler`, `validator`, `short-circuiter`, `contributor`
- `commands`, `operations`, `requests`, `observes`, `emits`, `events`: string arrays naming public commands, provider operations, or events
- `kind`: `command`, `provider-coordinator`, `event`, `diagnostic`, `privileged`
- `mode`: `active`, `optional`, `disabled`
- `mode`: `active`, `optional`, `legacy-shim`, `disabled`
- `compatibility`: prefer `none` for new declarations
- `ownership`: `exclusive-owner`, `multi-provider`, `observer-only`, `requester-only`, `privileged`, `diagnostic-only`
- `safety`: `safe`, `privileged`, `sensitive`, `diagnostic-only`
@@ -191,7 +191,7 @@ Invalid capability metadata is rejected by schema validation and ignored by runt
### `license` (string, optional but recommended)
SPDX identifier. For curated plugins, must be AGPL-3.0-or-later or AGPL-compatible (MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0). See [CONTRIBUTING.md](../CONTRIBUTING.md).
SPDX identifier. Contributions must use `AGPL-3.0-only`. See [CONTRIBUTING.md](../CONTRIBUTING.md).
## Backend routes — `setup(app, context)`
+38 -5
View File
@@ -95,15 +95,41 @@
"type": "object"
},
"nav": {},
"screen": {},
"script": {},
"routes": {},
"settings": {},
"screen": {
"$ref": "#/$defs/pluginRelpath"
},
"script": {
"$ref": "#/$defs/pluginRelpath"
},
"routes": {
"$ref": "#/$defs/pluginRelpath"
},
"settings": {
"type": "object",
"additionalProperties": false,
"properties": {
"html": {
"$ref": "#/$defs/pluginRelpath"
},
"server_files": {
"type": "array",
"items": {
"type": "string",
"not": {
"pattern": "^/|^[a-zA-Z]:|\\\\|(^|/)\\.\\.(/|$)|//|(^|/)\\.(/|$)|^\\."
}
},
"uniqueItems": true
}
}
},
"diagnostics": {},
"type": {
"type": "string"
},
"tour": {},
"tour": {
"$ref": "#/$defs/pluginRelpath"
},
"description": {
"type": "string",
"description": "Short one-sentence summary of the plugin, surfaced on the v3 Pedalboard Plugins page (clamped to ~2 lines). Optional and additive."
@@ -127,6 +153,13 @@
},
"additionalProperties": true,
"$defs": {
"pluginRelpath": {
"type": "string",
"minLength": 1,
"not": {
"pattern": "^/|^[a-zA-Z]:|\\\\|(^|/)\\.\\.(/|$)|//|(^|/)\\.(/|$)|^\\.|[?#]"
}
},
"domainName": {
"type": "string",
"minLength": 1,
+13 -23
View File
@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/slopsmith/slopsmith/blob/main/schema/plugin.schema.json",
"title": "Slopsmith plugin manifest",
"description": "Schema for plugins/<id>/plugin.json. See docs/plugin-manifest.md for prose. License enum mirrors the curated-plugin allowlist in CONTRIBUTING.md (keep them in sync; tests/test_plugin_schema.py asserts subset). Capability metadata follows capability-pipelines.v1 and is intentionally additive.",
"description": "Schema for plugins/<id>/plugin.json. See docs/plugin-manifest.md for prose. Contributions must use AGPL-3.0-only. Capability metadata follows capability-pipelines.v1 and is intentionally additive.",
"type": "object",
"additionalProperties": true,
"required": ["id", "name"],
@@ -51,23 +51,8 @@
},
"license": {
"type": "string",
"description": "SPDX identifier. For curated plugins, must match CONTRIBUTING.md's allowlist.",
"enum": [
"AGPL-3.0-only",
"AGPL-3.0-or-later",
"GPL-3.0-only",
"GPL-3.0-or-later",
"LGPL-3.0-only",
"LGPL-3.0-or-later",
"MIT",
"BSD-2-Clause",
"BSD-3-Clause",
"Apache-2.0",
"ISC",
"Unlicense",
"CC0-1.0",
"0BSD"
]
"description": "SPDX identifier. Contributions must use AGPL-3.0-only.",
"enum": ["AGPL-3.0-only"]
},
"nav": {
"type": "object",
@@ -80,11 +65,11 @@
"description": "Optional navigation metadata. Plugin UI declares stable ui contributions for attribution."
},
"screen": {
"type": "string",
"$ref": "#/$defs/pluginRelpath",
"description": "Relative path to HTML mounted at #plugin-<id>."
},
"script": {
"type": "string",
"$ref": "#/$defs/pluginRelpath",
"description": "Relative path to JS loaded in global scope on page load. Wrap your code in an IIFE."
},
"styles": {
@@ -94,11 +79,11 @@
"description": "Plugin-root-relative path under assets/ (e.g. 'assets/plugin.css') to a compiled, preflight-off Tailwind stylesheet the frontend injects as a <link>. Must stay under assets/ with no '..', backslash, query, or fragment. See docs/plugin-styles.md."
},
"routes": {
"type": "string",
"$ref": "#/$defs/pluginRelpath",
"description": "Relative path to Python module exporting setup(app, context). See docs/plugin-manifest.md."
},
"tour": {
"type": "string",
"$ref": "#/$defs/pluginRelpath",
"description": "Relative path to tour JSON for the in-app onboarding tour."
},
"settings": {
@@ -106,7 +91,7 @@
"additionalProperties": false,
"properties": {
"html": {
"type": "string",
"$ref": "#/$defs/pluginRelpath",
"description": "Relative path to settings-panel HTML."
},
"server_files": {
@@ -195,6 +180,11 @@
}
],
"$defs": {
"pluginRelpath": {
"type": "string",
"minLength": 1,
"not": { "pattern": "^/|^[a-zA-Z]:|\\\\|(^|/)\\.\\.(/|$)|//|(^|/)\\.(/|$)|^\\.|[?#]" }
},
"domainName": {
"type": "string",
"minLength": 1,
+34 -48
View File
@@ -7,12 +7,8 @@ Four independent guarantees:
2. Each in-tree manifest's `id` matches its parent directory name —
the loader assumes this and silent drift would break plugin
discovery.
3. The `license` enum in the schema is a subset of the SPDX identifiers
listed in `CONTRIBUTING.md`'s "Plugin licensing" curated allowlist.
If you edit the allowlist in `CONTRIBUTING.md`, run pytest locally
and update the schema enum to match — these two files must stay in
sync because the same allowlist is referenced from both human-facing
docs and from CI manifest validation.
3. The `license` enum in the schema enforces AGPL-3.0-only for
contributed manifests.
4. The schema accepts capability-pipelines.v1 manifest metadata so
native capability declarations stay first-class in tooling.
"""
@@ -69,6 +65,7 @@ def test_schema_contains_capability_contract(schema: dict) -> None:
assert "diagnostic-only" in declaration["properties"]["safety"]["enum"]
assert "styles" in schema["properties"]
assert schema["properties"]["styles"]["pattern"].startswith("^assets/")
assert "pluginRelpath" in schema["$defs"]
def test_docs_schema_capability_contract_matches_ci_schema(schema: dict, docs_schema: dict) -> None:
@@ -80,8 +77,9 @@ def test_docs_schema_capability_contract_matches_ci_schema(schema: dict, docs_sc
return [without_descriptions(item) for item in value]
return value
for key in ("standards", "capability_api", "capabilities", "ui", "ui_contributions", "runtime_domains", "domains", "settings_schema", "styles"):
for key in ("standards", "capability_api", "capabilities", "ui", "ui_contributions", "runtime_domains", "domains", "settings_schema", "styles", "screen", "script", "routes", "tour", "settings"):
assert without_descriptions(docs_schema["properties"][key]) == without_descriptions(schema["properties"][key])
assert without_descriptions(docs_schema["$defs"]["pluginRelpath"]) == without_descriptions(schema["$defs"]["pluginRelpath"])
assert without_descriptions(docs_schema["$defs"]["domainName"]) == without_descriptions(schema["$defs"]["domainName"])
assert without_descriptions(docs_schema["$defs"]["capabilityDeclaration"]) == without_descriptions(schema["$defs"]["capabilityDeclaration"])
assert without_descriptions(docs_schema["$defs"]["domainDeclaration"]) == without_descriptions(schema["$defs"]["domainDeclaration"])
@@ -148,6 +146,7 @@ def test_capability_manifest_metadata_validates(schema: dict) -> None:
"id": "capability_example",
"name": "Capability Example",
"version": "0.1.0",
"license": "AGPL-3.0-only",
"standards": ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"],
"script": "screen.js",
"settings": {"html": "settings.html"},
@@ -214,48 +213,35 @@ def test_invalid_capability_metadata_fails_schema(schema: dict) -> None:
jsonschema.validate(manifest, schema)
def _extract_allowlist_from_contributing() -> set[str]:
"""Pull the curated-license allowlist out of CONTRIBUTING.md.
def test_schema_license_enum_requires_agpl_only(schema: dict) -> None:
"""Contributed manifests must not validate with non-AGPL licenses."""
assert schema["properties"]["license"]["enum"] == ["AGPL-3.0-only"]
Looks at the "Plugin licensing" section: any bullet line whose text
starts with a recognized SPDX-shape identifier is considered part of
the allowlist. Forms like "AGPL-3.0-only or AGPL-3.0-or-later" are
split on " or ".
"""
text = CONTRIBUTING_PATH.read_text(encoding="utf-8")
section = text.split("## Plugin licensing", 1)
if len(section) < 2:
pytest.fail("'## Plugin licensing' section not found in CONTRIBUTING.md")
body = section[1].split("\n## ", 1)[0]
spdx_re = re.compile(r"^[A-Za-z0-9.+-]+$")
allowlist: set[str] = set()
for line in body.splitlines():
if not line.lstrip().startswith("- "):
continue
rest = line.lstrip()[2:].strip()
# Strip trailing punctuation / parenthetical notes.
rest = re.split(r"\s*\(|\s*—|\s*--", rest)[0].strip().rstrip(".,;")
for token in re.split(r"\s+or\s+|\s*/\s+|\s*,\s+", rest):
token = token.strip().rstrip(".,;").strip()
if token and spdx_re.match(token):
allowlist.add(token)
return allowlist
manifest = {"id": "license_example", "name": "License Example", "license": "MIT"}
with pytest.raises(jsonschema.ValidationError):
jsonschema.validate(manifest, schema)
def test_schema_license_enum_subset_of_contributing_allowlist(schema: dict) -> None:
"""Schema's license enum must be ⊆ CONTRIBUTING.md curated allowlist.
def test_plugin_runtime_paths_are_plugin_relative(schema: dict) -> None:
"""Runtime file path fields must reject escapes and URL suffixes."""
valid = {
"id": "path_example",
"name": "Path Example",
"screen": "screen.html",
"script": "assets/screen.js",
"routes": "routes.py",
"tour": "tours/intro.json",
"settings": {"html": "settings/settings.html"},
}
jsonschema.validate(valid, schema)
If you add a license to the schema enum, also list it in
CONTRIBUTING.md "Plugin licensing". Direction matters: schema ⊆
allowlist (the schema can be stricter than what CONTRIBUTING.md
documents — typically the schema *equals* the allowlist).
"""
license_enum = set(schema["properties"]["license"]["enum"])
allowlist = _extract_allowlist_from_contributing()
missing = license_enum - allowlist
assert not missing, (
f"License enum values present in schema/plugin.schema.json but "
f"not listed in CONTRIBUTING.md 'Plugin licensing' section: {sorted(missing)}. "
f"Update CONTRIBUTING.md or remove from the schema enum."
)
for field in ("screen", "script", "routes", "tour"):
for bad_path in ("../escape.html", "safe/../escape.html", "/abs.html", "C:/abs.html", "dir\\file.js", "screen.html?x=1", "screen.html#frag", "./screen.html", ".hidden"):
manifest = {"id": "bad_path_example", "name": "Bad Path Example", field: bad_path}
with pytest.raises(jsonschema.ValidationError):
jsonschema.validate(manifest, schema)
for bad_path in ("../settings.html", "settings/../settings.html", "/settings.html", "settings\\settings.html", "settings.html?x=1", "settings.html#frag", "./settings.html", ".settings.html"):
manifest = {"id": "bad_settings_path", "name": "Bad Settings Path", "settings": {"html": bad_path}}
with pytest.raises(jsonschema.ValidationError):
jsonschema.validate(manifest, schema)