fix(plugins): enforce manifest kinds for exclusive slots (#114537)

Normalize plugin manifests to the documented memory and context-engine kinds. Filter unsupported entries, preserve valid dual-kind declaration order, and collapse duplicates before runtime slot selection.

Closes #114321

Co-authored-by: kevin2966n <147227108+kevin2966n@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-07-27 07:42:46 -04:00
committed by GitHub
parent 5e4b0d3bea
commit 3799e70e97
2 changed files with 107 additions and 6 deletions
@@ -3,6 +3,7 @@ import fs from "node:fs";
import path from "node:path";
import JSON5 from "json5";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveMemorySlotDecision } from "./config-state.js";
import { loadPluginManifest } from "./manifest.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
@@ -122,6 +123,100 @@ describe("loadPluginManifest JSON5 tolerance", () => {
}
});
it.each([
{
name: "a supported memory kind",
rawKind: "memory",
expectedKind: "memory",
},
{
name: "a supported context-engine kind",
rawKind: "context-engine",
expectedKind: "context-engine",
},
{
name: "both supported kinds in declaration order",
rawKind: ["context-engine", "memory"],
expectedKind: ["context-engine", "memory"],
},
{
name: "duplicate memory kinds collapsed into one kind",
rawKind: ["memory", "memory"],
expectedKind: "memory",
},
{
name: "duplicate context-engine kinds collapsed into one kind",
rawKind: ["context-engine", "context-engine"],
expectedKind: "context-engine",
},
{
name: "supported kinds filtered from invalid and duplicate array entries",
rawKind: ["memory", "unknown-kind", 42, "memory", "context-engine", null],
expectedKind: ["memory", "context-engine"],
},
{
name: "a valid memory kind retained alongside invalid entries",
rawKind: ["unknown-kind", 42, "memory", null],
expectedKind: "memory",
},
{
name: "an unsupported scalar kind",
rawKind: "unknown-kind",
expectedKind: undefined,
},
{
name: "an array containing only unsupported kinds",
rawKind: ["unknown-kind", 42, null],
expectedKind: undefined,
},
])("normalizes $name", ({ rawKind, expectedKind }) => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "openclaw.plugin.json"),
JSON.stringify({
id: "kind-normalization",
kind: rawKind,
configSchema: { type: "object" },
}),
"utf-8",
);
const result = loadPluginManifest(dir, false);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.manifest.kind).toEqual(expectedKind);
}
});
it("keeps duplicate memory declarations subject to the exclusive memory slot", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "openclaw.plugin.json"),
JSON.stringify({
id: "duplicate-memory",
kind: ["memory", "memory"],
configSchema: { type: "object" },
}),
"utf-8",
);
const result = loadPluginManifest(dir, false);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.manifest.kind).toBe("memory");
expect(
resolveMemorySlotDecision({
id: result.manifest.id,
kind: result.manifest.kind,
slot: "memory-core",
selectedId: "memory-core",
}),
).toEqual({ enabled: false, reason: 'memory slot set to "memory-core"' });
}
});
it("normalizes modelSupport metadata from the manifest", () => {
const dir = makeTempDir();
const json5Content = `{
+12 -6
View File
@@ -29,6 +29,7 @@ const PLUGIN_MANIFEST_FILENAMES = [PLUGIN_MANIFEST_FILENAME] as const;
const MAX_PLUGIN_MANIFEST_BYTES = 256 * 1024;
const MAX_PLUGIN_MANIFEST_LOAD_CACHE_ENTRIES = 512;
const CORE_RESERVED_PLUGIN_IDS = new Set(["node-mcp"]);
const VALID_PLUGIN_KINDS: ReadonlySet<string> = new Set<PluginKind>(["memory", "context-engine"]);
export function isCoreReservedPluginId(id: string): boolean {
return CORE_RESERVED_PLUGIN_IDS.has(normalizePluginPolicyId(id));
@@ -109,13 +110,18 @@ function setCachedPluginManifestLoadResult(
}
function parsePluginKind(raw: unknown): PluginKind | PluginKind[] | undefined {
if (typeof raw === "string") {
return raw as PluginKind;
const values = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : [];
const kinds: PluginKind[] = [];
for (const value of values) {
if (typeof value !== "string" || !VALID_PLUGIN_KINDS.has(value)) {
continue;
}
const kind = value as PluginKind;
if (!kinds.includes(kind)) {
kinds.push(kind);
}
}
if (Array.isArray(raw) && raw.length > 0 && raw.every((k) => typeof k === "string")) {
return raw.length === 1 ? (raw[0] as PluginKind) : (raw as PluginKind[]);
}
return undefined;
return kinds.length === 0 ? undefined : kinds.length === 1 ? kinds[0] : kinds;
}
export function loadPluginManifest(