Contracts: harden plugin registry loading

This commit is contained in:
Vincent Koc 2026-03-18 01:46:50 -07:00
parent 7ac23ae7c2
commit 4ac9024de9
2 changed files with 120 additions and 136 deletions

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { loadPluginManifestRegistry } from "../manifest-registry.js";
import { resolvePluginWebSearchProviders } from "../web-search-providers.js";
import {
capabilityContractLoadError,
imageGenerationProviderContractRegistry,
mediaUnderstandingProviderContractRegistry,
pluginRegistrationContractRegistry,
@ -85,6 +86,11 @@ function findRegistrationForPlugin(pluginId: string) {
}
describe("plugin contract registry", () => {
it("loads bundled non-provider capability registries without import-time failure", () => {
expect(capabilityContractLoadError).toBeUndefined();
expect(pluginRegistrationContractRegistry.length).toBeGreaterThan(0);
});
it("does not duplicate bundled provider ids", () => {
const ids = providerContractRegistry.map((entry) => entry.provider.id);
expect(ids).toEqual([...new Set(ids)]);

View File

@ -1,17 +1,8 @@
import anthropicPlugin from "../../../extensions/anthropic/index.js";
import bravePlugin from "../../../extensions/brave/index.js";
import elevenLabsPlugin from "../../../extensions/elevenlabs/index.js";
import firecrawlPlugin from "../../../extensions/firecrawl/index.js";
import googlePlugin from "../../../extensions/google/index.js";
import microsoftPlugin from "../../../extensions/microsoft/index.js";
import minimaxPlugin from "../../../extensions/minimax/index.js";
import mistralPlugin from "../../../extensions/mistral/index.js";
import moonshotPlugin from "../../../extensions/moonshot/index.js";
import openAIPlugin from "../../../extensions/openai/index.js";
import perplexityPlugin from "../../../extensions/perplexity/index.js";
import xaiPlugin from "../../../extensions/xai/index.js";
import zaiPlugin from "../../../extensions/zai/index.js";
import { createCapturedPluginRegistration } from "../captured-registration.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { withBundledPluginEnablementCompat } from "../bundled-compat.js";
import { resolveBundledWebSearchPluginIds } from "../bundled-web-search.js";
import { loadOpenClawPlugins } from "../loader.js";
import { createPluginLoaderLogger } from "../logger.js";
import { resolvePluginProviders } from "../providers.js";
import type {
ImageGenerationProviderPlugin,
@ -21,11 +12,6 @@ import type {
WebSearchProviderPlugin,
} from "../types.js";
type RegistrablePlugin = {
id: string;
register: (api: ReturnType<typeof createCapturedPluginRegistration>["api"]) => void;
};
type CapabilityContractEntry<T> = {
pluginId: string;
provider: T;
@ -52,52 +38,30 @@ type PluginRegistrationContractEntry = {
toolNames: string[];
};
const bundledWebSearchPlugins: Array<RegistrablePlugin & { credentialValue: unknown }> = [
{ ...bravePlugin, credentialValue: "BSA-test" },
{ ...firecrawlPlugin, credentialValue: "fc-test" },
{ ...googlePlugin, credentialValue: "AIza-test" },
{ ...moonshotPlugin, credentialValue: "sk-test" },
{ ...perplexityPlugin, credentialValue: "pplx-test" },
{ ...xaiPlugin, credentialValue: "xai-test" },
];
const log = createSubsystemLogger("plugins");
const bundledSpeechPlugins: RegistrablePlugin[] = [elevenLabsPlugin, microsoftPlugin, openAIPlugin];
const BUNDLED_WEB_SEARCH_CREDENTIAL_VALUES: Readonly<Record<string, unknown>> = {
brave: "BSA-test",
firecrawl: "fc-test",
google: "AIza-test",
moonshot: "sk-test",
perplexity: "pplx-test",
xai: "xai-test",
};
const bundledMediaUnderstandingPlugins: RegistrablePlugin[] = [
anthropicPlugin,
googlePlugin,
minimaxPlugin,
mistralPlugin,
moonshotPlugin,
openAIPlugin,
zaiPlugin,
];
const BUNDLED_SPEECH_PLUGIN_IDS = ["elevenlabs", "microsoft", "openai"] as const;
const BUNDLED_MEDIA_UNDERSTANDING_PLUGIN_IDS = [
"anthropic",
"google",
"minimax",
"mistral",
"moonshot",
"openai",
"zai",
] as const;
const BUNDLED_IMAGE_GENERATION_PLUGIN_IDS = ["google", "openai"] as const;
const bundledImageGenerationPlugins: RegistrablePlugin[] = [googlePlugin, openAIPlugin];
function captureRegistrations(plugin: RegistrablePlugin) {
const captured = createCapturedPluginRegistration();
plugin.register(captured.api);
return captured;
}
function buildCapabilityContractRegistry<T>(params: {
plugins: RegistrablePlugin[];
select: (captured: ReturnType<typeof createCapturedPluginRegistration>) => T[];
}): CapabilityContractEntry<T>[] {
return params.plugins.flatMap((plugin) => {
const captured = captureRegistrations(plugin);
return params.select(captured).map((provider) => ({
pluginId: plugin.id,
provider,
}));
});
}
export const providerContractRegistry: ProviderContractEntry[] = buildCapabilityContractRegistry({
plugins: [],
select: () => [],
});
export const providerContractRegistry: ProviderContractEntry[] = [];
export let providerContractLoadError: Error | undefined;
@ -143,6 +107,55 @@ export const providerContractCompatPluginIds = providerContractPluginIds.map((pl
pluginId === "kimi-coding" ? "kimi" : pluginId,
);
const bundledCapabilityContractPluginIds = [
...new Set([
...providerContractCompatPluginIds,
...resolveBundledWebSearchPluginIds({}),
...BUNDLED_SPEECH_PLUGIN_IDS,
...BUNDLED_MEDIA_UNDERSTANDING_PLUGIN_IDS,
...BUNDLED_IMAGE_GENERATION_PLUGIN_IDS,
]),
].toSorted((left, right) => left.localeCompare(right));
export let capabilityContractLoadError: Error | undefined;
function loadBundledCapabilityRegistry() {
try {
capabilityContractLoadError = undefined;
return loadOpenClawPlugins({
config: withBundledPluginEnablementCompat({
config: {
plugins: {
enabled: true,
allow: bundledCapabilityContractPluginIds,
slots: {
memory: "none",
},
},
},
pluginIds: bundledCapabilityContractPluginIds,
}),
cache: false,
activate: false,
logger: createPluginLoaderLogger(log),
});
} catch (error) {
capabilityContractLoadError = error instanceof Error ? error : new Error(String(error));
return loadOpenClawPlugins({
config: {
plugins: {
enabled: false,
},
},
cache: false,
activate: false,
logger: createPluginLoaderLogger(log),
});
}
}
const loadedBundledCapabilityRegistry = loadBundledCapabilityRegistry();
export function requireProviderContractProvider(providerId: string): ProviderPlugin {
const provider = uniqueProviderContractProviders.find((entry) => entry.id === providerId);
if (!provider) {
@ -183,85 +196,50 @@ export function resolveProviderContractProvidersForPluginIds(
}
export const webSearchProviderContractRegistry: WebSearchProviderContractEntry[] =
bundledWebSearchPlugins.flatMap((plugin) => {
const captured = captureRegistrations(plugin);
return captured.webSearchProviders.map((provider) => ({
pluginId: plugin.id,
provider,
credentialValue: plugin.credentialValue,
loadedBundledCapabilityRegistry.webSearchProviders
.filter((entry) => entry.pluginId in BUNDLED_WEB_SEARCH_CREDENTIAL_VALUES)
.map((entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
credentialValue: BUNDLED_WEB_SEARCH_CREDENTIAL_VALUES[entry.pluginId],
}));
});
export const speechProviderContractRegistry: SpeechProviderContractEntry[] =
buildCapabilityContractRegistry({
plugins: bundledSpeechPlugins,
select: (captured) => captured.speechProviders,
});
loadedBundledCapabilityRegistry.speechProviders.map((entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
}));
export const mediaUnderstandingProviderContractRegistry: MediaUnderstandingProviderContractEntry[] =
buildCapabilityContractRegistry({
plugins: bundledMediaUnderstandingPlugins,
select: (captured) => captured.mediaUnderstandingProviders,
});
loadedBundledCapabilityRegistry.mediaUnderstandingProviders.map((entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
}));
export const imageGenerationProviderContractRegistry: ImageGenerationProviderContractEntry[] =
buildCapabilityContractRegistry({
plugins: bundledImageGenerationPlugins,
select: (captured) => captured.imageGenerationProviders,
});
loadedBundledCapabilityRegistry.imageGenerationProviders.map((entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
}));
const bundledPluginRegistrationList = [
...new Map(
[
...bundledSpeechPlugins,
...bundledMediaUnderstandingPlugins,
...bundledImageGenerationPlugins,
...bundledWebSearchPlugins,
].map((plugin) => [plugin.id, plugin]),
).values(),
];
export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry[] = [
...new Map(
providerContractRegistry.map((entry) => [
entry.pluginId,
{
pluginId: entry.pluginId,
providerIds: providerContractRegistry
.filter((candidate) => candidate.pluginId === entry.pluginId)
.map((candidate) => candidate.provider.id),
speechProviderIds: [] as string[],
mediaUnderstandingProviderIds: [] as string[],
imageGenerationProviderIds: [] as string[],
webSearchProviderIds: [] as string[],
toolNames: [] as string[],
},
]),
).values(),
];
for (const plugin of bundledPluginRegistrationList) {
const captured = captureRegistrations(plugin);
const existing = pluginRegistrationContractRegistry.find((entry) => entry.pluginId === plugin.id);
const next = {
pluginId: plugin.id,
providerIds: captured.providers.map((provider) => provider.id),
speechProviderIds: captured.speechProviders.map((provider) => provider.id),
mediaUnderstandingProviderIds: captured.mediaUnderstandingProviders.map(
(provider) => provider.id,
),
imageGenerationProviderIds: captured.imageGenerationProviders.map((provider) => provider.id),
webSearchProviderIds: captured.webSearchProviders.map((provider) => provider.id),
toolNames: captured.tools.map((tool) => tool.name),
};
if (!existing) {
pluginRegistrationContractRegistry.push(next);
continue;
}
existing.providerIds = next.providerIds.length > 0 ? next.providerIds : existing.providerIds;
existing.speechProviderIds = next.speechProviderIds;
existing.mediaUnderstandingProviderIds = next.mediaUnderstandingProviderIds;
existing.imageGenerationProviderIds = next.imageGenerationProviderIds;
existing.webSearchProviderIds = next.webSearchProviderIds;
existing.toolNames = next.toolNames;
}
export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry[] =
loadedBundledCapabilityRegistry.plugins
.filter(
(plugin) =>
plugin.origin === "bundled" &&
(plugin.providerIds.length > 0 ||
plugin.speechProviderIds.length > 0 ||
plugin.mediaUnderstandingProviderIds.length > 0 ||
plugin.imageGenerationProviderIds.length > 0 ||
plugin.webSearchProviderIds.length > 0 ||
plugin.toolNames.length > 0),
)
.map((plugin) => ({
pluginId: plugin.id,
providerIds: plugin.providerIds,
speechProviderIds: plugin.speechProviderIds,
mediaUnderstandingProviderIds: plugin.mediaUnderstandingProviderIds,
imageGenerationProviderIds: plugin.imageGenerationProviderIds,
webSearchProviderIds: plugin.webSearchProviderIds,
toolNames: plugin.toolNames,
}));