feat: collapse plugin runtime to one tier behind a plugin SDK

This commit is contained in:
LukeGus committed 2026-09-22 14:11:50 -05:00
1 parent 6e058f6e9e
commit 4758016f41
96 files changed
+34631 -8551

No files matched your search

@@ -1,10 +1,10 @@
{
"id": "sample-bad-action",
"name": "Sample Bad Action",
"id": "sample-plugin",
"name": "Sample",
"version": "1.0.0",
"description": "An action gated by a permission the plugin never declares.",
"description": "A sample.",
"author": {
"name": "Jane Doe"
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
@@ -12,26 +12,19 @@
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": {
"backend": false,
"frontend": true,
"electron": false,
"platforms": ["linux", "win32", "darwin"]
},
"permissions": ["ui.tab"],
"capabilities": ["kv:own", "ui:surface"],
"contributes": {
"permissionGroup": {
"group": "samplebadaction",
"permissions": ["samplebadaction.use"]
"group": "sample-plugin",
"permissions": ["sample-plugin.use"]
},
"actions": [
{
"id": "samplebadaction.open",
"titleKey": "sampleBadAction.open",
"id": "sample.open",
"titleKey": "sample.open",
"handler": "open",
"permission": "samplebadaction.never_declared"
"permission": "sample-plugin.nope"
}
]
},
"sidecars": []
}
}
@@ -1,10 +1,10 @@
{
"id": "Sample_Plugin!",
"name": "Sample Plugin",
"id": "Sample_Plugin",
"name": "Sample",
"version": "1.0.0",
"description": "Has an id that violates the required pattern.",
"description": "A sample.",
"author": {
"name": "Jane Doe"
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
@@ -12,12 +12,5 @@
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": {
"backend": true,
"frontend": true,
"electron": false,
"platforms": ["linux"]
},
"permissions": [],
"sidecars": []
"capabilities": ["kv:own", "ui:surface"]
}
@@ -1,9 +1,9 @@
{
"id": "sample-plugin",
"name": "Sample Plugin",
"name": "Sample",
"version": "1.0.0",
"description": "A sample.",
"author": {
"name": "Jane Doe"
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
@@ -11,12 +11,5 @@
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": {
"backend": true,
"frontend": true,
"electron": false,
"platforms": ["linux"]
},
"permissions": [],
"sidecars": []
"capabilities": ["kv:own", "ui:surface"]
}
@@ -0,0 +1,27 @@
{
"id": "sample-plugin",
"name": "Sample",
"version": "1.0.0",
"description": "A sample.",
"author": {
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": ["kv:own", "ui:surface"],
"contributes": {
"tabs": [
{
"id": "t",
"titleKey": "k",
"icon": "i",
"openFrom": ["rail"],
"extra": 1
}
]
}
}
@@ -0,0 +1,25 @@
{
"id": "sample-plugin",
"name": "Sample",
"version": "1.0.0",
"description": "A sample.",
"author": {
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": ["kv:own", "ui:surface"],
"contributes": {
"permissionGroup": {
"group": "sample-plugin",
"permissions": ["sample-plugin.use"],
"defaultForRole": {
"user": ["admin.users.manage"]
}
}
}
}
@@ -1,10 +1,10 @@
{
"id": "sample-provider",
"name": "Sample Provider",
"id": "sample-plugin",
"name": "Sample",
"version": "1.0.0",
"description": "Gates a service behind a permission its own group never declares.",
"description": "A sample.",
"author": {
"name": "Jane Doe"
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
@@ -12,25 +12,18 @@
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": {
"backend": true,
"frontend": false,
"electron": false,
"platforms": ["linux"]
},
"permissions": ["hosts.read"],
"contributes": {
"permissionGroup": {
"group": "sampleprovider",
"permissions": ["sampleprovider.greet.use"]
}
},
"capabilities": ["kv:own", "ui:surface"],
"provides": [
{
"service": "sampleprovider.greet",
"version": "1.2.0",
"permission": "sampleprovider.undeclared"
"service": "sample.thing",
"version": "1.0.0",
"permission": "sample-plugin.undeclared"
}
],
"sidecars": []
"contributes": {
"permissionGroup": {
"group": "sample-plugin",
"permissions": ["sample-plugin.use"]
}
}
}
@@ -0,0 +1,16 @@
{
"id": "sample-plugin",
"name": "Sample",
"version": "1.0.0",
"description": "A sample.",
"author": {
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": ["hosts.read"]
}
@@ -1,23 +1,16 @@
{
"id": "sample-plugin",
"name": "Sample Plugin",
"name": "Sample",
"version": "1.0.0",
"description": "Has a category that is not in the known category list.",
"description": "A sample.",
"author": {
"name": "Jane Doe"
"name": "Termix"
},
"license": "MIT",
"category": "Miscellaneous",
"category": "Nonsense",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": {
"backend": true,
"frontend": true,
"electron": false,
"platforms": ["linux"]
},
"permissions": [],
"sidecars": []
"capabilities": ["kv:own", "ui:surface"]
}
@@ -0,0 +1,17 @@
{
"id": "sample-plugin",
"name": "Sample",
"version": "1.0.0",
"description": "A sample.",
"author": {
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": ["kv:own", "ui:surface"],
"sudoEverything": true
}
@@ -1,23 +0,0 @@
{
"id": "sample-plugin",
"name": "Sample Plugin",
"version": "1.0.0",
"description": "Requests a permission that is not in the known capability list.",
"author": {
"name": "Jane Doe"
},
"license": "MIT",
"category": "Productivity",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": {
"backend": true,
"frontend": true,
"electron": false,
"platforms": ["linux"]
},
"permissions": ["credentials.read"],
"sidecars": []
}
@@ -1,10 +1,10 @@
{
"id": "sample-actions",
"name": "Sample Actions",
"id": "sample-plugin",
"name": "Sample",
"version": "1.0.0",
"description": "A plugin that contributes a UI action and offers a slot of its own.",
"description": "A sample.",
"author": {
"name": "Jane Doe"
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
@@ -12,41 +12,19 @@
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": {
"backend": false,
"frontend": true,
"electron": false,
"platforms": ["linux", "win32", "darwin"]
},
"permissions": ["ui.tab"],
"capabilities": ["kv:own", "ui:surface"],
"contributes": {
"permissionGroup": {
"group": "sampleactions",
"permissions": ["sampleactions.use"]
"group": "sample-plugin",
"permissions": ["sample-plugin.use"]
},
"actions": [
{
"id": "sampleactions.openWithContext",
"titleKey": "sampleActions.open",
"handler": "openWithContext",
"icon": "Sparkles",
"permission": "sampleactions.use",
"slot": "terminal.toolbar",
"kind": "button"
},
{
"id": "sampleactions.ungated",
"titleKey": "sampleActions.ungated",
"handler": "ungated"
}
],
"actionSlots": [
{
"id": "sampleactions.panel",
"accepts": ["button"],
"descriptionKey": "sampleActions.panelDescription"
"id": "sample.open",
"titleKey": "sample.open",
"handler": "open",
"permission": "sample-plugin.use"
}
]
},
"sidecars": []
}
}
@@ -1,10 +1,10 @@
{
"id": "sample-provider",
"name": "Sample Provider",
"id": "sample-plugin",
"name": "Sample",
"version": "1.0.0",
"description": "A plugin that both provides and requires a named service.",
"description": "A sample.",
"author": {
"name": "Jane Doe"
"name": "Termix"
},
"license": "MIT",
"category": "Productivity",
@@ -12,32 +12,18 @@
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": {
"backend": true,
"frontend": false,
"electron": false,
"platforms": ["linux", "win32", "darwin"]
},
"permissions": ["hosts.read"],
"contributes": {
"permissionGroup": {
"group": "sampleprovider",
"permissions": ["sampleprovider.greet.use"]
}
},
"capabilities": ["kv:own", "ui:surface"],
"provides": [
{
"service": "sampleprovider.greet",
"version": "1.2.0",
"permission": "sampleprovider.greet.use"
"service": "sample.thing",
"version": "1.0.0",
"permission": "sample-plugin.use"
}
],
"requires": [
{
"service": "other.thing",
"versionRange": "^1.0.0",
"optional": true
"contributes": {
"permissionGroup": {
"group": "sample-plugin",
"permissions": ["sample-plugin.use"]
}
],
"sidecars": []
}
}
+4 -33
View File
@@ -1,45 +1,16 @@
{
"id": "sample-plugin",
"name": "Sample Plugin",
"name": "Sample",
"version": "1.0.0",
"description": "A sample plugin used for schema validation tests.",
"description": "A sample.",
"author": {
"name": "Jane Doe",
"url": "https://example.com"
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/example/sample-plugin",
"category": "Productivity",
"icon": "Box",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": {
"backend": true,
"frontend": true,
"electron": false,
"platforms": ["linux", "win32", "darwin"]
},
"permissions": ["hosts.read", "ui.tab", "ui.rail"],
"contributes": {
"tabs": [
{
"id": "sample-plugin-tab",
"titleKey": "samplePlugin.tab.title",
"icon": "Box",
"openFrom": ["rail", "palette"]
}
],
"settingsPanel": {
"titleKey": "samplePlugin.settings.title"
},
"apiPrefix": "sample"
},
"dependencies": {
"plugins": {
"other-plugin": "^1.0.0"
}
},
"sidecars": []
"capabilities": ["kv:own", "ui:surface"]
}
-415
View File
@@ -1,415 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://termix.site/schemas/plugin-manifest.schema.json",
"title": "Termix Plugin Manifest",
"type": "object",
"additionalProperties": false,
"required": [
"id",
"name",
"version",
"description",
"author",
"license",
"engine",
"category",
"capabilities",
"permissions",
"sidecars"
],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z0-9-]+$",
"description": "Unique plugin id, lowercase alphanumeric and hyphens only."
},
"name": {
"type": "string",
"minLength": 1
},
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$",
"description": "Semver version string."
},
"description": {
"type": "string",
"minLength": 1
},
"author": {
"type": "object",
"additionalProperties": false,
"required": ["name"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"url": { "type": "string", "format": "uri" }
}
},
"license": {
"type": "string",
"minLength": 1
},
"repository": {
"type": "string",
"format": "uri"
},
"category": {
"type": "string",
"enum": [
"Terminal",
"Files & Transfer",
"Infrastructure",
"Monitoring",
"Networking",
"Access & Security",
"Productivity"
],
"description": "Store category used for browsing/filtering. Must match one of the categories the store UI filters by."
},
"icon": {
"type": "string",
"minLength": 1,
"description": "Either a Lucide icon name (same convention as contributes.tabs[].icon) or a relative path to an icon.svg shipped in the plugin package root. If both a Lucide name and an icon.svg are present, the registry-index build should prefer icon.svg."
},
"engine": {
"type": "object",
"additionalProperties": false,
"required": ["termix", "api"],
"properties": {
"termix": {
"type": "string",
"minLength": 1,
"description": "Informational-only Termix version range this plugin targets. Not mechanically enforced."
},
"api": {
"type": "string",
"pattern": "^[0-9]+$",
"description": "Plugin SDK major version, as a string. The real compatibility gate."
}
}
},
"capabilities": {
"type": "object",
"additionalProperties": false,
"required": ["backend", "frontend", "electron", "platforms"],
"properties": {
"backend": { "type": "boolean" },
"frontend": { "type": "boolean" },
"electron": { "type": "boolean" },
"platforms": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
},
"permissions": {
"type": "array",
"items": {
"type": "string",
"enum": [
"hosts.read",
"hosts.write",
"credentials.use",
"ssh.exec",
"ssh.sftp",
"storage.own",
"storage.secrets",
"network.outbound",
"events.read",
"notify.send",
"users.read",
"process.sidecar",
"process:transport-owner",
"ui.tab",
"ui.rail",
"ui.card",
"ui.settings",
"ui.palette"
]
}
},
"contributes": {
"type": "object",
"additionalProperties": false,
"properties": {
"tabs": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "titleKey", "icon", "openFrom"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"titleKey": { "type": "string", "minLength": 1 },
"icon": { "type": "string", "minLength": 1 },
"openFrom": {
"type": "array",
"items": {
"type": "string",
"enum": ["rail", "host-context-menu", "palette"]
},
"minItems": 1
}
}
}
},
"hostCapability": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"required": ["key", "labelKey", "editorTab"],
"properties": {
"key": { "type": "string", "minLength": 1 },
"labelKey": { "type": "string", "minLength": 1 },
"editorTab": { "type": "string", "minLength": 1 }
}
},
{
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["key", "labelKey", "editorTab"],
"properties": {
"key": { "type": "string", "minLength": 1 },
"labelKey": { "type": "string", "minLength": 1 },
"editorTab": { "type": "string", "minLength": 1 }
}
}
}
]
},
"permissionGroup": {
"type": "object",
"additionalProperties": false,
"required": ["group", "permissions"],
"properties": {
"group": { "type": "string", "minLength": 1 },
"permissions": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"minItems": 1
},
"defaultForRole": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
}
},
"settingsPanel": {
"type": "object",
"additionalProperties": false,
"required": ["titleKey"],
"properties": {
"titleKey": { "type": "string", "minLength": 1 }
}
},
"dashboardCards": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "titleKey"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"titleKey": { "type": "string", "minLength": 1 }
}
}
},
"apiPrefix": {
"type": "string",
"pattern": "^[a-z0-9-]+$",
"description": "Mounted at /plugin-api/<id>/<apiPrefix>/*."
},
"actions": {
"type": "array",
"description": "Named frontend actions this plugin exposes for other plugins to invoke.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "titleKey", "handler"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$",
"description": "Dotted, globally unique action name, e.g. \"ai.openWithContext\"."
},
"titleKey": { "type": "string", "minLength": 1 },
"handler": {
"type": "string",
"pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$",
"description": "Name of the frontend export implementing this action. The runtime binding is registerAction(id, fn)."
},
"icon": { "type": "string", "minLength": 1 },
"permission": {
"type": "string",
"minLength": 1,
"description": "Role permission a user needs. Must also appear in contributes.permissionGroup.permissions."
},
"slot": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$",
"description": "Slot this action is intended for. Advisory; binding happens at runtime."
},
"kind": { "type": "string", "enum": ["button"] }
}
}
},
"actionSlots": {
"type": "array",
"description": "Places this plugin offers other plugins to contribute a UI element.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "accepts"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$",
"description": "Dotted slot name, e.g. \"terminal.toolbar\"."
},
"accepts": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "enum": ["button"] },
"description": "Contribution kinds this slot will render. Checked in the frontend at registration time."
},
"descriptionKey": { "type": "string", "minLength": 1 }
}
}
}
}
},
"dependencies": {
"type": "object",
"additionalProperties": false,
"properties": {
"plugins": {
"type": "object",
"additionalProperties": {
"type": "string",
"minLength": 1,
"description": "Semver range string."
}
}
}
},
"provides": {
"type": "array",
"description": "Named services this plugin exposes to other plugins.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["service", "version", "permission"],
"properties": {
"service": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$",
"description": "Dotted service name, e.g. \"ssh.transport\"."
},
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$",
"description": "Semver version of this service contract."
},
"permission": {
"type": "string",
"minLength": 1,
"description": "Role permission a calling user needs. Must also appear in contributes.permissionGroup.permissions."
}
}
}
},
"requires": {
"type": "array",
"description": "Named services this plugin needs another active plugin to provide.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["service", "versionRange"],
"properties": {
"service": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$"
},
"versionRange": {
"type": "string",
"minLength": 1,
"description": "Semver range, e.g. \"^1.2.0\"."
},
"optional": {
"type": "boolean",
"description": "When true, an unsatisfied requirement is skipped instead of failing activation."
}
}
}
},
"providesSecret": {
"type": "array",
"description": "Secrets this plugin offers other plugins by reference. The value is never copied; a borrower re-reads it per call.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["key", "permission"],
"properties": {
"key": {
"type": "string",
"pattern": "^[a-z0-9-]+$",
"description": "Secret name within this plugin, e.g. \"api-key\"."
},
"permission": {
"type": "string",
"minLength": 1,
"description": "Role permission a borrowing user needs. Must also appear in contributes.permissionGroup.permissions."
},
"descriptionKey": {
"type": "string",
"minLength": 1,
"description": "i18n key describing the sharing, shown in the admin UI."
}
}
}
},
"requiresSecret": {
"type": "array",
"description": "Secrets this plugin borrows from another plugin. Reads resolve to null when the provider is absent or has nothing set.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["plugin", "key"],
"properties": {
"plugin": {
"type": "string",
"pattern": "^[a-z0-9-]+$",
"description": "Plugin id that offers the secret."
},
"key": {
"type": "string",
"pattern": "^[a-z0-9-]+$"
},
"optional": {
"type": "boolean",
"description": "When true, an absent provider is normal rather than reported as an unmet requirement."
}
}
}
},
"sidecars": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "binary"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"binary": { "type": "string", "minLength": 1 }
}
}
}
}
}
+67 -601
View File
@@ -1,622 +1,88 @@
/**
* Validates a plugin manifest.json against scripts/plugin-manifest.schema.json.
* Validates plugin manifests against the SDK contract.
*
* Hand-rolled rather than pulling in ajv: the schema is small and the checks
* that matter (required fields, id pattern, semver, known permissions) are a
* handful of straightforward comparisons.
* node scripts/validate-plugin-manifest.cjs # every bundled manifest
* node scripts/validate-plugin-manifest.cjs path/to/manifest.json
*
* Usage: node scripts/validate-plugin-manifest.cjs <path-to-manifest.json>
* The rules live in packages/plugin-sdk/src/manifest.ts, which the server
* uses too, so there is one implementation rather than a copy here that
* drifts. This file only finds the manifests and prints the result.
*/
const fs = require("fs");
const path = require("path");
const semver = require("semver");
const fs = require("node:fs");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const SCHEMA_PATH = path.join(__dirname, "plugin-manifest.schema.json");
const root = path.resolve(__dirname, "..");
const sdkEntry = path.join(root, "packages", "plugin-sdk", "dist", "manifest.js");
const ID_PATTERN = /^[a-z0-9-]+$/;
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
const API_VERSION_PATTERN = /^[0-9]+$/;
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/;
// Dotted like a service name, but segments may be camelCase: these name a
// frontend function ("ai.openWithContext"), not a lowercase service contract.
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"];
const ACTION_CONTRIBUTION_KINDS = ["button"];
function loadSchema() {
const raw = fs.readFileSync(SCHEMA_PATH, "utf8");
return JSON.parse(raw);
}
function knownPermissions(schema) {
return schema.properties.permissions.items.enum;
}
function knownCategories(schema) {
return schema.properties.category.enum;
}
function validateManifest(manifest, schema) {
const errors = [];
const requiredTopLevel = schema.required;
for (const field of requiredTopLevel) {
if (!(field in manifest)) {
errors.push(`Missing required field: "${field}"`);
}
}
if (typeof manifest.id === "string" && !ID_PATTERN.test(manifest.id)) {
errors.push(`Field "id" must match ${ID_PATTERN}, got: "${manifest.id}"`);
}
if (
typeof manifest.version === "string" &&
!SEMVER_PATTERN.test(manifest.version)
) {
errors.push(
`Field "version" must be valid semver, got: "${manifest.version}"`,
);
}
if (manifest.author && typeof manifest.author === "object") {
if (!manifest.author.name) {
errors.push('Field "author.name" is required');
}
}
if (typeof manifest.category === "string") {
const categories = knownCategories(schema);
if (!categories.includes(manifest.category)) {
errors.push(
`Field "category" must be one of: ${categories.join(", ")}, got: "${manifest.category}"`,
);
}
} else if ("category" in manifest) {
errors.push('Field "category" must be a string');
}
if ("icon" in manifest && typeof manifest.icon !== "string") {
errors.push('Field "icon" must be a string');
}
if (manifest.engine && typeof manifest.engine === "object") {
if (!manifest.engine.termix) {
errors.push('Field "engine.termix" is required');
}
if (!API_VERSION_PATTERN.test(String(manifest.engine.api))) {
errors.push(
`Field "engine.api" must be an integer-as-string, got: "${manifest.engine.api}"`,
);
}
}
if (manifest.capabilities && typeof manifest.capabilities === "object") {
for (const field of ["backend", "frontend", "electron"]) {
if (typeof manifest.capabilities[field] !== "boolean") {
errors.push(`Field "capabilities.${field}" must be a boolean`);
}
}
if (!Array.isArray(manifest.capabilities.platforms)) {
errors.push('Field "capabilities.platforms" must be an array');
}
}
const known = knownPermissions(schema);
if (Array.isArray(manifest.permissions)) {
for (const permission of manifest.permissions) {
if (!known.includes(permission)) {
errors.push(
`Unknown permission: "${permission}". Known values: ${known.join(", ")}`,
);
}
}
} else if ("permissions" in manifest) {
errors.push('Field "permissions" must be an array');
}
if (Array.isArray(manifest.sidecars)) {
manifest.sidecars.forEach((sidecar, index) => {
if (!sidecar || typeof sidecar !== "object") {
errors.push(`sidecars[${index}] must be an object`);
return;
}
if (!sidecar.id) errors.push(`sidecars[${index}].id is required`);
if (!sidecar.binary) errors.push(`sidecars[${index}].binary is required`);
});
} else if ("sidecars" in manifest) {
errors.push('Field "sidecars" must be an array');
}
errors.push(...validateProvides(manifest.provides));
errors.push(...validateRequires(manifest.requires));
errors.push(...validateProvidesSecret(manifest.providesSecret));
errors.push(...validateRequiresSecret(manifest.requiresSecret));
if (manifest.contributes && typeof manifest.contributes === "object") {
errors.push(...validateContributes(manifest.contributes));
}
// A service permission has to be one the plugin's own permissionGroup
// declares, or no admin could ever grant it.
const declared = manifest.contributes?.permissionGroup?.permissions;
if (Array.isArray(manifest.provides) && Array.isArray(declared)) {
for (const entry of manifest.provides) {
if (entry && entry.permission && !declared.includes(entry.permission)) {
errors.push(
`Service "${entry.service}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`,
);
}
}
}
// Same rule for a shared secret: sharing rides on the provider's own RBAC
// permission rather than a separate grant mechanism.
if (Array.isArray(manifest.providesSecret) && Array.isArray(declared)) {
for (const entry of manifest.providesSecret) {
if (entry && entry.permission && !declared.includes(entry.permission)) {
errors.push(
`Secret "${entry.key}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`,
);
}
}
}
if (Array.isArray(manifest.requiresSecret)) {
for (const entry of manifest.requiresSecret) {
if (entry && entry.plugin && entry.plugin === manifest.id) {
errors.push(
`requiresSecret entry for "${entry.key}" names this plugin itself; use ctx.secrets.get instead`,
);
}
}
}
// Same rule for UI actions: a permission the catalog never sees is one no
// admin can grant, so the action would be invisible rather than denied.
const actions = manifest.contributes?.actions;
if (Array.isArray(actions)) {
for (const action of actions) {
if (!action || !action.permission) continue;
if (!Array.isArray(declared) || !declared.includes(action.permission)) {
errors.push(
`Action "${action.id}" is gated by "${action.permission}", which is not declared in contributes.permissionGroup.permissions`,
);
}
}
}
return errors;
}
function validateProvides(provides) {
if (provides === undefined) return [];
if (!Array.isArray(provides)) return ['Field "provides" must be an array'];
const errors = [];
const seen = new Set();
provides.forEach((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`provides[${index}] must be an object`);
return;
}
if (
typeof entry.service !== "string" ||
!SERVICE_PATTERN.test(entry.service)
) {
errors.push(
`provides[${index}].service must match ${SERVICE_PATTERN}, got: "${entry.service}"`,
);
} else if (seen.has(entry.service)) {
errors.push(
`provides[${index}].service is a duplicate: "${entry.service}"`,
);
} else {
seen.add(entry.service);
}
if (
typeof entry.version !== "string" ||
!SEMVER_PATTERN.test(entry.version)
) {
errors.push(
`provides[${index}].version must be valid semver, got: "${entry.version}"`,
);
}
if (typeof entry.permission !== "string" || entry.permission.length === 0) {
errors.push(`provides[${index}].permission is required`);
}
});
return errors;
}
function validateRequires(requires) {
if (requires === undefined) return [];
if (!Array.isArray(requires)) return ['Field "requires" must be an array'];
const errors = [];
const seen = new Set();
requires.forEach((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`requires[${index}] must be an object`);
return;
}
if (
typeof entry.service !== "string" ||
!SERVICE_PATTERN.test(entry.service)
) {
errors.push(
`requires[${index}].service must match ${SERVICE_PATTERN}, got: "${entry.service}"`,
);
} else if (seen.has(entry.service)) {
errors.push(
`requires[${index}].service is a duplicate: "${entry.service}"`,
);
} else {
seen.add(entry.service);
}
if (
typeof entry.versionRange !== "string" ||
semver.validRange(entry.versionRange) === null
) {
errors.push(
`requires[${index}].versionRange must be a valid semver range, got: "${entry.versionRange}"`,
);
}
if ("optional" in entry && typeof entry.optional !== "boolean") {
errors.push(`requires[${index}].optional must be a boolean`);
}
});
return errors;
}
function validateProvidesSecret(providesSecret) {
if (providesSecret === undefined) return [];
if (!Array.isArray(providesSecret)) {
return ['Field "providesSecret" must be an array'];
}
const errors = [];
const seen = new Set();
providesSecret.forEach((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`providesSecret[${index}] must be an object`);
return;
}
if (typeof entry.key !== "string" || !SECRET_KEY_PATTERN.test(entry.key)) {
errors.push(
`providesSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${entry.key}"`,
);
} else if (seen.has(entry.key)) {
errors.push(
`providesSecret[${index}].key is a duplicate: "${entry.key}"`,
);
} else {
seen.add(entry.key);
}
if (typeof entry.permission !== "string" || entry.permission.length === 0) {
errors.push(`providesSecret[${index}].permission is required`);
}
if (
"descriptionKey" in entry &&
(typeof entry.descriptionKey !== "string" ||
entry.descriptionKey.length === 0)
) {
errors.push(`providesSecret[${index}].descriptionKey must be a string`);
}
});
return errors;
}
function validateRequiresSecret(requiresSecret) {
if (requiresSecret === undefined) return [];
if (!Array.isArray(requiresSecret)) {
return ['Field "requiresSecret" must be an array'];
}
const errors = [];
const seen = new Set();
requiresSecret.forEach((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`requiresSecret[${index}] must be an object`);
return;
}
if (typeof entry.plugin !== "string" || !ID_PATTERN.test(entry.plugin)) {
errors.push(
`requiresSecret[${index}].plugin must match ${ID_PATTERN}, got: "${entry.plugin}"`,
);
}
if (typeof entry.key !== "string" || !SECRET_KEY_PATTERN.test(entry.key)) {
errors.push(
`requiresSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${entry.key}"`,
);
}
if (typeof entry.plugin === "string" && typeof entry.key === "string") {
const id = `${entry.plugin}:${entry.key}`;
if (seen.has(id)) {
errors.push(`requiresSecret[${index}] is a duplicate: "${id}"`);
} else {
seen.add(id);
}
}
if ("optional" in entry && typeof entry.optional !== "boolean") {
errors.push(`requiresSecret[${index}].optional must be a boolean`);
}
});
return errors;
}
function validateContributes(contributes) {
const errors = [];
if ("tabs" in contributes) {
if (!Array.isArray(contributes.tabs)) {
errors.push('Field "contributes.tabs" must be an array');
} else {
contributes.tabs.forEach((tab, index) => {
for (const field of ["id", "titleKey", "icon", "openFrom"]) {
if (!(field in (tab || {}))) {
errors.push(`contributes.tabs[${index}].${field} is required`);
}
}
if (Array.isArray(tab?.openFrom)) {
for (const value of tab.openFrom) {
if (!OPEN_FROM_VALUES.includes(value)) {
errors.push(
`contributes.tabs[${index}].openFrom has unknown value: "${value}"`,
);
}
}
}
});
}
}
if ("hostCapability" in contributes) {
const raw = contributes.hostCapability;
const entries = Array.isArray(raw) ? raw : [raw];
const seenKeys = new Set();
entries.forEach((entry, index) => {
const hc = entry || {};
const prefix = Array.isArray(raw)
? `contributes.hostCapability[${index}]`
: "contributes.hostCapability";
for (const field of ["key", "labelKey", "editorTab"]) {
if (!(field in hc)) {
errors.push(`${prefix}.${field} is required`);
}
}
if (typeof hc.key === "string") {
if (seenKeys.has(hc.key)) {
errors.push(`${prefix}.key is a duplicate: "${hc.key}"`);
}
seenKeys.add(hc.key);
}
});
}
if ("permissionGroup" in contributes) {
const group = contributes.permissionGroup || {};
if (!group.group)
errors.push("contributes.permissionGroup.group is required");
if (!Array.isArray(group.permissions) || group.permissions.length === 0) {
errors.push(
"contributes.permissionGroup.permissions must be a non-empty array",
);
}
}
if ("settingsPanel" in contributes) {
if (!contributes.settingsPanel?.titleKey) {
errors.push("contributes.settingsPanel.titleKey is required");
}
}
if ("dashboardCards" in contributes) {
if (!Array.isArray(contributes.dashboardCards)) {
errors.push('Field "contributes.dashboardCards" must be an array');
} else {
contributes.dashboardCards.forEach((card, index) => {
for (const field of ["id", "titleKey"]) {
if (!(field in (card || {}))) {
errors.push(
`contributes.dashboardCards[${index}].${field} is required`,
);
}
}
});
}
}
if ("actions" in contributes) {
errors.push(...validateActions(contributes.actions));
}
if ("actionSlots" in contributes) {
errors.push(...validateActionSlots(contributes.actionSlots));
}
return errors;
}
function validateActions(actions) {
if (!Array.isArray(actions)) {
return ['Field "contributes.actions" must be an array'];
}
const errors = [];
const seen = new Set();
actions.forEach((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`contributes.actions[${index}] must be an object`);
return;
}
if (typeof entry.id !== "string" || !ACTION_ID_PATTERN.test(entry.id)) {
errors.push(
`contributes.actions[${index}].id must match ${ACTION_ID_PATTERN}, got: "${entry.id}"`,
);
} else if (seen.has(entry.id)) {
errors.push(
`contributes.actions[${index}].id is a duplicate: "${entry.id}"`,
);
} else {
seen.add(entry.id);
}
if (typeof entry.titleKey !== "string" || entry.titleKey.length === 0) {
errors.push(`contributes.actions[${index}].titleKey is required`);
}
if (
typeof entry.handler !== "string" ||
!HANDLER_PATTERN.test(entry.handler)
) {
errors.push(
`contributes.actions[${index}].handler must match ${HANDLER_PATTERN}, got: "${entry.handler}"`,
);
}
if ("icon" in entry && typeof entry.icon !== "string") {
errors.push(`contributes.actions[${index}].icon must be a string`);
}
if ("permission" in entry && typeof entry.permission !== "string") {
errors.push(`contributes.actions[${index}].permission must be a string`);
}
if (
"slot" in entry &&
(typeof entry.slot !== "string" || !ACTION_ID_PATTERN.test(entry.slot))
) {
errors.push(
`contributes.actions[${index}].slot must match ${ACTION_ID_PATTERN}, got: "${entry.slot}"`,
);
}
if ("kind" in entry && !ACTION_CONTRIBUTION_KINDS.includes(entry.kind)) {
errors.push(
`contributes.actions[${index}].kind must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}, got: "${entry.kind}"`,
);
}
});
return errors;
}
function validateActionSlots(slots) {
if (!Array.isArray(slots)) {
return ['Field "contributes.actionSlots" must be an array'];
}
const errors = [];
const seen = new Set();
slots.forEach((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`contributes.actionSlots[${index}] must be an object`);
return;
}
if (typeof entry.id !== "string" || !ACTION_ID_PATTERN.test(entry.id)) {
errors.push(
`contributes.actionSlots[${index}].id must match ${ACTION_ID_PATTERN}, got: "${entry.id}"`,
);
} else if (seen.has(entry.id)) {
errors.push(
`contributes.actionSlots[${index}].id is a duplicate: "${entry.id}"`,
);
} else {
seen.add(entry.id);
}
if (!Array.isArray(entry.accepts) || entry.accepts.length === 0) {
errors.push(
`contributes.actionSlots[${index}].accepts must be a non-empty array`,
);
} else {
for (const kind of entry.accepts) {
if (!ACTION_CONTRIBUTION_KINDS.includes(kind)) {
errors.push(
`contributes.actionSlots[${index}].accepts has unknown value: "${kind}". Known values: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`,
);
}
}
}
if ("descriptionKey" in entry && typeof entry.descriptionKey !== "string") {
errors.push(
`contributes.actionSlots[${index}].descriptionKey must be a string`,
);
}
});
return errors;
}
function main() {
const manifestPath = process.argv[2];
if (!manifestPath) {
async function loadValidator() {
if (!fs.existsSync(sdkEntry)) {
console.error(
"Usage: node scripts/validate-plugin-manifest.cjs <path-to-manifest.json>",
`The plugin SDK is not built yet (${path.relative(root, sdkEntry)} is missing).\n` +
`Run: npm run build:sdk`,
);
process.exit(1);
}
return import(pathToFileURL(sdkEntry).href);
}
const resolvedPath = path.resolve(manifestPath);
if (!fs.existsSync(resolvedPath)) {
console.error(`Manifest not found: ${resolvedPath}`);
process.exit(1);
function bundledManifests() {
const pluginsDir = path.join(root, "plugins");
if (!fs.existsSync(pluginsDir)) return [];
return fs
.readdirSync(pluginsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(pluginsDir, entry.name, "manifest.json"))
.filter((file) => fs.existsSync(file));
}
async function main() {
const { parseManifest } = await loadValidator();
const argument = process.argv[2];
const files = argument
? [path.resolve(process.cwd(), argument)]
: bundledManifests();
if (files.length === 0) {
console.log("No plugin manifests found.");
return;
}
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(resolvedPath, "utf8"));
} catch (err) {
console.error(`Manifest is not valid JSON: ${err.message}`);
process.exit(1);
}
let failed = 0;
const schema = loadSchema();
const errors = validateManifest(manifest, schema);
for (const file of files) {
const relative = path.relative(root, file);
if (errors.length > 0) {
console.error(`Invalid plugin manifest: ${resolvedPath}`);
for (const error of errors) {
console.error(` - ${error}`);
let raw;
try {
raw = JSON.parse(fs.readFileSync(file, "utf8"));
} catch (error) {
console.error(`${relative}: not valid JSON: ${error.message}`);
failed += 1;
continue;
}
process.exit(1);
const { errors } = parseManifest(raw);
if (errors.length > 0) {
console.error(`${relative}:`);
for (const error of errors) console.error(` - ${error}`);
failed += 1;
continue;
}
console.log(`ok ${relative}`);
}
console.log(`Valid plugin manifest: ${resolvedPath}`);
if (failed > 0) {
console.error(`\n${failed} manifest(s) failed validation.`);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = { validateManifest, loadSchema };
main().catch((error) => {
console.error(error);
process.exit(1);
});
+66 -45
View File
@@ -5,13 +5,12 @@ import path from "node:path";
const VALIDATOR = path.join(__dirname, "validate-plugin-manifest.cjs");
const FIXTURES = path.join(__dirname, "__fixtures__", "manifests");
function runValidator(fixture: string): { status: number; output: string } {
function runValidator(fixture?: string): { status: number; output: string } {
const args = fixture
? [VALIDATOR, path.join(FIXTURES, fixture)]
: [VALIDATOR];
try {
const output = execFileSync(
"node",
[VALIDATOR, path.join(FIXTURES, fixture)],
{ encoding: "utf8" },
);
const output = execFileSync("node", args, { encoding: "utf8" });
return { status: 0, output };
} catch (err) {
const error = err as { status: number; stdout: string; stderr: string };
@@ -23,54 +22,76 @@ describe("validate-plugin-manifest.cjs", () => {
it("accepts a valid manifest", () => {
const { status, output } = runValidator("valid.json");
expect(status).toBe(0);
expect(output).toContain("Valid plugin manifest");
expect(output).toContain("ok");
});
it("accepts a manifest declaring services", () => {
expect(runValidator("valid-services.json").status).toBe(0);
});
it("accepts a manifest declaring actions", () => {
expect(runValidator("valid-actions.json").status).toBe(0);
});
it("rejects a manifest missing a required field", () => {
const { status, output } = runValidator("invalid-missing-field.json");
expect(status).not.toBe(0);
expect(output).toContain('Missing required field: "description"');
expect(status).toBe(1);
expect(output).toContain("id");
});
it("rejects a manifest with a bad id pattern", () => {
it("rejects an id that is not a lowercase slug", () => {
const { status, output } = runValidator("invalid-bad-id.json");
expect(status).not.toBe(0);
expect(output).toMatch(/Field "id" must match/);
expect(status).toBe(1);
expect(output).toContain("id");
});
it("accepts a manifest declaring provides and requires", () => {
const { status, output } = runValidator("valid-services.json");
it("rejects an unknown category", () => {
expect(runValidator("invalid-unknown-category.json").status).toBe(1);
});
// The dotted spelling was the v1 form. Capabilities are colon-separated now
// so they can never be confused with RBAC permissions.
it("rejects a dotted capability name", () => {
const { status, output } = runValidator("invalid-unknown-capability.json");
expect(status).toBe(1);
expect(output).toContain("hosts.read");
});
it("rejects a service permission the plugin does not declare", () => {
expect(runValidator("invalid-service-permission.json").status).toBe(1);
});
it("rejects an action permission the plugin does not declare", () => {
expect(runValidator("invalid-action-permission.json").status).toBe(1);
});
it("rejects an unknown top-level field", () => {
const { status, output } = runValidator("invalid-unknown-field.json");
expect(status).toBe(1);
expect(output).toContain("sudoEverything");
});
it("rejects an unknown field nested inside contributes", () => {
const { status, output } = runValidator(
"invalid-nested-unknown-field.json",
);
expect(status).toBe(1);
expect(output).toContain("extra");
});
// A manifest must not be able to hand a role a permission the plugin does
// not own, which is how a plugin could otherwise grant itself admin rights.
it("rejects a role default outside the plugin's own namespace", () => {
const { status, output } = runValidator(
"invalid-role-default-escalation.json",
);
expect(status).toBe(1);
expect(output).toContain("admin.users.manage");
});
it("validates every bundled manifest when given no argument", () => {
const { status, output } = runValidator();
expect(status).toBe(0);
expect(output).toContain("Valid plugin manifest");
});
it("rejects a service gated by an undeclared permission", () => {
const { status, output } = runValidator("invalid-service-permission.json");
expect(status).not.toBe(0);
expect(output).toContain("sampleprovider.undeclared");
});
it("accepts a manifest declaring actions and actionSlots", () => {
const { status, output } = runValidator("valid-actions.json");
expect(status).toBe(0);
expect(output).toContain("Valid plugin manifest");
});
it("rejects an action gated by an undeclared permission", () => {
const { status, output } = runValidator("invalid-action-permission.json");
expect(status).not.toBe(0);
expect(output).toContain("samplebadaction.never_declared");
});
it("rejects a manifest with an unknown permission", () => {
const { status, output } = runValidator("invalid-unknown-permission.json");
expect(status).not.toBe(0);
expect(output).toContain('Unknown permission: "credentials.read"');
});
it("rejects a manifest with an unknown category", () => {
const { status, output } = runValidator("invalid-unknown-category.json");
expect(status).not.toBe(0);
expect(output).toMatch(/Field "category" must be one of/);
expect(output).toContain("ssh-terminal");
});
});