fix(plugins): centralize explicit plugin scope handling (#65298)

* fix(plugins): centralize explicit plugin scope handling

* fix(plugins): preserve explicit empty web scopes

* fix(plugins): preserve runtime web provider scopes without config

* fix(plugins): preserve web provider runtime filtering

* fix(plugins): preserve scoped web runtime fallback

* fix(plugins): harden plugin scope normalization
This commit is contained in:
Vincent Koc
2026-04-12 16:16:37 +01:00
committed by GitHub
parent 659bcc5e5b
commit 6a189eec0b
19 changed files with 475 additions and 56 deletions

View File

@@ -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([]);
});
});

View File

@@ -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),

View File

@@ -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<string> | 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,

View File

@@ -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"]);
});
});

View File

@@ -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<string> | null {
if (ids === undefined) {
return null;
}
return new Set(ids);
}
export function serializePluginIdScope(ids?: readonly string[]): string {
return ids === undefined ? "__unscoped__" : JSON.stringify(ids);
}

View File

@@ -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",

View File

@@ -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;

View File

@@ -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 ||

View File

@@ -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",

View File

@@ -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,

View File

@@ -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",
}),
);
});
});

View File

@@ -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 }
: {}),
}),
);
}

View File

@@ -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: [],
}),
);
});
});

View File

@@ -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<typeof getActivePluginRegistry>,
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) {

View File

@@ -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();
});
});

View File

@@ -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([]);
});
});

View File

@@ -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<TProvider & { pluginId: string }>,
) => Array<TProvider & { pluginId: string }>;
}): Array<TProvider & { pluginId: string }> {
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))

View File

@@ -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<string, unknown>) => ({
...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<string, unknown>) => ({
...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"],
}),
);
});
});

View File

@@ -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<TEntry>(
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<TEntry>(
{
cache: params.cache ?? false,
activate: params.activate ?? false,
...(onlyPluginIds ? { onlyPluginIds } : {}),
...(hasExplicitPluginIdScope(onlyPluginIds) ? { onlyPluginIds } : {}),
},
);
}
@@ -220,9 +223,9 @@ export function resolveRuntimeWebProviders<TEntry>(
params: Omit<ResolvePluginWebProvidersParams, "activate" | "cache" | "mode">,
deps: ResolveWebProviderRuntimeDeps<TEntry>,
): 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,