diff --git a/src/plugins/activation-planner.test.ts b/src/plugins/activation-planner.test.ts index 0ae57bc9b11..27728f5ed84 100644 --- a/src/plugins/activation-planner.test.ts +++ b/src/plugins/activation-planner.test.ts @@ -167,4 +167,16 @@ describe("resolveManifestActivationPluginIds", () => { }), ).toEqual(["demo-channel"]); }); + + it("treats explicit empty plugin scopes as scoped-empty", () => { + expect( + resolveManifestActivationPluginIds({ + trigger: { + kind: "provider", + provider: "openai", + }, + onlyPluginIds: [], + }), + ).toEqual([]); + }); }); diff --git a/src/plugins/activation-planner.ts b/src/plugins/activation-planner.ts index 567dcef61b0..930fee12c57 100644 --- a/src/plugins/activation-planner.ts +++ b/src/plugins/activation-planner.ts @@ -4,6 +4,7 @@ import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js"; import type { PluginManifestActivationCapability } from "./manifest.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; +import { createPluginIdScopeSet, normalizePluginIdScope } from "./plugin-scope.js"; export type PluginActivationPlannerTrigger = | { kind: "command"; command: string } @@ -20,10 +21,7 @@ export function resolveManifestActivationPluginIds(params: { origin?: PluginOrigin; onlyPluginIds?: readonly string[]; }): string[] { - const onlyPluginIds = - params.onlyPluginIds && params.onlyPluginIds.length > 0 - ? new Set(params.onlyPluginIds.map((pluginId) => pluginId.trim()).filter(Boolean)) - : null; + const onlyPluginIdSet = createPluginIdScopeSet(normalizePluginIdScope(params.onlyPluginIds)); return [ ...new Set( @@ -35,7 +33,7 @@ export function resolveManifestActivationPluginIds(params: { .plugins.filter( (plugin) => (!params.origin || plugin.origin === params.origin) && - (!onlyPluginIds || onlyPluginIds.has(plugin.id)) && + (!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)) && matchesManifestActivationTrigger(plugin, params.trigger), ) .map((plugin) => plugin.id), diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index e172f265e46..72b348e124e 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -58,6 +58,12 @@ import { } from "./memory-state.js"; import { unwrapDefaultModuleExport } from "./module-export.js"; import { isPathInside, safeStatSync } from "./path-safety.js"; +import { + createPluginIdScopeSet, + hasExplicitPluginIdScope, + normalizePluginIdScope, + serializePluginIdScope, +} from "./plugin-scope.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { resolvePluginCacheInputs } from "./roots.js"; import { @@ -360,8 +366,7 @@ function buildCacheKey(params: { }, ]), ); - const scopeKey = - params.onlyPluginIds === undefined ? "__unscoped__" : JSON.stringify(params.onlyPluginIds); + const scopeKey = serializePluginIdScope(params.onlyPluginIds); const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime"; const startupChannelMode = params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full"; @@ -376,14 +381,6 @@ function buildCacheKey(params: { })}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${moduleLoadMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`; } -function normalizeScopedPluginIds(ids?: string[]): string[] | undefined { - if (!ids) { - return undefined; - } - const normalized = Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean))).toSorted(); - return normalized; -} - function matchesScopedPluginRequest(params: { onlyPluginIdSet: ReadonlySet | null; pluginId: string; @@ -451,7 +448,7 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean { options.autoEnabledReasons !== undefined || options.workspaceDir !== undefined || options.env !== undefined || - options.onlyPluginIds !== undefined || + hasExplicitPluginIdScope(options.onlyPluginIds) || options.runtimeOptions !== undefined || options.pluginSdkResolution !== undefined || options.coreGatewayHandlers !== undefined || @@ -469,7 +466,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { const activationSource = createPluginActivationSource({ config: activationSourceConfig, }); - const onlyPluginIds = normalizeScopedPluginIds(options.onlyPluginIds); + const onlyPluginIds = normalizePluginIdScope(options.onlyPluginIds); const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true; const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true; const runtimeSubagentMode = resolveRuntimeSubagentMode(options.runtimeOptions); @@ -1097,7 +1094,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } = resolvePluginLoadCacheContext(options); const logger = options.logger ?? defaultLogger(); const validateOnly = options.mode === "validate"; - const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null; + const onlyPluginIdSet = createPluginIdScopeSet(onlyPluginIds); const cacheEnabled = options.cache !== false; if (cacheEnabled) { const cached = getCachedPluginRegistry(cacheKey); @@ -1833,7 +1830,7 @@ export async function loadOpenClawPluginCliRegistry( cache: false, }); const logger = options.logger ?? defaultLogger(); - const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null; + const onlyPluginIdSet = createPluginIdScopeSet(onlyPluginIds); const getJiti = createPluginJitiLoader(options); const { registry, registerCli } = createPluginRegistry({ logger, diff --git a/src/plugins/plugin-scope.test.ts b/src/plugins/plugin-scope.test.ts new file mode 100644 index 00000000000..5e03c0970c0 --- /dev/null +++ b/src/plugins/plugin-scope.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { normalizePluginIdScope } from "./plugin-scope.js"; + +describe("normalizePluginIdScope", () => { + it("normalizes logical duplicates into a stable scope", () => { + expect(normalizePluginIdScope([" beta ", "alpha", "beta", ""])).toEqual(["alpha", "beta"]); + }); + + it("ignores non-string scope values instead of throwing", () => { + expect( + normalizePluginIdScope(["alpha", null, 42, { id: "beta" }, " beta "] as unknown[]), + ).toEqual(["alpha", "beta"]); + }); +}); diff --git a/src/plugins/plugin-scope.ts b/src/plugins/plugin-scope.ts new file mode 100644 index 00000000000..3053ebc1dfd --- /dev/null +++ b/src/plugins/plugin-scope.ts @@ -0,0 +1,34 @@ +export type PluginIdScope = readonly string[] | undefined; + +export function normalizePluginIdScope(ids?: readonly unknown[]): string[] | undefined { + if (ids === undefined) { + return undefined; + } + return Array.from( + new Set( + ids + .filter((id): id is string => typeof id === "string") + .map((id) => id.trim()) + .filter(Boolean), + ), + ).toSorted(); +} + +export function hasExplicitPluginIdScope(ids?: readonly string[]): boolean { + return ids !== undefined; +} + +export function hasNonEmptyPluginIdScope(ids?: readonly string[]): boolean { + return ids !== undefined && ids.length > 0; +} + +export function createPluginIdScopeSet(ids?: readonly string[]): ReadonlySet | null { + if (ids === undefined) { + return null; + } + return new Set(ids); +} + +export function serializePluginIdScope(ids?: readonly string[]): string { + return ids === undefined ? "__unscoped__" : JSON.stringify(ids); +} diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index a650bec3b02..ffa846f39a3 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -69,6 +69,7 @@ let prepareProviderRuntimeAuth: typeof import("./provider-runtime.js").preparePr let resetProviderRuntimeHookCacheForTest: typeof import("./provider-runtime.js").resetProviderRuntimeHookCacheForTest; let refreshProviderOAuthCredentialWithPlugin: typeof import("./provider-runtime.js").refreshProviderOAuthCredentialWithPlugin; let resolveProviderRuntimePlugin: typeof import("./provider-runtime.js").resolveProviderRuntimePlugin; +let providerRuntimeTesting: typeof import("./provider-runtime.js").__testing; let runProviderDynamicModel: typeof import("./provider-runtime.js").runProviderDynamicModel; let validateProviderReplayTurnsWithPlugin: typeof import("./provider-runtime.js").validateProviderReplayTurnsWithPlugin; let wrapProviderStreamFn: typeof import("./provider-runtime.js").wrapProviderStreamFn; @@ -282,6 +283,7 @@ describe("provider-runtime", () => { resetProviderRuntimeHookCacheForTest, refreshProviderOAuthCredentialWithPlugin, resolveProviderRuntimePlugin, + __testing: providerRuntimeTesting, runProviderDynamicModel, validateProviderReplayTurnsWithPlugin, wrapProviderStreamFn, @@ -330,6 +332,26 @@ describe("provider-runtime", () => { }); }); + it("normalizes plugin scopes in provider hook cache keys", () => { + const base = { + workspaceDir: "/tmp/workspace", + env: { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv, + providerRefs: ["demo"], + }; + + expect( + providerRuntimeTesting.buildHookProviderCacheKey({ + ...base, + onlyPluginIds: [" beta ", "alpha", "beta"], + }), + ).toBe( + providerRuntimeTesting.buildHookProviderCacheKey({ + ...base, + onlyPluginIds: ["alpha", "beta"], + }), + ); + }); + it("returns provider-prepared runtime auth for the matched provider", async () => { const prepareRuntimeAuth = vi.fn(async () => ({ apiKey: "runtime-token", diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 3d234384e4a..99ae3b96efb 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -8,6 +8,7 @@ import type { ProviderSystemPromptContribution } from "../agents/system-prompt-c import type { ModelProviderConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { normalizePluginIdScope, serializePluginIdScope } from "./plugin-scope.js"; import { resolveBundledProviderPolicySurface } from "./provider-public-artifacts.js"; import type { ProviderRuntimeModel } from "./provider-runtime-model.types.js"; import { resolveCatalogHookProviderPluginIds } from "./providers.js"; @@ -123,7 +124,8 @@ function buildHookProviderCacheKey(params: { workspaceDir: params.workspaceDir, env: params.env, }); - return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(params.config ?? null)}::${JSON.stringify(params.onlyPluginIds ?? [])}::${JSON.stringify(params.providerRefs ?? [])}`; + const onlyPluginIds = normalizePluginIdScope(params.onlyPluginIds); + return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(params.config ?? null)}::${serializePluginIdScope(onlyPluginIds)}::${JSON.stringify(params.providerRefs ?? [])}`; } export function clearProviderRuntimeHookCache(): void { @@ -141,6 +143,10 @@ export function resetProviderRuntimeHookCacheForTest(): void { clearProviderRuntimeHookCache(); } +export const __testing = { + buildHookProviderCacheKey, +} as const; + function resolveProviderPluginsForHooks(params: { config?: OpenClawConfig; workspaceDir?: string; diff --git a/src/plugins/providers.runtime.ts b/src/plugins/providers.runtime.ts index e2f9909277d..fbbdbc4b794 100644 --- a/src/plugins/providers.runtime.ts +++ b/src/plugins/providers.runtime.ts @@ -7,6 +7,7 @@ import { resolveRuntimePluginRegistry, type PluginLoadOptions, } from "./loader.js"; +import { hasExplicitPluginIdScope } from "./plugin-scope.js"; import { resolveActivatableProviderOwnerPluginIds, resolveDiscoverableProviderOwnerPluginIds, @@ -99,7 +100,7 @@ function resolvePluginProviderLoadBase(params: { }) : []; const requestedPluginIds = - params.onlyPluginIds || + hasExplicitPluginIdScope(params.onlyPluginIds) || params.providerRefs?.length || params.modelRefs?.length || providerOwnedPluginIds.length > 0 || diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index fa74dabf0f3..72f94bf5704 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -23,6 +23,7 @@ let resolveOwningPluginIdsForProvider: typeof import("./providers.js").resolveOw let resolveOwningPluginIdsForModelRef: typeof import("./providers.js").resolveOwningPluginIdsForModelRef; let resolveActivatableProviderOwnerPluginIds: typeof import("./providers.js").resolveActivatableProviderOwnerPluginIds; let resolveEnabledProviderPluginIds: typeof import("./providers.js").resolveEnabledProviderPluginIds; +let resolveDiscoveredProviderPluginIds: typeof import("./providers.js").resolveDiscoveredProviderPluginIds; let resolveDiscoverableProviderOwnerPluginIds: typeof import("./providers.js").resolveDiscoverableProviderOwnerPluginIds; let resolvePluginProviders: typeof import("./providers.runtime.js").resolvePluginProviders; let setActivePluginRegistry: SetActivePluginRegistry; @@ -143,7 +144,7 @@ function expectLastRuntimeRegistryLoad(params?: { cache: false, activate: false, ...(params?.env ? { env: params.env } : {}), - ...(params?.onlyPluginIds ? { onlyPluginIds: params.onlyPluginIds } : {}), + ...(params?.onlyPluginIds !== undefined ? { onlyPluginIds: params.onlyPluginIds } : {}), }), ); } @@ -157,7 +158,7 @@ function expectLastSetupRegistryLoad(params?: { cache: false, activate: false, ...(params?.env ? { env: params.env } : {}), - ...(params?.onlyPluginIds ? { onlyPluginIds: params.onlyPluginIds } : {}), + ...(params?.onlyPluginIds !== undefined ? { onlyPluginIds: params.onlyPluginIds } : {}), }), ); } @@ -194,7 +195,7 @@ function createBundledProviderCompatOptions(params?: { onlyPluginIds?: readonly }, }, bundledProviderAllowlistCompat: true, - ...(params?.onlyPluginIds ? { onlyPluginIds: params.onlyPluginIds } : {}), + ...(params?.onlyPluginIds !== undefined ? { onlyPluginIds: params.onlyPluginIds } : {}), }; } @@ -290,6 +291,7 @@ describe("resolvePluginProviders", () => { resolveOwningPluginIdsForProvider, resolveOwningPluginIdsForModelRef, resolveEnabledProviderPluginIds, + resolveDiscoveredProviderPluginIds, resolveDiscoverableProviderOwnerPluginIds, } = await import("./providers.js")); ({ resolvePluginProviders } = await import("./providers.runtime.js")); @@ -385,6 +387,23 @@ describe("resolvePluginProviders", () => { ]); }); + it("treats explicit empty provider scopes as scoped-empty in provider helpers", () => { + expect( + resolveEnabledProviderPluginIds({ + config: {}, + env: {} as NodeJS.ProcessEnv, + onlyPluginIds: [], + }), + ).toEqual([]); + expect( + resolveDiscoveredProviderPluginIds({ + config: {}, + env: {} as NodeJS.ProcessEnv, + onlyPluginIds: [], + }), + ).toEqual([]); + }); + it.each([ { name: "can augment restrictive allowlists for bundled provider compatibility", diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 7ad3e9ea75b..71b408b480e 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -7,6 +7,7 @@ import { type PluginManifestRecord, type PluginManifestRegistry, } from "./manifest-registry.js"; +import { createPluginIdScopeSet } from "./plugin-scope.js"; export function withBundledProviderVitestCompat(params: { config: PluginLoadOptions["config"]; @@ -22,7 +23,7 @@ export function resolveBundledProviderCompatPluginIds(params: { env?: PluginLoadOptions["env"]; onlyPluginIds?: readonly string[]; }): string[] { - const onlyPluginIdSet = params.onlyPluginIds ? new Set(params.onlyPluginIds) : null; + const onlyPluginIdSet = createPluginIdScopeSet(params.onlyPluginIds); const registry = loadPluginManifestRegistry({ config: params.config, workspaceDir: params.workspaceDir, @@ -45,7 +46,7 @@ export function resolveEnabledProviderPluginIds(params: { env?: PluginLoadOptions["env"]; onlyPluginIds?: readonly string[]; }): string[] { - const onlyPluginIdSet = params.onlyPluginIds ? new Set(params.onlyPluginIds) : null; + const onlyPluginIdSet = createPluginIdScopeSet(params.onlyPluginIds); const registry = loadPluginManifestRegistry({ config: params.config, workspaceDir: params.workspaceDir, @@ -76,7 +77,7 @@ export function resolveDiscoveredProviderPluginIds(params: { onlyPluginIds?: readonly string[]; includeUntrustedWorkspacePlugins?: boolean; }): string[] { - const onlyPluginIdSet = params.onlyPluginIds ? new Set(params.onlyPluginIds) : null; + const onlyPluginIdSet = createPluginIdScopeSet(params.onlyPluginIds); const registry = loadPluginManifestRegistry({ config: params.config, workspaceDir: params.workspaceDir, diff --git a/src/plugins/runtime/metadata-registry-loader.test.ts b/src/plugins/runtime/metadata-registry-loader.test.ts index d4e3a0a2a28..260346fdda0 100644 --- a/src/plugins/runtime/metadata-registry-loader.test.ts +++ b/src/plugins/runtime/metadata-registry-loader.test.ts @@ -78,4 +78,18 @@ describe("loadPluginMetadataRegistrySnapshot", () => { }), ); }); + + it("preserves explicit empty plugin scopes on metadata snapshots", () => { + loadPluginMetadataRegistrySnapshot({ + config: { plugins: {} }, + onlyPluginIds: [], + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: [], + mode: "validate", + }), + ); + }); }); diff --git a/src/plugins/runtime/metadata-registry-loader.ts b/src/plugins/runtime/metadata-registry-loader.ts index a88f915b47e..5883fe55bf0 100644 --- a/src/plugins/runtime/metadata-registry-loader.ts +++ b/src/plugins/runtime/metadata-registry-loader.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { loadOpenClawPlugins } from "../loader.js"; +import { hasExplicitPluginIdScope } from "../plugin-scope.js"; import type { PluginRegistry } from "../registry.js"; import { buildPluginRuntimeLoadOptions, resolvePluginRuntimeLoadContext } from "./load-context.js"; @@ -20,7 +21,9 @@ export function loadPluginMetadataRegistrySnapshot(options?: { activate: false, mode: "validate", loadModules: options?.loadModules, - ...(options?.onlyPluginIds !== undefined ? { onlyPluginIds: options.onlyPluginIds } : {}), + ...(hasExplicitPluginIdScope(options?.onlyPluginIds) + ? { onlyPluginIds: options?.onlyPluginIds } + : {}), }), ); } diff --git a/src/plugins/runtime/runtime-registry-loader.test.ts b/src/plugins/runtime/runtime-registry-loader.test.ts index 536da37b9f1..aa1c9c1ba96 100644 --- a/src/plugins/runtime/runtime-registry-loader.test.ts +++ b/src/plugins/runtime/runtime-registry-loader.test.ts @@ -156,4 +156,20 @@ describe("ensurePluginRegistryLoaded", () => { expect.objectContaining({ onlyPluginIds: ["demo-b"] }), ); }); + + it("forwards explicit empty scopes without widening to channel resolution", () => { + ensurePluginRegistryLoaded({ + scope: "configured-channels", + config: {} as never, + onlyPluginIds: [], + }); + + expect(mocks.resolveConfiguredChannelPluginIds).not.toHaveBeenCalled(); + expect(mocks.resolveChannelPluginIds).not.toHaveBeenCalled(); + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: [], + }), + ); + }); }); diff --git a/src/plugins/runtime/runtime-registry-loader.ts b/src/plugins/runtime/runtime-registry-loader.ts index 2594700627f..7e87f6654dd 100644 --- a/src/plugins/runtime/runtime-registry-loader.ts +++ b/src/plugins/runtime/runtime-registry-loader.ts @@ -4,6 +4,11 @@ import { resolveConfiguredChannelPluginIds, } from "../channel-plugin-ids.js"; import { loadOpenClawPlugins } from "../loader.js"; +import { + hasExplicitPluginIdScope, + hasNonEmptyPluginIdScope, + normalizePluginIdScope, +} from "../plugin-scope.js"; import { getActivePluginRegistry } from "../runtime.js"; import { buildPluginRuntimeLoadOptions, resolvePluginRuntimeLoadContext } from "./load-context.js"; @@ -29,12 +34,15 @@ function activeRegistrySatisfiesScope( scope: PluginRegistryScope, active: ReturnType, expectedChannelPluginIds: readonly string[], - requestedPluginIds: readonly string[], + requestedPluginIds: readonly string[] | undefined, ): boolean { if (!active) { return false; } - if (requestedPluginIds.length > 0) { + if (requestedPluginIds !== undefined) { + if (requestedPluginIds.length === 0) { + return false; + } const activePluginIds = new Set( active.plugins.filter((plugin) => plugin.status === "loaded").map((plugin) => plugin.id), ); @@ -62,12 +70,11 @@ export function ensurePluginRegistryLoaded(options?: { onlyPluginIds?: string[]; }): void { const scope = options?.scope ?? "all"; - const requestedPluginIds = - options?.onlyPluginIds?.map((pluginId) => pluginId.trim()).filter(Boolean) ?? []; - const scopedLoad = requestedPluginIds.length > 0; + const requestedPluginIds = normalizePluginIdScope(options?.onlyPluginIds); + const scopedLoad = hasExplicitPluginIdScope(requestedPluginIds); const context = resolvePluginRuntimeLoadContext(options); const expectedChannelPluginIds = scopedLoad - ? requestedPluginIds + ? (requestedPluginIds ?? []) : scope === "configured-channels" ? resolveConfiguredChannelPluginIds({ config: context.config, @@ -85,13 +92,13 @@ export function ensurePluginRegistryLoaded(options?: { if ( !scopedLoad && scopeRank(pluginRegistryLoaded) >= scopeRank(scope) && - activeRegistrySatisfiesScope(scope, active, expectedChannelPluginIds, expectedChannelPluginIds) + activeRegistrySatisfiesScope(scope, active, expectedChannelPluginIds, undefined) ) { return; } if ( (pluginRegistryLoaded === "none" || scopedLoad) && - activeRegistrySatisfiesScope(scope, active, expectedChannelPluginIds, expectedChannelPluginIds) + activeRegistrySatisfiesScope(scope, active, expectedChannelPluginIds, requestedPluginIds) ) { if (!scopedLoad) { pluginRegistryLoaded = scope; @@ -101,7 +108,10 @@ export function ensurePluginRegistryLoaded(options?: { loadOpenClawPlugins( buildPluginRuntimeLoadOptions(context, { throwOnLoadError: true, - ...(expectedChannelPluginIds.length > 0 ? { onlyPluginIds: expectedChannelPluginIds } : {}), + ...(hasExplicitPluginIdScope(requestedPluginIds) || + hasNonEmptyPluginIdScope(expectedChannelPluginIds) + ? { onlyPluginIds: expectedChannelPluginIds } + : {}), }), ); if (!scopedLoad) { diff --git a/src/plugins/web-provider-resolution-candidates.test.ts b/src/plugins/web-provider-resolution-candidates.test.ts new file mode 100644 index 00000000000..f7d38c737a5 --- /dev/null +++ b/src/plugins/web-provider-resolution-candidates.test.ts @@ -0,0 +1,68 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + loadPluginManifestRegistry: vi.fn(), + resolveManifestContractPluginIds: vi.fn(), +})); + +vi.mock("./manifest-registry.js", () => ({ + loadPluginManifestRegistry: (...args: unknown[]) => mocks.loadPluginManifestRegistry(...args), + resolveManifestContractPluginIds: (...args: unknown[]) => + mocks.resolveManifestContractPluginIds(...args), +})); + +let resolveManifestDeclaredWebProviderCandidatePluginIds: typeof import("./web-provider-resolution-shared.js").resolveManifestDeclaredWebProviderCandidatePluginIds; + +describe("resolveManifestDeclaredWebProviderCandidatePluginIds", () => { + beforeAll(async () => { + ({ resolveManifestDeclaredWebProviderCandidatePluginIds } = + await import("./web-provider-resolution-shared.js")); + }); + + beforeEach(() => { + mocks.resolveManifestContractPluginIds.mockReset(); + mocks.resolveManifestContractPluginIds.mockReturnValue(["alpha"]); + mocks.loadPluginManifestRegistry.mockReset(); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "alpha", + origin: "bundled", + configSchema: { + properties: { + webSearch: {}, + }, + }, + }, + { + id: "beta", + origin: "bundled", + contracts: { + webSearchProviders: ["beta-search"], + }, + }, + ], + diagnostics: [], + }); + }); + + it("treats explicit empty plugin scopes as scoped-empty", () => { + expect( + resolveManifestDeclaredWebProviderCandidatePluginIds({ + contract: "webSearchProviders", + configKey: "webSearch", + onlyPluginIds: [], + }), + ).toEqual([]); + }); + + it("keeps runtime fallback for scoped plugins with no declared web candidates", () => { + expect( + resolveManifestDeclaredWebProviderCandidatePluginIds({ + contract: "webSearchProviders", + configKey: "webSearch", + onlyPluginIds: ["missing-plugin"], + }), + ).toBeUndefined(); + }); +}); diff --git a/src/plugins/web-provider-resolution-shared.test.ts b/src/plugins/web-provider-resolution-shared.test.ts new file mode 100644 index 00000000000..38e658fbbc8 --- /dev/null +++ b/src/plugins/web-provider-resolution-shared.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + buildWebProviderSnapshotCacheKey, + mapRegistryProviders, +} from "./web-provider-resolution-shared.js"; + +describe("web-provider-resolution-shared", () => { + it("distinguishes explicit empty plugin scopes in cache keys", () => { + const unscoped = buildWebProviderSnapshotCacheKey({ + envKey: "demo", + }); + const scopedEmpty = buildWebProviderSnapshotCacheKey({ + envKey: "demo", + onlyPluginIds: [], + }); + + expect(scopedEmpty).not.toBe(unscoped); + }); + + it("treats explicit empty plugin scopes as scoped-empty when mapping providers", () => { + const providers = mapRegistryProviders({ + entries: [ + { + pluginId: "alpha", + provider: { id: "alpha-provider" }, + }, + { + pluginId: "beta", + provider: { id: "beta-provider" }, + }, + ], + onlyPluginIds: [], + sortProviders: (values) => values, + }); + + expect(providers).toEqual([]); + }); +}); diff --git a/src/plugins/web-provider-resolution-shared.ts b/src/plugins/web-provider-resolution-shared.ts index ed423145856..f295a79e2fc 100644 --- a/src/plugins/web-provider-resolution-shared.ts +++ b/src/plugins/web-provider-resolution-shared.ts @@ -6,6 +6,11 @@ import { resolveManifestContractPluginIds, type PluginManifestRecord, } from "./manifest-registry.js"; +import { + createPluginIdScopeSet, + normalizePluginIdScope, + serializePluginIdScope, +} from "./plugin-scope.js"; export type WebProviderContract = "webSearchProviders" | "webFetchProviders"; export type WebProviderConfigKey = "webSearch" | "webFetch"; @@ -77,8 +82,8 @@ export function resolveManifestDeclaredWebProviderCandidatePluginIds(params: { onlyPluginIds: params.onlyPluginIds, }), ); - const onlyPluginIdSet = - params.onlyPluginIds && params.onlyPluginIds.length > 0 ? new Set(params.onlyPluginIds) : null; + const scopedPluginIds = normalizePluginIdScope(params.onlyPluginIds); + const onlyPluginIdSet = createPluginIdScopeSet(scopedPluginIds); const ids = loadPluginManifestRegistry({ config: params.config, workspaceDir: params.workspaceDir, @@ -93,7 +98,10 @@ export function resolveManifestDeclaredWebProviderCandidatePluginIds(params: { ) .map((plugin) => plugin.id) .toSorted((left, right) => left.localeCompare(right)); - return ids.length > 0 ? ids : undefined; + if (ids.length > 0) { + return ids; + } + return scopedPluginIds?.length === 0 ? [] : undefined; } function resolveBundledWebProviderCompatPluginIds(params: { @@ -160,13 +168,12 @@ export function buildWebProviderSnapshotCacheKey(params: { typeof params.envKey === "string" ? params.envKey : Object.entries(params.envKey).toSorted(([left], [right]) => left.localeCompare(right)); + const onlyPluginIds = normalizePluginIdScope(params.onlyPluginIds); return JSON.stringify({ workspaceDir: params.workspaceDir ?? "", bundledAllowlistCompat: params.bundledAllowlistCompat === true, origin: params.origin ?? "", - onlyPluginIds: [...new Set(params.onlyPluginIds ?? [])].toSorted((left, right) => - left.localeCompare(right), - ), + onlyPluginIds: serializePluginIdScope(onlyPluginIds), env: envKey, }); } @@ -181,8 +188,7 @@ export function mapRegistryProviders< providers: Array, ) => Array; }): Array { - const onlyPluginIdSet = - params.onlyPluginIds && params.onlyPluginIds.length > 0 ? new Set(params.onlyPluginIds) : null; + const onlyPluginIdSet = createPluginIdScopeSet(normalizePluginIdScope(params.onlyPluginIds)); return params.sortProviders( params.entries .filter((entry) => !onlyPluginIdSet || onlyPluginIdSet.has(entry.pluginId)) diff --git a/src/plugins/web-provider-runtime-shared.test.ts b/src/plugins/web-provider-runtime-shared.test.ts new file mode 100644 index 00000000000..9b53e4a0ecd --- /dev/null +++ b/src/plugins/web-provider-runtime-shared.test.ts @@ -0,0 +1,157 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + isPluginRegistryLoadInFlight: vi.fn(() => false), + loadOpenClawPlugins: vi.fn(), + resolveCompatibleRuntimePluginRegistry: vi.fn(), + resolveRuntimePluginRegistry: vi.fn(), + getActivePluginRegistryWorkspaceDir: vi.fn(() => undefined), + buildPluginRuntimeLoadOptionsFromValues: vi.fn( + (_values: unknown, overrides?: Record) => ({ + ...overrides, + }), + ), + createPluginRuntimeLoaderLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +})); + +vi.mock("./loader.js", () => ({ + isPluginRegistryLoadInFlight: mocks.isPluginRegistryLoadInFlight, + loadOpenClawPlugins: mocks.loadOpenClawPlugins, + resolveCompatibleRuntimePluginRegistry: mocks.resolveCompatibleRuntimePluginRegistry, + resolveRuntimePluginRegistry: mocks.resolveRuntimePluginRegistry, +})); + +vi.mock("./runtime.js", () => ({ + getActivePluginRegistryWorkspaceDir: mocks.getActivePluginRegistryWorkspaceDir, +})); + +vi.mock("./runtime/load-context.js", () => ({ + buildPluginRuntimeLoadOptionsFromValues: mocks.buildPluginRuntimeLoadOptionsFromValues, + createPluginRuntimeLoaderLogger: mocks.createPluginRuntimeLoaderLogger, +})); + +let createWebProviderSnapshotCache: typeof import("./web-provider-runtime-shared.js").createWebProviderSnapshotCache; +let resolvePluginWebProviders: typeof import("./web-provider-runtime-shared.js").resolvePluginWebProviders; +let resolveRuntimeWebProviders: typeof import("./web-provider-runtime-shared.js").resolveRuntimeWebProviders; + +describe("web-provider-runtime-shared", () => { + beforeAll(async () => { + ({ createWebProviderSnapshotCache, resolvePluginWebProviders, resolveRuntimeWebProviders } = + await import("./web-provider-runtime-shared.js")); + }); + + beforeEach(() => { + mocks.isPluginRegistryLoadInFlight.mockReset(); + mocks.isPluginRegistryLoadInFlight.mockReturnValue(false); + mocks.loadOpenClawPlugins.mockReset(); + mocks.resolveCompatibleRuntimePluginRegistry.mockReset(); + mocks.resolveRuntimePluginRegistry.mockReset(); + mocks.getActivePluginRegistryWorkspaceDir.mockReset(); + mocks.getActivePluginRegistryWorkspaceDir.mockReturnValue(undefined); + mocks.buildPluginRuntimeLoadOptionsFromValues.mockReset(); + mocks.buildPluginRuntimeLoadOptionsFromValues.mockImplementation( + (_values: unknown, overrides?: Record) => ({ + ...overrides, + }), + ); + }); + + it("preserves explicit empty scopes in runtime-compatible web provider loads", () => { + const mapRegistryProviders = vi.fn(() => []); + mocks.resolveCompatibleRuntimePluginRegistry.mockReturnValue({} as never); + + resolvePluginWebProviders( + { + config: {}, + onlyPluginIds: [], + }, + { + snapshotCache: createWebProviderSnapshotCache(), + resolveBundledResolutionConfig: () => ({ + config: {}, + activationSourceConfig: {}, + autoEnabledReasons: {}, + }), + resolveCandidatePluginIds: () => [], + mapRegistryProviders, + }, + ); + + expect(mocks.resolveCompatibleRuntimePluginRegistry).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: [], + }), + ); + expect(mapRegistryProviders).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: [], + }), + ); + }); + + it("preserves explicit empty scopes in direct runtime web provider resolution", () => { + const mapRegistryProviders = vi.fn(() => []); + mocks.resolveRuntimePluginRegistry.mockReturnValue({} as never); + + resolveRuntimeWebProviders( + { + config: {}, + onlyPluginIds: [], + }, + { + snapshotCache: createWebProviderSnapshotCache(), + resolveBundledResolutionConfig: () => ({ + config: {}, + activationSourceConfig: {}, + autoEnabledReasons: {}, + }), + resolveCandidatePluginIds: () => [], + mapRegistryProviders, + }, + ); + + expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: [], + }), + ); + expect(mapRegistryProviders).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: [], + }), + ); + }); + + it("preserves explicit scopes when config is omitted in direct runtime resolution", () => { + const mapRegistryProviders = vi.fn(() => []); + mocks.resolveRuntimePluginRegistry.mockReturnValue({} as never); + + resolveRuntimeWebProviders( + { + onlyPluginIds: ["alpha"], + }, + { + snapshotCache: createWebProviderSnapshotCache(), + resolveBundledResolutionConfig: () => ({ + config: {}, + activationSourceConfig: {}, + autoEnabledReasons: {}, + }), + resolveCandidatePluginIds: () => ["alpha"], + mapRegistryProviders, + }, + ); + + expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith(undefined); + expect(mapRegistryProviders).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["alpha"], + }), + ); + }); +}); diff --git a/src/plugins/web-provider-runtime-shared.ts b/src/plugins/web-provider-runtime-shared.ts index aa29dd788d0..2f108d9dbc0 100644 --- a/src/plugins/web-provider-runtime-shared.ts +++ b/src/plugins/web-provider-runtime-shared.ts @@ -13,6 +13,7 @@ import { } from "./loader.js"; import type { PluginLoadOptions } from "./loader.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; +import { hasExplicitPluginIdScope, normalizePluginIdScope } from "./plugin-scope.js"; import type { PluginRegistry } from "./registry.js"; import { getActivePluginRegistryWorkspaceDir } from "./runtime.js"; import { @@ -87,13 +88,15 @@ function resolveWebProviderLoadOptions( workspaceDir, env, }); - const onlyPluginIds = deps.resolveCandidatePluginIds({ - config, - workspaceDir, - env, - onlyPluginIds: params.onlyPluginIds, - origin: params.origin, - }); + const onlyPluginIds = normalizePluginIdScope( + deps.resolveCandidatePluginIds({ + config, + workspaceDir, + env, + onlyPluginIds: params.onlyPluginIds, + origin: params.origin, + }), + ); return buildPluginRuntimeLoadOptionsFromValues( { env, @@ -106,7 +109,7 @@ function resolveWebProviderLoadOptions( { cache: params.cache ?? false, activate: params.activate ?? false, - ...(onlyPluginIds ? { onlyPluginIds } : {}), + ...(hasExplicitPluginIdScope(onlyPluginIds) ? { onlyPluginIds } : {}), }, ); } @@ -220,9 +223,9 @@ export function resolveRuntimeWebProviders( params: Omit, deps: ResolveWebProviderRuntimeDeps, ): TEntry[] { - const runtimeRegistry = resolveRuntimePluginRegistry( - params.config === undefined ? undefined : resolveWebProviderLoadOptions(params, deps), - ); + const loadOptions = + params.config === undefined ? undefined : resolveWebProviderLoadOptions(params, deps); + const runtimeRegistry = resolveRuntimePluginRegistry(loadOptions); if (runtimeRegistry) { return deps.mapRegistryProviders({ registry: runtimeRegistry,