diff --git a/qa/scenarios/plugins/plugin-manifest-metadata-scan.yaml b/qa/scenarios/plugins/plugin-manifest-metadata-scan.yaml new file mode 100644 index 000000000000..75f4aa795555 --- /dev/null +++ b/qa/scenarios/plugins/plugin-manifest-metadata-scan.yaml @@ -0,0 +1,26 @@ +title: Plugin manifest authoring contracts + +scenario: + id: plugin-manifest-metadata-scan + surface: plugins + category: plugins.authoring-and-packaging-plugins + coverage: + primary: + - plugins.plugin-manifest + - plugins.validation-feedback + objective: Verify native plugin manifests expose identity, capabilities, and config schema before runtime loading and reject incomplete author metadata. + successCriteria: + - Metadata scanning preserves plugin identity, channel and provider capabilities, tool ownership, and config schema. + - A manifest without an id fails with the canonical identity error. + - A manifest without configSchema fails with the canonical schema error. + docsRefs: + - docs/plugins/building-plugins.md + - docs/plugins/manifest.md + codeRefs: + - src/plugins/manifest.ts + - src/plugins/manifest-metadata-scan.ts + - src/plugins/manifest-metadata-scan.test.ts + execution: + kind: vitest + path: src/plugins/manifest-metadata-scan.test.ts + summary: Run native manifest metadata discovery and fail-fast authoring validation contracts. diff --git a/qa/scenarios/plugins/plugin-package-metadata-contracts.yaml b/qa/scenarios/plugins/plugin-package-metadata-contracts.yaml new file mode 100644 index 000000000000..dc12cc7fdcb0 --- /dev/null +++ b/qa/scenarios/plugins/plugin-package-metadata-contracts.yaml @@ -0,0 +1,28 @@ +title: Plugin package metadata contracts + +scenario: + id: plugin-package-metadata-contracts + surface: plugins + category: plugins.authoring-and-packaging-plugins + coverage: + primary: + - plugins.package-metadata + - plugins.validation-feedback + objective: Verify package.json exposes plugin discovery and release metadata and rejects malformed or inconsistent entrypoint declarations. + successCriteria: + - The openclaw package block exposes source, runtime, setup, compatibility, and install metadata. + - Malformed openclaw and extensions fields fail with actionable field-specific errors. + - Runtime extension counts must match source extension counts. + - runtimeSetupEntry requires a matching setupEntry. + docsRefs: + - docs/plugins/building-plugins.md + - docs/plugins/manifest.md + - docs/plugins/reference.md + codeRefs: + - src/plugins/package-manifest.ts + - src/plugins/package-entry-resolution.ts + - src/plugins/contracts/package-manifest.contract.test.ts + execution: + kind: vitest + path: src/plugins/contracts/package-manifest.contract.test.ts + summary: Run plugin package metadata discovery and fail-fast entrypoint validation contracts. diff --git a/qa/scenarios/plugins/plugin-public-entrypoint-contracts.yaml b/qa/scenarios/plugins/plugin-public-entrypoint-contracts.yaml new file mode 100644 index 000000000000..5dd3d38629bc --- /dev/null +++ b/qa/scenarios/plugins/plugin-public-entrypoint-contracts.yaml @@ -0,0 +1,28 @@ +title: Plugin public entrypoint contracts + +scenario: + id: plugin-public-entrypoint-contracts + surface: plugins + category: plugins.authoring-and-packaging-plugins + coverage: + primary: + - plugins.entrypoint-discovery + objective: Verify plugin authors can discover public SDK entrypoints and support status from the maintained catalog and documentation. + successCriteria: + - Every public SDK catalog entry has a published package export. + - Private-local SDK entries stay out of published package exports. + - Deprecated entries remain public while their support status is maintained separately. + - Documented SDK subpaths resolve to maintained catalog entries. + docsRefs: + - docs/plugins/sdk-entrypoints.md + - docs/plugins/sdk-subpaths.md + codeRefs: + - scripts/lib/plugin-sdk-entrypoints.json + - scripts/lib/plugin-sdk-private-local-only-subpaths.json + - scripts/lib/plugin-sdk-deprecated-public-subpaths.json + - src/plugin-sdk/entrypoints.ts + - src/plugins/contracts/plugin-sdk-subpaths.test.ts + execution: + kind: vitest + path: src/plugins/contracts/plugin-sdk-subpaths.test.ts + summary: Run the public SDK catalog, package export, and documentation classification contract. diff --git a/src/plugins/contracts/package-manifest.contract.test.ts b/src/plugins/contracts/package-manifest.contract.test.ts index b3d721bfdbe8..81fa89e71651 100644 --- a/src/plugins/contracts/package-manifest.contract.test.ts +++ b/src/plugins/contracts/package-manifest.contract.test.ts @@ -1,6 +1,13 @@ -// Package manifest contract tests cover plugin package manifest requirements. import { describePackageManifestContract } from "openclaw/plugin-sdk/plugin-test-contracts"; +import { describe, expect, it } from "vitest"; +import { validatePackageExtensionEntriesForInstall } from "../package-entry-resolution.js"; +import { + getPackageManifestMetadata, + resolvePackageExtensionEntries, + type PackageManifest, +} from "../package-manifest.js"; +// Package manifest contract tests cover plugin package manifest requirements. type PackageManifestContractParams = Parameters[0]; const packageManifestContractTests: PackageManifestContractParams[] = [ @@ -87,3 +94,97 @@ const packageManifestContractTests: PackageManifestContractParams[] = [ for (const params of packageManifestContractTests) { describePackageManifestContract(params); } + +describe("plugin package authoring metadata", () => { + it("exposes the declared discovery and release entrypoints", () => { + const manifest: PackageManifest = { + name: "@openclaw/example", + version: "1.2.3", + openclaw: { + extensions: ["./src/index.ts"], + runtimeExtensions: ["./dist/index.js"], + setupEntry: "./src/setup.ts", + runtimeSetupEntry: "./dist/setup.js", + plugin: { + id: "example", + label: "Example", + }, + compat: { + pluginApi: ">=1", + minGatewayVersion: "2026.8.1", + }, + install: { + npmSpec: "@openclaw/example", + minHostVersion: "2026.8.1", + }, + }, + }; + + expect(getPackageManifestMetadata(manifest)).toEqual(manifest.openclaw); + expect(resolvePackageExtensionEntries(manifest)).toEqual({ + status: "ok", + entries: ["./src/index.ts"], + }); + }); + + it.each([ + { + name: "non-object openclaw metadata", + manifest: { openclaw: "invalid" } as unknown as PackageManifest, + error: "package.json openclaw must be an object", + }, + { + name: "non-array extension metadata", + manifest: { openclaw: { extensions: "./index.js" } } as unknown as PackageManifest, + error: "package.json openclaw.extensions must be an array", + }, + { + name: "blank extension metadata", + manifest: { openclaw: { extensions: [" "] } } as PackageManifest, + error: "package.json openclaw.extensions[0] must be a non-empty string", + }, + ])("fails fast on $name", ({ manifest, error }) => { + expect(resolvePackageExtensionEntries(manifest)).toEqual({ + status: "invalid", + entries: [], + error, + }); + }); + + it("rejects inconsistent source and runtime extension metadata", async () => { + const result = await validatePackageExtensionEntriesForInstall({ + packageDir: process.cwd(), + extensions: ["./src/one.ts", "./src/two.ts"], + manifest: { + openclaw: { + extensions: ["./src/one.ts", "./src/two.ts"], + runtimeExtensions: ["./dist/one.js"], + }, + }, + }); + + expect(result).toEqual({ + ok: false, + error: + "package.json openclaw.runtimeExtensions length (1) must match openclaw.extensions length (2)", + }); + }); + + it("rejects a runtime setup entry without a source setup entry", async () => { + const result = await validatePackageExtensionEntriesForInstall({ + packageDir: process.cwd(), + extensions: [], + manifest: { + openclaw: { + extensions: [], + runtimeSetupEntry: "./dist/setup.js", + }, + }, + }); + + expect(result).toEqual({ + ok: false, + error: "package.json openclaw.runtimeSetupEntry requires openclaw.setupEntry", + }); + }); +}); diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index 77f88bf6a8a9..5cd97314c691 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -51,7 +51,13 @@ import type { } from "../../plugin-sdk/channel-plugin-common.js"; import * as channelReplyPipelineDirectSdk from "../../plugin-sdk/channel-reply-pipeline.js"; import * as coreDirectSdk from "../../plugin-sdk/core.js"; -import { publicPluginSdkSubpaths as pluginSdkSubpaths } from "../../plugin-sdk/entrypoints.js"; +import { + buildPluginSdkPackageExports, + deprecatedPublicPluginSdkEntrypoints, + pluginSdkEntrypoints, + privateLocalOnlyPluginSdkEntrypoints, + publicPluginSdkSubpaths as pluginSdkSubpaths, +} from "../../plugin-sdk/entrypoints.js"; import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js"; import { listGitTrackedFiles, toRepoRelativePath } from "../../test-utils/repo-files.js"; import type { PluginRuntime } from "../runtime/types.js"; @@ -500,6 +506,32 @@ describe("plugin-sdk subpath exports", () => { }); }); + it("keeps the public entrypoint catalog, package exports, and support-status docs aligned", () => { + const docs = readFileSync(resolve(REPO_ROOT, "docs/plugins/sdk-subpaths.md"), "utf8"); + const packageExports = buildPluginSdkPackageExports(); + const documentedSubpaths = new Set( + [...docs.matchAll(/`plugin-sdk\/([a-z0-9-]+)`/gu)].map((match) => match[1]), + ); + + expect(docs).toContain("scripts/lib/plugin-sdk-entrypoints.json"); + expect(docs).toContain("scripts/lib/plugin-sdk-private-local-only-subpaths.json"); + expect(docs).toContain("scripts/lib/plugin-sdk-deprecated-public-subpaths.json"); + expect(docs).toContain("private-local entries explicitly"); + + for (const subpath of pluginSdkSubpaths) { + expect(packageExports).toHaveProperty(`./plugin-sdk/${subpath}`); + } + for (const subpath of privateLocalOnlyPluginSdkEntrypoints) { + expect(packageExports).not.toHaveProperty(`./plugin-sdk/${subpath}`); + } + for (const subpath of deprecatedPublicPluginSdkEntrypoints) { + expect(pluginSdkSubpaths).toContain(subpath); + } + for (const subpath of documentedSubpaths) { + expect(pluginSdkEntrypoints).toContain(subpath); + } + }); + it("keeps the curated public list free of internal implementation subpaths", () => { for (const deniedSubpath of [ "acpx", diff --git a/src/plugins/manifest-metadata-scan.test.ts b/src/plugins/manifest-metadata-scan.test.ts index 3b7ae2088bce..113911e20ec8 100644 --- a/src/plugins/manifest-metadata-scan.test.ts +++ b/src/plugins/manifest-metadata-scan.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { writePersistedInstalledPluginIndexSync } from "./installed-plugin-index-store.js"; import { listOpenClawPluginManifestMetadata } from "./manifest-metadata-scan.js"; +import { loadPluginManifest } from "./manifest.js"; const tempRoots: string[] = []; @@ -126,6 +127,60 @@ describe("listOpenClawPluginManifestMetadata", () => { }); }); + it("preserves identity, capabilities, and config schema without loading plugin runtime", () => { + const root = createTempRoot(); + const home = path.join(root, "home"); + const pluginDir = path.join(home, ".openclaw", "extensions", "authoring-contract"); + const manifest = { + id: "authoring-contract", + name: "Authoring contract", + channels: ["authoring-channel"], + providers: ["authoring-provider"], + contracts: { + tools: ["authoring_lookup"], + }, + configSchema: { + type: "object", + additionalProperties: false, + properties: { + endpoint: { type: "string" }, + }, + }, + }; + writeJson(path.join(pluginDir, "openclaw.plugin.json"), manifest); + + const records = listOpenClawPluginManifestMetadata({ + OPENCLAW_HOME: home, + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(root, "empty-bundled"), + }); + + expect(records).toContainEqual({ + pluginDir, + manifest, + origin: "global", + }); + }); + + it.each([ + { + name: "missing identity", + manifest: { configSchema: { type: "object" } }, + error: "plugin manifest requires id", + }, + { + name: "missing config schema", + manifest: { id: "missing-schema" }, + error: "plugin manifest requires configSchema", + }, + ])("fails fast on $name", ({ manifest, error }) => { + const pluginDir = createTempRoot(); + writeJson(path.join(pluginDir, "openclaw.plugin.json"), manifest); + + const result = loadPluginManifest(pluginDir, false); + + expect(result).toMatchObject({ ok: false, error }); + }); + it("skips oversized plugin manifests to prevent OOM during metadata scan", () => { const root = createTempRoot(); const home = path.join(root, "home");