fix: preserve external capability providers

This commit is contained in:
Shakker
2026-05-01 20:08:07 +01:00
parent fac06a2320
commit dfde770a3a
7 changed files with 451 additions and 104 deletions

View File

@@ -28,12 +28,13 @@ function createAuthStore(providers: string[] = []): AuthProfileStore {
function createPlugin(params: {
id: string;
origin?: PluginManifestRecord["origin"];
contracts: NonNullable<PluginManifestRecord["contracts"]>;
setupProviders?: Array<{ id: string; envVars?: string[] }>;
}): PluginManifestRecord {
return {
id: params.id,
origin: "bundled",
origin: params.origin ?? "bundled",
rootDir: `/plugins/${params.id}`,
source: `/plugins/${params.id}/index.js`,
manifestPath: `/plugins/${params.id}/openclaw.plugin.json`,
@@ -47,10 +48,22 @@ function createPlugin(params: {
};
}
function installSnapshot(config: OpenClawConfig, plugins: PluginManifestRecord[]) {
function installSnapshot(
config: OpenClawConfig,
plugins: PluginManifestRecord[],
enabledPluginIds = plugins
.filter((plugin) => plugin.origin !== "bundled")
.map((plugin) => plugin.id),
) {
const snapshot = {
policyHash: resolveInstalledPluginIndexPolicyHash(config),
index: { plugins: [] },
index: {
plugins: plugins.map((plugin) => ({
pluginId: plugin.id,
origin: plugin.origin,
enabled: plugin.origin === "bundled" || enabledPluginIds.includes(plugin.id),
})),
},
registryDiagnostics: [],
manifestRegistry: { plugins, diagnostics: [] },
plugins,
@@ -217,6 +230,81 @@ describe("optional media tool factory planning", () => {
});
});
it("keeps enabled external manifest capability providers on the factory path", () => {
const config: OpenClawConfig = {};
installSnapshot(config, [
createPlugin({
id: "external-image",
origin: "global",
contracts: { imageGenerationProviders: ["external-image"] },
setupProviders: [{ id: "external-image", envVars: ["EXTERNAL_IMAGE_API_KEY"] }],
}),
createPlugin({
id: "external-video",
origin: "global",
contracts: { videoGenerationProviders: ["external-video"] },
setupProviders: [{ id: "external-video", envVars: ["EXTERNAL_VIDEO_API_KEY"] }],
}),
createPlugin({
id: "external-music",
origin: "global",
contracts: { musicGenerationProviders: ["external-music"] },
setupProviders: [{ id: "external-music", envVars: ["EXTERNAL_MUSIC_API_KEY"] }],
}),
createPlugin({
id: "external-media",
origin: "global",
contracts: { mediaUnderstandingProviders: ["external-media"] },
setupProviders: [{ id: "external-media", envVars: ["EXTERNAL_MEDIA_API_KEY"] }],
}),
]);
expect(
__testing.resolveOptionalMediaToolFactoryPlan({
config,
authStore: createAuthStore([
"external-image",
"external-video",
"external-music",
"external-media",
]),
}),
).toEqual({
imageGenerate: true,
videoGenerate: true,
musicGenerate: true,
pdf: true,
});
});
it("ignores external manifest capability providers excluded by plugin policy", () => {
const config: OpenClawConfig = {
plugins: {
allow: ["other-plugin"],
},
};
installSnapshot(config, [
createPlugin({
id: "external-image",
origin: "global",
contracts: { imageGenerationProviders: ["external-image"] },
setupProviders: [{ id: "external-image", envVars: ["EXTERNAL_IMAGE_API_KEY"] }],
}),
]);
expect(
__testing.resolveOptionalMediaToolFactoryPlan({
config,
authStore: createAuthStore(["external-image"]),
}),
).toEqual({
imageGenerate: false,
videoGenerate: false,
musicGenerate: false,
pdf: false,
});
});
it("falls back to existing factory checks when snapshot or auth store proof is missing", () => {
expect(__testing.resolveOptionalMediaToolFactoryPlan({ config: {} })).toEqual({
imageGenerate: true,

View File

@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { callGateway } from "../gateway/call.js";
import { isEmbeddedMode } from "../infra/embedded-mode.js";
import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
import { isManifestPluginAvailableForControlPlane } from "../plugins/manifest-contract-eligibility.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime.js";
@@ -109,9 +110,16 @@ function hasAuthSignalForSnapshotCapability(params: {
snapshot: PluginMetadataSnapshot;
authStore: AuthProfileStore;
key: CapabilityContractKey;
config?: OpenClawConfig;
}): boolean {
for (const plugin of params.snapshot.plugins) {
if (plugin.origin !== "bundled") {
if (
!isManifestPluginAvailableForControlPlane({
snapshot: params.snapshot,
plugin,
config: params.config,
})
) {
continue;
}
for (const providerId of plugin.contracts?.[params.key] ?? []) {
@@ -147,7 +155,13 @@ function hasConfiguredVisionModelAuthSignal(params: {
return true;
}
for (const plugin of params.snapshot.plugins) {
if (plugin.origin !== "bundled") {
if (
!isManifestPluginAvailableForControlPlane({
snapshot: params.snapshot,
plugin,
config: params.config,
})
) {
continue;
}
if (hasNonEmptyEnvCandidate(pluginSetupProviderEnvVars(plugin, providerId))) {
@@ -208,6 +222,7 @@ function resolveOptionalMediaToolFactoryPlan(params: {
snapshot,
authStore: params.authStore,
key: "imageGenerationProviders",
config: params.config,
})),
videoGenerate:
allowVideoGenerate &&
@@ -216,6 +231,7 @@ function resolveOptionalMediaToolFactoryPlan(params: {
snapshot,
authStore: params.authStore,
key: "videoGenerationProviders",
config: params.config,
})),
musicGenerate:
allowMusicGenerate &&
@@ -224,6 +240,7 @@ function resolveOptionalMediaToolFactoryPlan(params: {
snapshot,
authStore: params.authStore,
key: "musicGenerationProviders",
config: params.config,
})),
pdf:
allowPdf &&
@@ -232,6 +249,7 @@ function resolveOptionalMediaToolFactoryPlan(params: {
snapshot,
authStore: params.authStore,
key: "mediaUnderstandingProviders",
config: params.config,
}) ||
hasConfiguredVisionModelAuthSignal({
config: params.config,

View File

@@ -4,7 +4,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { SsrFPolicy } from "../../infra/net/ssrf.js";
import { getDefaultLocalRoots } from "../../media/web-media.js";
import { readSnakeCaseParamRaw } from "../../param-key.js";
import { resolveBundledCapabilityProviderIds } from "../../plugins/capability-provider-runtime.js";
import { resolveManifestCapabilityProviderIds } from "../../plugins/capability-provider-runtime.js";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
@@ -317,7 +317,7 @@ export function hasGenerationToolAvailability(params: {
}),
);
}
return resolveBundledCapabilityProviderIds({
return resolveManifestCapabilityProviderIds({
key: params.providerKey,
cfg: params.cfg,
workspaceDir: params.workspaceDir,

View File

@@ -85,6 +85,7 @@ vi.mock("./bundled-compat.js", () => ({
let resolvePluginCapabilityProviders: typeof import("./capability-provider-runtime.js").resolvePluginCapabilityProviders;
let resolvePluginCapabilityProvider: typeof import("./capability-provider-runtime.js").resolvePluginCapabilityProvider;
let resolveBundledCapabilityProviderIds: typeof import("./capability-provider-runtime.js").resolveBundledCapabilityProviderIds;
let resolveManifestCapabilityProviderIds: typeof import("./capability-provider-runtime.js").resolveManifestCapabilityProviderIds;
let clearCurrentPluginMetadataSnapshot: typeof import("./current-plugin-metadata-snapshot.js").clearCurrentPluginMetadataSnapshot;
let setCurrentPluginMetadataSnapshot: typeof import("./current-plugin-metadata-snapshot.js").setCurrentPluginMetadataSnapshot;
@@ -106,10 +107,14 @@ function expectBundledCompatLoadPath(params: {
};
};
}) {
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledWith({
config: params.cfg,
env: process.env,
});
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledWith(
expect.objectContaining({
config: params.cfg,
env: process.env,
includeDisabled: true,
index: expect.any(Object),
}),
);
expect(mocks.withBundledPluginEnablementCompat).toHaveBeenCalledWith({
config: params.allowlistCompat,
pluginIds: ["openai"],
@@ -197,6 +202,7 @@ describe("resolvePluginCapabilityProviders", () => {
beforeAll(async () => {
({
resolveBundledCapabilityProviderIds,
resolveManifestCapabilityProviderIds,
resolvePluginCapabilityProvider,
resolvePluginCapabilityProviders,
} = await import("./capability-provider-runtime.js"));
@@ -281,6 +287,62 @@ describe("resolvePluginCapabilityProviders", () => {
expect(mocks.loadPluginManifestRegistry).not.toHaveBeenCalled();
});
it("resolves enabled external capability ids from the current metadata snapshot", () => {
setCurrentPluginMetadataSnapshot({
policyHash: "policy",
workspaceDir: "/workspace",
index: {
plugins: [
{ pluginId: "external-image", origin: "global", enabled: true },
{ pluginId: "external-disabled", origin: "global", enabled: false },
],
},
registryDiagnostics: [],
manifestRegistry: { plugins: [], diagnostics: [] },
plugins: [
{
id: "external-image",
origin: "global",
contracts: { imageGenerationProviders: ["external-image"] },
},
{
id: "external-disabled",
origin: "global",
contracts: { imageGenerationProviders: ["external-disabled"] },
},
],
diagnostics: [],
byPluginId: new Map(),
normalizePluginId: (id: string) => id,
owners: {
channels: new Map(),
channelConfigs: new Map(),
providers: new Map(),
modelCatalogProviders: new Map(),
cliBackends: new Map(),
setupProviders: new Map(),
commandAliases: new Map(),
contracts: new Map(),
},
metrics: {
registrySnapshotMs: 0,
manifestRegistryMs: 0,
ownerMapsMs: 0,
totalMs: 0,
indexPluginCount: 2,
manifestPluginCount: 2,
},
} as never);
expect(
resolveManifestCapabilityProviderIds({
key: "imageGenerationProviders",
workspaceDir: "/workspace",
}),
).toEqual(["external-image"]);
expect(mocks.loadPluginManifestRegistry).not.toHaveBeenCalled();
});
it("uses the active registry when capability providers are already loaded", () => {
const active = createEmptyPluginRegistry();
active.speechProviders.push({
@@ -308,6 +370,51 @@ describe("resolvePluginCapabilityProviders", () => {
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith();
});
it("targets enabled external capability plugins without bundled fallback capture", () => {
const loaded = createEmptyPluginRegistry();
loaded.imageGenerationProviders.push({
pluginId: "external-image",
pluginName: "external-image",
source: "test",
provider: {
id: "external-image",
label: "External Image",
isConfigured: () => true,
generate: async () => ({
kind: "image",
images: [],
}),
},
} as never);
mocks.loadPluginRegistrySnapshot.mockReturnValue({
plugins: [{ pluginId: "external-image", origin: "global", enabled: true }],
});
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "external-image",
origin: "global",
contracts: { imageGenerationProviders: ["external-image"] },
},
],
diagnostics: [],
});
mocks.resolveRuntimePluginRegistry.mockImplementation((options?: unknown) =>
options ? loaded : undefined,
);
expectResolvedCapabilityProviderIds(
resolvePluginCapabilityProviders({ key: "imageGenerationProviders" }),
["external-image"],
);
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenLastCalledWith({
config: expect.any(Object),
onlyPluginIds: ["external-image"],
activate: false,
});
expect(mocks.loadBundledCapabilityRuntimeRegistry).not.toHaveBeenCalled();
});
it("uses active non-speech capability providers even when cfg has explicit plugin entries", () => {
const active = createEmptyPluginRegistry();
active.mediaUnderstandingProviders.push({
@@ -964,10 +1071,14 @@ describe("resolvePluginCapabilityProviders", () => {
const providers = resolvePluginCapabilityProviders({ key: "mediaUnderstandingProviders" });
expectResolvedCapabilityProviderIds(providers, ["google"]);
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledWith({
config: undefined,
env: process.env,
});
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledWith(
expect.objectContaining({
config: undefined,
env: process.env,
includeDisabled: true,
index: expect.any(Object),
}),
);
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: compatConfig,
onlyPluginIds: ["google"],
@@ -1088,10 +1199,14 @@ describe("resolvePluginCapabilityProviders", () => {
});
expectResolvedCapabilityProviderIds(providers, ["microsoft"]);
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledWith({
config: cfg,
env: process.env,
});
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledWith(
expect.objectContaining({
config: cfg,
env: process.env,
includeDisabled: true,
index: expect.any(Object),
}),
);
expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledWith({
config: cfg,
pluginIds: ["microsoft"],
@@ -1119,10 +1234,14 @@ describe("resolvePluginCapabilityProviders", () => {
});
expectNoResolvedCapabilityProviders(providers as Array<{ id: string }>);
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledWith({
config: {},
env: process.env,
});
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledWith(
expect.objectContaining({
config: {},
env: process.env,
includeDisabled: true,
index: expect.any(Object),
}),
);
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith();
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: expect.anything(),

View File

@@ -5,17 +5,24 @@ import {
withBundledPluginEnablementCompat,
withBundledPluginVitestCompat,
} from "./bundled-compat.js";
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
import {
resolvePluginRegistryLoadCacheKey,
resolveRuntimePluginRegistry,
type PluginLoadOptions,
} from "./loader.js";
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
import {
resolveConfigScopedRuntimeCacheValue,
type ConfigScopedRuntimeCache,
} from "./plugin-cache-primitives.js";
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js";
import {
hasManifestContractValue,
isManifestPluginAvailableForControlPlane,
listAvailableManifestContractValues,
} from "./manifest-contract-eligibility.js";
import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js";
import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js";
import { loadPluginRegistrySnapshot } from "./plugin-registry.js";
import type { PluginRegistry } from "./registry-types.js";
type CapabilityProviderRegistryKey =
@@ -41,6 +48,10 @@ type CapabilityContractKey =
type CapabilityProviderForKey<K extends CapabilityProviderRegistryKey> =
PluginRegistry[K][number] extends { provider: infer T } ? T : never;
type CapabilityProviderEntries = PluginRegistry[CapabilityProviderRegistryKey];
type CapabilityPluginResolution = {
runtimePluginIds: string[];
bundledCompatPluginIds: string[];
};
const capabilityProviderSnapshotCache: ConfigScopedRuntimeCache<CapabilityProviderEntries> =
new WeakMap();
@@ -70,35 +81,92 @@ function shouldSkipCapabilityResolution(params: {
);
}
function uniqueSorted(values: Iterable<string>): string[] {
return [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
}
function loadCapabilityManifestSnapshot(params: {
cfg?: OpenClawConfig;
workspaceDir?: string;
}): Pick<PluginMetadataSnapshot, "index" | "plugins"> {
const current = getCurrentPluginMetadataSnapshot({
config: params.cfg,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
if (current) {
return current;
}
const env = process.env;
const index = loadPluginRegistrySnapshot({
config: params.cfg,
env,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
return {
index,
plugins: loadPluginManifestRegistryForInstalledIndex({
index,
config: params.cfg,
env,
includeDisabled: true,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
}).plugins,
};
}
function resolveCapabilityPluginIds(params: {
key: CapabilityProviderRegistryKey;
cfg?: OpenClawConfig;
workspaceDir?: string;
providerId?: string;
}): CapabilityPluginResolution {
const contractKey = CAPABILITY_CONTRACT_KEY[params.key];
const snapshot = loadCapabilityManifestSnapshot(params);
const contractPlugins = snapshot.plugins.filter((plugin) =>
hasManifestContractValue({
plugin,
contract: contractKey,
value: params.providerId,
}),
);
return {
runtimePluginIds: uniqueSorted(
contractPlugins
.filter((plugin) =>
isManifestPluginAvailableForControlPlane({
snapshot,
plugin,
config: params.cfg,
}),
)
.map((plugin) => plugin.id),
),
bundledCompatPluginIds: uniqueSorted(
contractPlugins.filter((plugin) => plugin.origin === "bundled").map((plugin) => plugin.id),
),
};
}
function resolveBundledCapabilityCompatPluginIds(params: {
key: CapabilityProviderRegistryKey;
cfg?: OpenClawConfig;
workspaceDir?: string;
providerId?: string;
}): string[] {
const env = process.env;
return resolveCapabilityPluginIds(params).bundledCompatPluginIds;
}
export function resolveManifestCapabilityProviderIds(params: {
key: CapabilityProviderRegistryKey;
cfg?: OpenClawConfig;
workspaceDir?: string;
}): string[] {
const contractKey = CAPABILITY_CONTRACT_KEY[params.key];
const snapshot = getCurrentPluginMetadataSnapshot({
return listAvailableManifestContractValues({
snapshot: loadCapabilityManifestSnapshot(params),
contract: contractKey,
config: params.cfg,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
const plugins =
snapshot?.plugins ??
loadPluginManifestRegistryForPluginRegistry({
config: params.cfg,
env,
includeDisabled: true,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
}).plugins;
return plugins
.filter(
(plugin) =>
plugin.origin === "bundled" &&
(plugin.contracts?.[contractKey]?.length ?? 0) > 0 &&
(!params.providerId || (plugin.contracts?.[contractKey] ?? []).includes(params.providerId)),
)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
export function resolveBundledCapabilityProviderIds(params: {
@@ -106,27 +174,13 @@ export function resolveBundledCapabilityProviderIds(params: {
cfg?: OpenClawConfig;
workspaceDir?: string;
}): string[] {
const env = process.env;
const contractKey = CAPABILITY_CONTRACT_KEY[params.key];
const snapshot = getCurrentPluginMetadataSnapshot({
config: params.cfg,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
const plugins =
snapshot?.plugins ??
loadPluginManifestRegistryForPluginRegistry({
config: params.cfg,
env,
includeDisabled: true,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
}).plugins;
return [
...new Set(
plugins.flatMap((plugin) =>
plugin.origin === "bundled" ? (plugin.contracts?.[contractKey] ?? []) : [],
),
const snapshot = loadCapabilityManifestSnapshot(params);
return uniqueSorted(
snapshot.plugins.flatMap((plugin) =>
plugin.origin === "bundled" ? (plugin.contracts?.[contractKey] ?? []) : [],
),
].toSorted((left, right) => left.localeCompare(right));
);
}
function resolveCapabilityProviderConfig(params: {
@@ -347,32 +401,40 @@ function filterLoadedProvidersForRequestedConfig<K extends CapabilityProviderReg
}) as PluginRegistry[K];
}
function resolveRequestedCapabilityCompatPluginIds(params: {
function resolveRequestedCapabilityPluginIds(params: {
key: CapabilityProviderRegistryKey;
cfg?: OpenClawConfig;
requested?: Set<string>;
}): string[] | undefined {
}): CapabilityPluginResolution | undefined {
if (params.key !== "speechProviders" || !params.requested || params.requested.size === 0) {
return undefined;
}
const pluginIds = new Set<string>();
const runtimePluginIds = new Set<string>();
const bundledCompatPluginIds = new Set<string>();
for (const providerId of params.requested) {
for (const pluginId of resolveBundledCapabilityCompatPluginIds({
const resolution = resolveCapabilityPluginIds({
key: params.key,
cfg: params.cfg,
providerId,
})) {
pluginIds.add(pluginId);
});
for (const pluginId of resolution.runtimePluginIds) {
runtimePluginIds.add(pluginId);
}
for (const pluginId of resolution.bundledCompatPluginIds) {
bundledCompatPluginIds.add(pluginId);
}
}
return pluginIds.size > 0
? [...pluginIds].toSorted((left, right) => left.localeCompare(right))
return runtimePluginIds.size > 0
? {
runtimePluginIds: uniqueSorted(runtimePluginIds),
bundledCompatPluginIds: uniqueSorted(bundledCompatPluginIds),
}
: undefined;
}
function loadCapabilityProviderEntries<K extends CapabilityProviderRegistryKey>(params: {
key: K;
pluginIds: string[];
bundledCompatPluginIds: string[];
loadOptions: PluginLoadOptions;
requested?: Set<string>;
}): PluginRegistry[K] {
@@ -388,11 +450,11 @@ function loadCapabilityProviderEntries<K extends CapabilityProviderRegistryKey>(
if (entries.length > 0 && (!missingRequested || missingRequested.size === 0)) {
return entries;
}
if (params.pluginIds.length === 0) {
if (params.bundledCompatPluginIds.length === 0) {
return entries;
}
const captured = loadBundledCapabilityRuntimeRegistry({
pluginIds: params.pluginIds,
pluginIds: params.bundledCompatPluginIds,
env: process.env,
pluginSdkResolution: params.loadOptions.pluginSdkResolution,
})[params.key] as PluginRegistry[K];
@@ -414,23 +476,23 @@ export function resolvePluginCapabilityProvider<K extends CapabilityProviderRegi
return activeProvider;
}
const pluginIds = resolveBundledCapabilityCompatPluginIds({
const pluginIds = resolveCapabilityPluginIds({
key: params.key,
cfg: params.cfg,
providerId: params.providerId,
});
if (pluginIds.length === 0) {
if (pluginIds.runtimePluginIds.length === 0) {
return undefined;
}
const compatConfig = resolveCapabilityProviderConfig({
key: params.key,
cfg: params.cfg,
pluginIds,
pluginIds: pluginIds.bundledCompatPluginIds,
});
const loadOptions = createCapabilityProviderFallbackLoadOptions({
compatConfig,
pluginIds,
pluginIds: pluginIds.runtimePluginIds,
});
const loadedProviders = resolveConfigScopedRuntimeCacheValue({
cache: capabilityProviderSnapshotCache,
@@ -439,7 +501,7 @@ export function resolvePluginCapabilityProvider<K extends CapabilityProviderRegi
load: () =>
loadCapabilityProviderEntries({
key: params.key,
pluginIds,
bundledCompatPluginIds: pluginIds.bundledCompatPluginIds,
loadOptions,
requested: new Set([params.providerId.toLowerCase()]),
}) as CapabilityProviderEntries,
@@ -450,7 +512,7 @@ export function resolvePluginCapabilityProvider<K extends CapabilityProviderRegi
function resolveCachedCapabilityProviderEntries<K extends CapabilityProviderRegistryKey>(params: {
key: K;
cfg?: OpenClawConfig;
pluginIds: string[];
bundledCompatPluginIds: string[];
loadOptions: PluginLoadOptions;
requested?: Set<string>;
}): PluginRegistry[K] {
@@ -464,7 +526,7 @@ function resolveCachedCapabilityProviderEntries<K extends CapabilityProviderRegi
load: () =>
loadCapabilityProviderEntries({
key: params.key,
pluginIds: params.pluginIds,
bundledCompatPluginIds: params.bundledCompatPluginIds,
loadOptions: params.loadOptions,
requested: params.requested,
}) as CapabilityProviderEntries,
@@ -501,28 +563,28 @@ export function resolvePluginCapabilityProviders<K extends CapabilityProviderReg
(activeProviders.length === 0 ? collectRequestedSpeechProviderIds(params.cfg) : undefined);
}
const pluginIds =
resolveRequestedCapabilityCompatPluginIds({
resolveRequestedCapabilityPluginIds({
key: params.key,
cfg: params.cfg,
requested: requestedSpeechProviders,
}) ??
resolveBundledCapabilityCompatPluginIds({
resolveCapabilityPluginIds({
key: params.key,
cfg: params.cfg,
});
const compatConfig = resolveCapabilityProviderConfig({
key: params.key,
cfg: params.cfg,
pluginIds,
pluginIds: pluginIds.bundledCompatPluginIds,
});
const loadOptions = createCapabilityProviderFallbackLoadOptions({
compatConfig,
pluginIds,
pluginIds: pluginIds.runtimePluginIds,
});
const loadedProviders = resolveCachedCapabilityProviderEntries({
key: params.key,
cfg: params.cfg,
pluginIds,
bundledCompatPluginIds: pluginIds.bundledCompatPluginIds,
loadOptions,
requested: requestedSpeechProviders,
});

View File

@@ -0,0 +1,59 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isInstalledPluginEnabled } from "./installed-plugin-index.js";
import type { PluginManifestContractListKey, PluginManifestRecord } from "./manifest-registry.js";
import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js";
export function isManifestPluginAvailableForControlPlane(params: {
snapshot: Pick<PluginMetadataSnapshot, "index">;
plugin: Pick<PluginManifestRecord, "id" | "origin" | "enabledByDefault">;
config?: OpenClawConfig;
}): boolean {
if (params.plugin.origin === "bundled") {
return true;
}
return isInstalledPluginEnabled(params.snapshot.index, params.plugin.id, params.config);
}
export function hasManifestContractValue(params: {
plugin: Pick<PluginManifestRecord, "contracts">;
contract: PluginManifestContractListKey;
value?: string;
}): boolean {
const values = params.plugin.contracts?.[params.contract] ?? [];
return values.length > 0 && (!params.value || values.includes(params.value));
}
export function listAvailableManifestContractPlugins(params: {
snapshot: Pick<PluginMetadataSnapshot, "index" | "plugins">;
contract: PluginManifestContractListKey;
value?: string;
config?: OpenClawConfig;
}): PluginManifestRecord[] {
return params.snapshot.plugins.filter(
(plugin) =>
hasManifestContractValue({
plugin,
contract: params.contract,
value: params.value,
}) &&
isManifestPluginAvailableForControlPlane({
snapshot: params.snapshot,
plugin,
config: params.config,
}),
);
}
export function listAvailableManifestContractValues(params: {
snapshot: Pick<PluginMetadataSnapshot, "index" | "plugins">;
contract: PluginManifestContractListKey;
config?: OpenClawConfig;
}): string[] {
const values = new Set<string>();
for (const plugin of listAvailableManifestContractPlugins(params)) {
for (const value of plugin.contracts?.[params.contract] ?? []) {
values.add(value);
}
}
return [...values].toSorted((left, right) => left.localeCompare(right));
}

View File

@@ -1,6 +1,10 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
hasManifestContractValue,
listAvailableManifestContractPlugins,
} from "./manifest-contract-eligibility.js";
import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js";
import type { PluginManifestContractListKey, PluginManifestRecord } from "./manifest-registry.js";
import type { PluginManifestContractListKey } from "./manifest-registry.js";
import { loadPluginRegistrySnapshot } from "./plugin-registry.js";
export type ManifestContractRuntimePluginResolution = {
@@ -12,15 +16,6 @@ const DEMAND_ONLY_CONTRACT_LOOKUP_OPTIONS = {
preferPersisted: false,
} as const;
function hasManifestContractValue(
plugin: PluginManifestRecord,
contract: PluginManifestContractListKey,
value?: string,
): boolean {
const values = plugin.contracts?.[contract] ?? [];
return values.length > 0 && (!value || values.includes(value));
}
export function resolveManifestContractRuntimePluginResolution(params: {
cfg?: OpenClawConfig;
contract: PluginManifestContractListKey;
@@ -36,16 +31,22 @@ export function resolveManifestContractRuntimePluginResolution(params: {
config: params.cfg,
env: process.env,
includeDisabled: true,
}).plugins.filter((plugin) => hasManifestContractValue(plugin, params.contract, params.value));
}).plugins.filter((plugin) =>
hasManifestContractValue({
plugin,
contract: params.contract,
value: params.value,
}),
);
const bundledCompatPluginIds = allContractPlugins
.filter((plugin) => plugin.origin === "bundled")
.map((plugin) => plugin.id);
const enabledPluginIds = new Set(
index.plugins.filter((plugin) => plugin.enabled).map((plugin) => plugin.pluginId),
);
const pluginIds = allContractPlugins
.filter((plugin) => plugin.origin === "bundled" || enabledPluginIds.has(plugin.id))
.map((plugin) => plugin.id);
const pluginIds = listAvailableManifestContractPlugins({
snapshot: { index, plugins: allContractPlugins },
contract: params.contract,
value: params.value,
config: params.cfg,
}).map((plugin) => plugin.id);
return {
pluginIds: [...new Set(pluginIds)].toSorted((left, right) => left.localeCompare(right)),
bundledCompatPluginIds: [...new Set(bundledCompatPluginIds)].toSorted((left, right) =>