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:** **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`** — adds:
- `"type": "visualization"` and `"script": "screen.js"` to manifest - `"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) - [ ] DCO sign-off on every commit (`Signed-off-by:` trailer)
- [ ] Conventional-commit subject (`feat(scope):`, `fix(scope):`, `docs:`, `chore:`) - [ ] 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 - [ ] 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` - `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 - `commands`, `operations`, `requests`, `observes`, `emits`, `events`: string arrays naming public commands, provider operations, or events
- `kind`: `command`, `provider-coordinator`, `event`, `diagnostic`, `privileged` - `kind`: `command`, `provider-coordinator`, `event`, `diagnostic`, `privileged`
- `mode`: `active`, `optional`, `disabled` - `mode`: `active`, `optional`, `legacy-shim`, `disabled`
- `compatibility`: prefer `none` for new declarations - `compatibility`: prefer `none` for new declarations
- `ownership`: `exclusive-owner`, `multi-provider`, `observer-only`, `requester-only`, `privileged`, `diagnostic-only` - `ownership`: `exclusive-owner`, `multi-provider`, `observer-only`, `requester-only`, `privileged`, `diagnostic-only`
- `safety`: `safe`, `privileged`, `sensitive`, `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) ### `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)` ## Backend routes — `setup(app, context)`
+38 -5
View File
@@ -95,15 +95,41 @@
"type": "object" "type": "object"
}, },
"nav": {}, "nav": {},
"screen": {}, "screen": {
"script": {}, "$ref": "#/$defs/pluginRelpath"
"routes": {}, },
"settings": {}, "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": {}, "diagnostics": {},
"type": { "type": {
"type": "string" "type": "string"
}, },
"tour": {}, "tour": {
"$ref": "#/$defs/pluginRelpath"
},
"description": { "description": {
"type": "string", "type": "string",
"description": "Short one-sentence summary of the plugin, surfaced on the v3 Pedalboard Plugins page (clamped to ~2 lines). Optional and additive." "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, "additionalProperties": true,
"$defs": { "$defs": {
"pluginRelpath": {
"type": "string",
"minLength": 1,
"not": {
"pattern": "^/|^[a-zA-Z]:|\\\\|(^|/)\\.\\.(/|$)|//|(^|/)\\.(/|$)|^\\.|[?#]"
}
},
"domainName": { "domainName": {
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
+13 -23
View File
@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/slopsmith/slopsmith/blob/main/schema/plugin.schema.json", "$id": "https://github.com/slopsmith/slopsmith/blob/main/schema/plugin.schema.json",
"title": "Slopsmith plugin manifest", "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", "type": "object",
"additionalProperties": true, "additionalProperties": true,
"required": ["id", "name"], "required": ["id", "name"],
@@ -51,23 +51,8 @@
}, },
"license": { "license": {
"type": "string", "type": "string",
"description": "SPDX identifier. For curated plugins, must match CONTRIBUTING.md's allowlist.", "description": "SPDX identifier. Contributions must use AGPL-3.0-only.",
"enum": [ "enum": ["AGPL-3.0-only"]
"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"
]
}, },
"nav": { "nav": {
"type": "object", "type": "object",
@@ -80,11 +65,11 @@
"description": "Optional navigation metadata. Plugin UI declares stable ui contributions for attribution." "description": "Optional navigation metadata. Plugin UI declares stable ui contributions for attribution."
}, },
"screen": { "screen": {
"type": "string", "$ref": "#/$defs/pluginRelpath",
"description": "Relative path to HTML mounted at #plugin-<id>." "description": "Relative path to HTML mounted at #plugin-<id>."
}, },
"script": { "script": {
"type": "string", "$ref": "#/$defs/pluginRelpath",
"description": "Relative path to JS loaded in global scope on page load. Wrap your code in an IIFE." "description": "Relative path to JS loaded in global scope on page load. Wrap your code in an IIFE."
}, },
"styles": { "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." "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": { "routes": {
"type": "string", "$ref": "#/$defs/pluginRelpath",
"description": "Relative path to Python module exporting setup(app, context). See docs/plugin-manifest.md." "description": "Relative path to Python module exporting setup(app, context). See docs/plugin-manifest.md."
}, },
"tour": { "tour": {
"type": "string", "$ref": "#/$defs/pluginRelpath",
"description": "Relative path to tour JSON for the in-app onboarding tour." "description": "Relative path to tour JSON for the in-app onboarding tour."
}, },
"settings": { "settings": {
@@ -106,7 +91,7 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"html": { "html": {
"type": "string", "$ref": "#/$defs/pluginRelpath",
"description": "Relative path to settings-panel HTML." "description": "Relative path to settings-panel HTML."
}, },
"server_files": { "server_files": {
@@ -195,6 +180,11 @@
} }
], ],
"$defs": { "$defs": {
"pluginRelpath": {
"type": "string",
"minLength": 1,
"not": { "pattern": "^/|^[a-zA-Z]:|\\\\|(^|/)\\.\\.(/|$)|//|(^|/)\\.(/|$)|^\\.|[?#]" }
},
"domainName": { "domainName": {
"type": "string", "type": "string",
"minLength": 1, "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 — 2. Each in-tree manifest's `id` matches its parent directory name —
the loader assumes this and silent drift would break plugin the loader assumes this and silent drift would break plugin
discovery. discovery.
3. The `license` enum in the schema is a subset of the SPDX identifiers 3. The `license` enum in the schema enforces AGPL-3.0-only for
listed in `CONTRIBUTING.md`'s "Plugin licensing" curated allowlist. contributed manifests.
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.
4. The schema accepts capability-pipelines.v1 manifest metadata so 4. The schema accepts capability-pipelines.v1 manifest metadata so
native capability declarations stay first-class in tooling. 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 "diagnostic-only" in declaration["properties"]["safety"]["enum"]
assert "styles" in schema["properties"] assert "styles" in schema["properties"]
assert schema["properties"]["styles"]["pattern"].startswith("^assets/") 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: 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 [without_descriptions(item) for item in value]
return 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["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"]["domainName"]) == without_descriptions(schema["$defs"]["domainName"])
assert without_descriptions(docs_schema["$defs"]["capabilityDeclaration"]) == without_descriptions(schema["$defs"]["capabilityDeclaration"]) assert without_descriptions(docs_schema["$defs"]["capabilityDeclaration"]) == without_descriptions(schema["$defs"]["capabilityDeclaration"])
assert without_descriptions(docs_schema["$defs"]["domainDeclaration"]) == without_descriptions(schema["$defs"]["domainDeclaration"]) 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", "id": "capability_example",
"name": "Capability Example", "name": "Capability Example",
"version": "0.1.0", "version": "0.1.0",
"license": "AGPL-3.0-only",
"standards": ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"], "standards": ["capability-pipelines.v1", "plugin-runtime-idempotent.v1"],
"script": "screen.js", "script": "screen.js",
"settings": {"html": "settings.html"}, "settings": {"html": "settings.html"},
@@ -214,48 +213,35 @@ def test_invalid_capability_metadata_fails_schema(schema: dict) -> None:
jsonschema.validate(manifest, schema) jsonschema.validate(manifest, schema)
def _extract_allowlist_from_contributing() -> set[str]: def test_schema_license_enum_requires_agpl_only(schema: dict) -> None:
"""Pull the curated-license allowlist out of CONTRIBUTING.md. """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 manifest = {"id": "license_example", "name": "License Example", "license": "MIT"}
starts with a recognized SPDX-shape identifier is considered part of with pytest.raises(jsonschema.ValidationError):
the allowlist. Forms like "AGPL-3.0-only or AGPL-3.0-or-later" are jsonschema.validate(manifest, schema)
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
def test_schema_license_enum_subset_of_contributing_allowlist(schema: dict) -> None: def test_plugin_runtime_paths_are_plugin_relative(schema: dict) -> None:
"""Schema's license enum must be ⊆ CONTRIBUTING.md curated allowlist. """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 for field in ("screen", "script", "routes", "tour"):
CONTRIBUTING.md "Plugin licensing". Direction matters: schema ⊆ 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"):
allowlist (the schema can be stricter than what CONTRIBUTING.md manifest = {"id": "bad_path_example", "name": "Bad Path Example", field: bad_path}
documents — typically the schema *equals* the allowlist). with pytest.raises(jsonschema.ValidationError):
""" jsonschema.validate(manifest, schema)
license_enum = set(schema["properties"]["license"]["enum"])
allowlist = _extract_allowlist_from_contributing() 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"):
missing = license_enum - allowlist manifest = {"id": "bad_settings_path", "name": "Bad Settings Path", "settings": {"html": bad_path}}
assert not missing, ( with pytest.raises(jsonschema.ValidationError):
f"License enum values present in schema/plugin.schema.json but " jsonschema.validate(manifest, schema)
f"not listed in CONTRIBUTING.md 'Plugin licensing' section: {sorted(missing)}. "
f"Update CONTRIBUTING.md or remove from the schema enum."
)