diff --git a/src/agents/openclaw-tools.media-factory-plan.test.ts b/src/agents/openclaw-tools.media-factory-plan.test.ts index bf772b74ecb..3e2bbc4880c 100644 --- a/src/agents/openclaw-tools.media-factory-plan.test.ts +++ b/src/agents/openclaw-tools.media-factory-plan.test.ts @@ -28,12 +28,13 @@ function createAuthStore(providers: string[] = []): AuthProfileStore { function createPlugin(params: { id: string; + origin?: PluginManifestRecord["origin"]; contracts: NonNullable; 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, diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 3913038c639..6ef6877ce1e 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -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, diff --git a/src/agents/tools/media-tool-shared.ts b/src/agents/tools/media-tool-shared.ts index 2e406340273..3cb2a170085 100644 --- a/src/agents/tools/media-tool-shared.ts +++ b/src/agents/tools/media-tool-shared.ts @@ -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, diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index cd78e67f328..22e1ab19f6e 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -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(), diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index a77d68132a2..abdb6e33ac1 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -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 = PluginRegistry[K][number] extends { provider: infer T } ? T : never; type CapabilityProviderEntries = PluginRegistry[CapabilityProviderRegistryKey]; +type CapabilityPluginResolution = { + runtimePluginIds: string[]; + bundledCompatPluginIds: string[]; +}; const capabilityProviderSnapshotCache: ConfigScopedRuntimeCache = new WeakMap(); @@ -70,35 +81,92 @@ function shouldSkipCapabilityResolution(params: { ); } +function uniqueSorted(values: Iterable): string[] { + return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); +} + +function loadCapabilityManifestSnapshot(params: { + cfg?: OpenClawConfig; + workspaceDir?: string; +}): Pick { + 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; -}): string[] | undefined { +}): CapabilityPluginResolution | undefined { if (params.key !== "speechProviders" || !params.requested || params.requested.size === 0) { return undefined; } - const pluginIds = new Set(); + const runtimePluginIds = new Set(); + const bundledCompatPluginIds = new Set(); 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(params: { key: K; - pluginIds: string[]; + bundledCompatPluginIds: string[]; loadOptions: PluginLoadOptions; requested?: Set; }): PluginRegistry[K] { @@ -388,11 +450,11 @@ function loadCapabilityProviderEntries( 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 loadCapabilityProviderEntries({ key: params.key, - pluginIds, + bundledCompatPluginIds: pluginIds.bundledCompatPluginIds, loadOptions, requested: new Set([params.providerId.toLowerCase()]), }) as CapabilityProviderEntries, @@ -450,7 +512,7 @@ export function resolvePluginCapabilityProvider(params: { key: K; cfg?: OpenClawConfig; - pluginIds: string[]; + bundledCompatPluginIds: string[]; loadOptions: PluginLoadOptions; requested?: Set; }): PluginRegistry[K] { @@ -464,7 +526,7 @@ function resolveCachedCapabilityProviderEntries 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; + plugin: Pick; + 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; + 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; + 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; + contract: PluginManifestContractListKey; + config?: OpenClawConfig; +}): string[] { + const values = new Set(); + 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)); +} diff --git a/src/plugins/manifest-contract-runtime.ts b/src/plugins/manifest-contract-runtime.ts index 1255ca5d46a..f6a3df013e5 100644 --- a/src/plugins/manifest-contract-runtime.ts +++ b/src/plugins/manifest-contract-runtime.ts @@ -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) =>