mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:30:42 +00:00
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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
14
src/plugins/plugin-scope.test.ts
Normal file
14
src/plugins/plugin-scope.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
34
src/plugins/plugin-scope.ts
Normal file
34
src/plugins/plugin-scope.ts
Normal 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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
68
src/plugins/web-provider-resolution-candidates.test.ts
Normal file
68
src/plugins/web-provider-resolution-candidates.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
38
src/plugins/web-provider-resolution-shared.test.ts
Normal file
38
src/plugins/web-provider-resolution-shared.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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))
|
||||
|
||||
157
src/plugins/web-provider-runtime-shared.test.ts
Normal file
157
src/plugins/web-provider-runtime-shared.test.ts
Normal 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"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user