refactor: simplify web provider plugin discovery

This commit is contained in:
Peter Steinberger
2026-04-05 08:49:05 +01:00
parent c863ee1b86
commit 23275edef1
33 changed files with 420 additions and 1037 deletions

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "../../config/config.js";
import { resolveBundledWebSearchPluginId } from "../../plugins/bundled-web-search-provider-ids.js";
import { resolveManifestContractOwnerPluginId } from "../../plugins/manifest-registry.js";
import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js";
import {
resolveWebSearchDefinition,
@@ -19,7 +19,13 @@ export function createWebSearchTool(options?: {
const resolved = resolveWebSearchDefinition({
...options,
preferRuntimeProviders:
Boolean(runtimeProviderId) && !resolveBundledWebSearchPluginId(runtimeProviderId),
Boolean(runtimeProviderId) &&
!resolveManifestContractOwnerPluginId({
contract: "webSearchProviders",
value: runtimeProviderId,
origin: "bundled",
config: options?.config,
}),
});
if (!resolved) {
return null;

View File

@@ -2,8 +2,7 @@ import type { OpenClawConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { callGateway } from "../gateway/call.js";
import { validateSecretsResolveResult } from "../gateway/protocol/index.js";
import { resolveBundledWebFetchPluginId } from "../plugins/bundled-web-fetch-provider-ids.js";
import { resolveBundledWebSearchPluginId } from "../plugins/bundled-web-search-provider-ids.js";
import { resolveManifestContractOwnerPluginId } from "../plugins/manifest-registry.js";
import {
analyzeCommandSecretAssignmentsFromSnapshot,
type UnresolvedCommandSecretAssignment,
@@ -118,7 +117,12 @@ function classifyRuntimeWebTargetPathState(params: {
if (!configuredProvider) {
return "active";
}
return resolveBundledWebFetchPluginId(configuredProvider) === pluginId
return resolveManifestContractOwnerPluginId({
contract: "webFetchProviders",
value: configuredProvider,
origin: "bundled",
config: params.config,
}) === pluginId
? "active"
: "inactive";
}
@@ -131,7 +135,14 @@ function classifyRuntimeWebTargetPathState(params: {
if (!configuredProvider) {
return "active";
}
return resolveBundledWebSearchPluginId(configuredProvider) === pluginId ? "active" : "inactive";
return resolveManifestContractOwnerPluginId({
contract: "webSearchProviders",
value: configuredProvider,
origin: "bundled",
config: params.config,
}) === pluginId
? "active"
: "inactive";
}
const match = /^tools\.web\.search\.([^.]+)\.apiKey$/.exec(params.path);
@@ -184,7 +195,12 @@ function describeInactiveRuntimeWebTargetPath(params: {
const configuredProvider =
typeof search?.provider === "string" ? search.provider.trim().toLowerCase() : "";
const configuredPluginId = configuredProvider
? resolveBundledWebSearchPluginId(configuredProvider)
? resolveManifestContractOwnerPluginId({
contract: "webSearchProviders",
value: configuredProvider,
origin: "bundled",
config: params.config,
})
: undefined;
if (configuredPluginId && configuredPluginId !== pluginId) {
return `tools.web.search.provider is "${configuredProvider}".`;

View File

@@ -33,6 +33,7 @@ const getConfiguredPluginWebSearchCredential =
const mockWebSearchProviders = [
{
id: "brave",
pluginId: "brave",
envVars: ["BRAVE_API_KEY"],
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
getCredentialValue: (search?: Record<string, unknown>) => search?.apiKey,
@@ -40,6 +41,7 @@ const mockWebSearchProviders = [
},
{
id: "firecrawl",
pluginId: "firecrawl",
envVars: ["FIRECRAWL_API_KEY"],
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
getCredentialValue: getScopedWebSearchCredential("firecrawl"),
@@ -47,6 +49,7 @@ const mockWebSearchProviders = [
},
{
id: "gemini",
pluginId: "google",
envVars: ["GEMINI_API_KEY"],
credentialPath: "plugins.entries.google.config.webSearch.apiKey",
getCredentialValue: getScopedWebSearchCredential("gemini"),
@@ -54,6 +57,7 @@ const mockWebSearchProviders = [
},
{
id: "grok",
pluginId: "xai",
envVars: ["XAI_API_KEY"],
credentialPath: "plugins.entries.xai.config.webSearch.apiKey",
getCredentialValue: getScopedWebSearchCredential("grok"),
@@ -61,6 +65,7 @@ const mockWebSearchProviders = [
},
{
id: "kimi",
pluginId: "moonshot",
envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
credentialPath: "plugins.entries.moonshot.config.webSearch.apiKey",
getCredentialValue: getScopedWebSearchCredential("kimi"),
@@ -68,6 +73,7 @@ const mockWebSearchProviders = [
},
{
id: "minimax",
pluginId: "minimax",
envVars: ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY"],
credentialPath: "plugins.entries.minimax.config.webSearch.apiKey",
getCredentialValue: getScopedWebSearchCredential("minimax"),
@@ -75,6 +81,7 @@ const mockWebSearchProviders = [
},
{
id: "perplexity",
pluginId: "perplexity",
envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey",
getCredentialValue: getScopedWebSearchCredential("perplexity"),
@@ -82,6 +89,7 @@ const mockWebSearchProviders = [
},
{
id: "searxng",
pluginId: "searxng",
envVars: ["SEARXNG_BASE_URL"],
credentialPath: "plugins.entries.searxng.config.webSearch.baseUrl",
getCredentialValue: (search?: Record<string, unknown>) =>
@@ -91,6 +99,7 @@ const mockWebSearchProviders = [
},
{
id: "tavily",
pluginId: "tavily",
envVars: ["TAVILY_API_KEY"],
credentialPath: "plugins.entries.tavily.config.webSearch.apiKey",
getCredentialValue: getScopedWebSearchCredential("tavily"),
@@ -98,9 +107,8 @@ const mockWebSearchProviders = [
},
] as const;
vi.mock("../plugins/web-search-providers.js", () => {
vi.mock("../plugins/web-search-providers.runtime.js", () => {
return {
resolveBundledPluginWebSearchProviders: () => mockWebSearchProviders,
resolvePluginWebSearchProviders: () => mockWebSearchProviders,
};
});
@@ -158,6 +166,9 @@ vi.mock("../plugins/manifest-registry.js", () => {
origin: "bundled",
channels: [],
providers: [],
contracts: {
webSearchProviders: ["brave"],
},
cliBackends: [],
skills: [],
hooks: [],
@@ -181,6 +192,9 @@ vi.mock("../plugins/manifest-registry.js", () => {
origin: "bundled",
channels: [],
providers: [],
contracts: {
webSearchProviders: [id],
},
cliBackends: [],
skills: [],
hooks: [],
@@ -193,6 +207,17 @@ vi.mock("../plugins/manifest-registry.js", () => {
],
diagnostics: [],
}),
resolveManifestContractPluginIds: (params?: { contract?: string; origin?: string }) =>
params?.contract === "webSearchProviders" && params.origin === "bundled"
? mockWebSearchProviders
.map((provider) => provider.pluginId)
.filter((value, index, array) => array.indexOf(value) === index)
.toSorted((left, right) => left.localeCompare(right))
: [],
resolveManifestContractOwnerPluginId: (params?: { contract?: string; value?: string }) =>
params?.contract === "webSearchProviders"
? mockWebSearchProviders.find((provider) => provider.id === params.value)?.pluginId
: undefined,
};
});

View File

@@ -861,9 +861,9 @@ describe("applyPluginAutoEnable", () => {
it("prefers bluebubbles: skips imessage auto-configure when both are configured", () => {
const result = applyWithBluebubblesImessageConfig();
expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(true);
expect(result.config.channels?.bluebubbles?.enabled).toBe(true);
expect(result.config.plugins?.entries?.imessage?.enabled).toBeUndefined();
expect(result.changes.join("\n")).toContain("bluebubbles configured, enabled automatically.");
expect(result.changes.join("\n")).toContain("BlueBubbles configured, enabled automatically.");
expect(result.changes.join("\n")).not.toContain(
"iMessage configured, enabled automatically.",
);
@@ -874,7 +874,7 @@ describe("applyPluginAutoEnable", () => {
plugins: { entries: { imessage: { enabled: true } } },
});
expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(true);
expect(result.config.channels?.bluebubbles?.enabled).toBe(true);
expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true);
});

View File

@@ -6,13 +6,9 @@ import {
listPotentialConfiguredChannelIds,
} from "../channels/config-presence.js";
import { getChatChannelMeta, normalizeChatChannelId } from "../channels/registry.js";
import {
BUNDLED_AUTO_ENABLE_PROVIDER_PLUGIN_IDS,
BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS,
} from "../plugins/bundled-capability-metadata.js";
import { resolveBundledWebFetchPluginId } from "../plugins/bundled-web-fetch-provider-ids.js";
import {
loadPluginManifestRegistry,
resolveManifestContractOwnerPluginId,
type PluginManifestRegistry,
} from "../plugins/manifest-registry.js";
import { resolveOwningPluginIdsForModelRef } from "../plugins/providers.js";
@@ -81,7 +77,7 @@ const ENV_CATALOG_PATHS = ["OPENCLAW_PLUGIN_CATALOG_PATHS", "OPENCLAW_MPM_CATALO
function resolveAutoEnableProviderPluginIds(
registry: PluginManifestRegistry,
): Readonly<Record<string, string>> {
const entries = new Map<string, string>(Object.entries(BUNDLED_AUTO_ENABLE_PROVIDER_PLUGIN_IDS));
const entries = new Map<string, string>();
for (const plugin of registry.plugins) {
for (const providerId of plugin.autoEnableWhenConfiguredProviders ?? []) {
if (!entries.has(providerId)) {
@@ -214,11 +210,7 @@ function hasPluginOwnedToolConfig(cfg: OpenClawConfig, pluginId: string): boolea
function resolveProviderPluginsWithOwnedWebSearch(
registry: PluginManifestRegistry,
): ReadonlySet<string> {
const pluginIds = new Set(
BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter(
(entry) => entry.providerIds.length > 0 && entry.webSearchProviderIds.length > 0,
).map((entry) => entry.pluginId),
);
const pluginIds = new Set<string>();
for (const plugin of registry.plugins) {
if (plugin.providers.length > 0 && (plugin.contracts?.webSearchProviders?.length ?? 0) > 0) {
pluginIds.add(plugin.id);
@@ -227,26 +219,25 @@ function resolveProviderPluginsWithOwnedWebSearch(
return pluginIds;
}
const BUNDLED_WEB_FETCH_OWNER_PLUGIN_IDS = new Set(
BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter((entry) => entry.webFetchProviderIds.length > 0).map(
(entry) => entry.pluginId,
),
);
function resolveProviderPluginsWithOwnedWebFetch(): ReadonlySet<string> {
function resolveProviderPluginsWithOwnedWebFetch(
registry: PluginManifestRegistry,
): ReadonlySet<string> {
return new Set(
BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter((entry) => entry.webFetchProviderIds.length > 0).map(
(entry) => entry.pluginId,
),
registry.plugins
.filter((plugin) => (plugin.contracts?.webFetchProviders?.length ?? 0) > 0)
.map((plugin) => plugin.id),
);
}
function resolvePluginIdForConfiguredWebFetchProvider(
providerId: string | undefined,
): string | undefined {
return resolveBundledWebFetchPluginId(
typeof providerId === "string" ? providerId.trim().toLowerCase() : "",
);
return resolveManifestContractOwnerPluginId({
contract: "webFetchProviders",
value: typeof providerId === "string" ? providerId.trim().toLowerCase() : "",
origin: "bundled",
env: process.env,
});
}
function buildChannelToPluginIdMap(registry: PluginManifestRegistry): Map<string, string> {
@@ -378,16 +369,19 @@ function hasConfiguredWebFetchPluginEntry(cfg: OpenClawConfig): boolean {
if (!entries || typeof entries !== "object") {
return false;
}
return Object.entries(entries).some(
([pluginId, entry]) =>
BUNDLED_WEB_FETCH_OWNER_PLUGIN_IDS.has(pluginId) &&
isRecord(entry) &&
isRecord(entry.config) &&
isRecord(entry.config.webFetch),
return Object.values(entries).some(
(entry) => isRecord(entry) && isRecord(entry.config) && isRecord(entry.config.webFetch),
);
}
function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig): boolean {
const pluginEntries = cfg.plugins?.entries;
if (
pluginEntries &&
Object.values(pluginEntries).some((entry) => isRecord(entry) && isRecord(entry.config))
) {
return true;
}
if (cfg.auth?.profiles && Object.keys(cfg.auth.profiles).length > 0) {
return true;
}
@@ -601,7 +595,7 @@ function resolveConfiguredPlugins(
});
}
}
for (const pluginId of resolveProviderPluginsWithOwnedWebFetch()) {
for (const pluginId of resolveProviderPluginsWithOwnedWebFetch(registry)) {
if (hasPluginOwnedWebFetchConfig(cfg, pluginId)) {
changes.push({
pluginId,

View File

@@ -2,13 +2,15 @@ import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/ids.js";
import { withBundledPluginAllowlistCompat } from "../plugins/bundled-compat.js";
import { listBundledWebSearchPluginIds } from "../plugins/bundled-web-search-ids.js";
import {
normalizePluginsConfig,
resolveEffectivePluginActivationState,
resolveMemorySlotDecision,
} from "../plugins/config-state.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import {
loadPluginManifestRegistry,
resolveManifestContractPluginIds,
} from "../plugins/manifest-registry.js";
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
import { hasKind } from "../plugins/slots.js";
import { collectUnsupportedSecretRefConfigCandidates } from "../secrets/unsupported-surface-policy.js";
@@ -582,6 +584,31 @@ function validateConfigObjectWithPluginsBase(
let registryInfo: RegistryInfo | null = null;
let compatConfig: OpenClawConfig | null | undefined;
let compatPluginIds: ReadonlySet<string> | null = null;
let compatPluginIdsResolved = false;
const ensureCompatPluginIds = (): ReadonlySet<string> => {
if (compatPluginIdsResolved) {
return compatPluginIds ?? new Set<string>();
}
compatPluginIdsResolved = true;
const allow = config.plugins?.allow;
if (!Array.isArray(allow) || allow.length === 0) {
compatPluginIds = new Set<string>();
return compatPluginIds;
}
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
compatPluginIds = new Set(
resolveManifestContractPluginIds({
contract: "webSearchProviders",
origin: "bundled",
config,
workspaceDir: workspaceDir ?? undefined,
env: opts.env,
}),
);
return compatPluginIds;
};
const ensureCompatConfig = (): OpenClawConfig => {
if (compatConfig !== undefined) {
@@ -594,27 +621,9 @@ function validateConfigObjectWithPluginsBase(
return config;
}
const bundledWebSearchPluginIds = new Set(listBundledWebSearchPluginIds());
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const seenCompatPluginIds = new Set<string>();
const compatPluginIds = loadPluginManifestRegistry({
config,
workspaceDir: workspaceDir ?? undefined,
env: opts.env,
})
.plugins.filter((plugin) => {
if (seenCompatPluginIds.has(plugin.id)) {
return false;
}
seenCompatPluginIds.add(plugin.id);
return plugin.origin === "bundled" && bundledWebSearchPluginIds.has(plugin.id);
})
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
compatConfig = withBundledPluginAllowlistCompat({
config,
pluginIds: compatPluginIds,
pluginIds: [...ensureCompatPluginIds()],
});
return compatConfig ?? config;
};
@@ -978,7 +987,7 @@ function validateConfigObjectWithPluginsBase(
}
}
if (!enabled && entryHasConfig) {
if (!enabled && entryHasConfig && !ensureCompatPluginIds().has(pluginId)) {
warnings.push({
path: `plugins.entries.${pluginId}`,
message: `plugin disabled (${reason ?? "disabled"}) but config is present`,

View File

@@ -1 +0,0 @@
export { resolveBundledWebFetchPluginIds as listBundledWebFetchPluginIds } from "./bundled-web-fetch.js";

View File

@@ -1 +0,0 @@
export { resolveBundledWebFetchPluginId } from "./bundled-web-fetch.js";

View File

@@ -1,92 +0,0 @@
import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js";
import type { PluginLoadOptions } from "./loader.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import type { PluginWebFetchProviderEntry } from "./types.js";
type BundledWebFetchProviderEntry = PluginWebFetchProviderEntry & { pluginId: string };
const bundledWebFetchProvidersCache = new Map<string, BundledWebFetchProviderEntry[]>();
function resolveBundledWebFetchManifestPlugins(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}) {
return loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}).plugins.filter(
(plugin) =>
plugin.origin === "bundled" && (plugin.contracts?.webFetchProviders?.length ?? 0) > 0,
);
}
function loadBundledWebFetchProviders(params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): BundledWebFetchProviderEntry[] {
const pluginIds = resolveBundledWebFetchPluginIds(params ?? {});
const cacheKey = pluginIds.join("\u0000");
const cached = bundledWebFetchProvidersCache.get(cacheKey);
if (cached) {
return cached;
}
const providers =
pluginIds.length === 0
? []
: loadBundledCapabilityRuntimeRegistry({
pluginIds,
pluginSdkResolution: "dist",
}).webFetchProviders.map((entry) => ({
pluginId: entry.pluginId,
...entry.provider,
}));
bundledWebFetchProvidersCache.set(cacheKey, providers);
return providers;
}
export function resolveBundledWebFetchPluginIds(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): string[] {
return resolveBundledWebFetchManifestPlugins(params)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
export function listBundledWebFetchProviders(params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): PluginWebFetchProviderEntry[] {
return loadBundledWebFetchProviders(params);
}
export function resolveBundledWebFetchPluginId(
providerId: string | undefined,
params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
},
): string | undefined {
if (!providerId) {
return undefined;
}
const normalizedProviderId = providerId.trim().toLowerCase();
if (!normalizedProviderId) {
return undefined;
}
return resolveBundledWebFetchManifestPlugins({
config: params?.config,
workspaceDir: params?.workspaceDir,
env: params?.env,
}).find((plugin) =>
plugin.contracts?.webFetchProviders?.some(
(candidate) => candidate.trim().toLowerCase() === normalizedProviderId,
),
)?.id;
}

View File

@@ -1,7 +0,0 @@
import { listBundledWebSearchPluginIds as listBundledWebSearchPluginIdsImpl } from "./bundled-web-search.js";
export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = listBundledWebSearchPluginIdsImpl();
export function listBundledWebSearchPluginIds(): string[] {
return listBundledWebSearchPluginIdsImpl();
}

View File

@@ -1 +0,0 @@
export { resolveBundledWebSearchPluginId } from "./bundled-web-search.js";

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveBundledPluginWebSearchProviders } from "./web-search-providers.js";
import { resolvePluginWebSearchProviders } from "./web-search-providers.runtime.js";
function hasConfiguredCredentialValue(value: unknown): boolean {
if (typeof value === "string") {
@@ -16,10 +16,11 @@ export function hasBundledWebSearchCredential(params: {
const searchConfig =
params.searchConfig ??
(params.config.tools?.web?.search as Record<string, unknown> | undefined);
return resolveBundledPluginWebSearchProviders({
return resolvePluginWebSearchProviders({
config: params.config,
env: params.env,
bundledAllowlistCompat: true,
origin: "bundled",
}).some((provider) => {
const configuredCredential =
provider.getConfiguredCredentialValue?.(params.config) ??

View File

@@ -1,220 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js";
import { hasBundledWebSearchCredential } from "./bundled-web-search-registry.js";
import {
listBundledWebSearchPluginIds,
listBundledWebSearchProviders,
resolveBundledWebSearchPluginId,
resolveBundledWebSearchPluginIds,
} from "./bundled-web-search.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
vi.mock("./manifest-registry.js", () => ({
loadPluginManifestRegistry: vi.fn(),
}));
vi.mock("./bundled-capability-runtime.js", () => ({
loadBundledCapabilityRuntimeRegistry: vi.fn(),
}));
const resolveBundledPluginWebSearchProvidersMock = vi.hoisted(() => vi.fn());
vi.mock("./web-search-providers.js", () => ({
resolveBundledPluginWebSearchProviders: resolveBundledPluginWebSearchProvidersMock,
}));
function createMockedBundledWebSearchProvider(params: {
pluginId: string;
providerId: string;
configuredCredential?: unknown;
scopedCredential?: unknown;
envVars?: string[];
}) {
return {
pluginId: params.pluginId,
id: params.providerId,
label: params.providerId,
hint: `${params.providerId} provider`,
envVars: params.envVars ?? [],
placeholder: `${params.providerId}-key`,
signupUrl: `https://example.com/${params.providerId}`,
autoDetectOrder: 10,
credentialPath: `plugins.entries.${params.pluginId}.config.webSearch.apiKey`,
getCredentialValue: () => params.scopedCredential,
getConfiguredCredentialValue: () => params.configuredCredential,
setCredentialValue: () => {},
createTool: () => ({
description: params.providerId,
parameters: {},
execute: async () => ({}),
}),
};
}
describe("bundled web search helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(loadPluginManifestRegistry).mockReturnValue({
plugins: [
{ id: "xai", origin: "bundled", contracts: { webSearchProviders: ["grok"] } },
{ id: "google", origin: "bundled", contracts: { webSearchProviders: ["gemini"] } },
{ id: "minimax", origin: "bundled", contracts: { webSearchProviders: ["minimax"] } },
{ id: "noise", origin: "bundled" },
{ id: "external-google", origin: "workspace" },
] as never[],
diagnostics: [],
});
vi.mocked(loadBundledCapabilityRuntimeRegistry).mockReturnValue({
webSearchProviders: [
{
pluginId: "minimax",
provider: createMockedBundledWebSearchProvider({
pluginId: "minimax",
providerId: "minimax",
}),
},
{
pluginId: "xai",
provider: createMockedBundledWebSearchProvider({
pluginId: "xai",
providerId: "grok",
}),
},
{
pluginId: "google",
provider: createMockedBundledWebSearchProvider({
pluginId: "google",
providerId: "gemini",
}),
},
],
} as never);
});
it("returns bundled manifest-derived web search plugins from the registry", () => {
expect(
resolveBundledWebSearchPluginIds({
config: {
plugins: {
allow: ["google", "xai"],
},
},
workspaceDir: "/tmp/workspace",
env: { OPENCLAW_HOME: "/tmp/openclaw-home" },
}),
).toEqual(["google", "minimax", "xai"]);
expect(loadPluginManifestRegistry).toHaveBeenCalledWith({
config: {
plugins: {
allow: ["google", "xai"],
},
},
workspaceDir: "/tmp/workspace",
env: { OPENCLAW_HOME: "/tmp/openclaw-home" },
});
});
it("returns a copy of the bundled plugin id fast-path list", () => {
const listed = listBundledWebSearchPluginIds();
expect(listed).toEqual(["google", "minimax", "xai"]);
expect(listed).not.toBe(listBundledWebSearchPluginIds());
});
it("maps bundled provider ids back to their owning plugins", () => {
expect(resolveBundledWebSearchPluginId(" gemini ")).toBe("google");
expect(resolveBundledWebSearchPluginId(" minimax ")).toBe("minimax");
expect(resolveBundledWebSearchPluginId("missing")).toBeUndefined();
});
it("loads bundled provider entries through the capability runtime registry once", () => {
expect(listBundledWebSearchProviders()).toEqual([
expect.objectContaining({ pluginId: "minimax", id: "minimax" }),
expect.objectContaining({ pluginId: "xai", id: "grok" }),
expect.objectContaining({ pluginId: "google", id: "gemini" }),
]);
expect(listBundledWebSearchProviders()).toEqual([
expect.objectContaining({ pluginId: "minimax", id: "minimax" }),
expect.objectContaining({ pluginId: "xai", id: "grok" }),
expect.objectContaining({ pluginId: "google", id: "gemini" }),
]);
expect(loadBundledCapabilityRuntimeRegistry).toHaveBeenCalledTimes(1);
expect(loadBundledCapabilityRuntimeRegistry).toHaveBeenCalledWith({
pluginIds: ["google", "minimax", "xai"],
pluginSdkResolution: "dist",
});
});
});
describe("hasBundledWebSearchCredential", () => {
const baseCfg = {
agents: { defaults: { model: { primary: "ollama/mistral-8b" } } },
browser: { enabled: false },
tools: { web: { fetch: { enabled: false } } },
} satisfies OpenClawConfig;
beforeEach(() => {
resolveBundledPluginWebSearchProvidersMock.mockReset();
});
it.each([
{
name: "detects configured plugin credentials",
providers: [
createMockedBundledWebSearchProvider({
pluginId: "google",
providerId: "gemini",
configuredCredential: "AIza-test",
}),
],
config: baseCfg,
env: {},
},
{
name: "detects scoped tool credentials",
providers: [
createMockedBundledWebSearchProvider({
pluginId: "google",
providerId: "gemini",
scopedCredential: "AIza-test",
}),
],
config: baseCfg,
env: {},
searchConfig: { provider: "gemini" },
},
{
name: "detects env credentials",
providers: [
createMockedBundledWebSearchProvider({
pluginId: "xai",
providerId: "grok",
envVars: ["XAI_API_KEY"],
}),
],
config: baseCfg,
env: { XAI_API_KEY: "xai-test" },
},
] as const)("$name", ({ providers, config, env, searchConfig }) => {
resolveBundledPluginWebSearchProvidersMock.mockReturnValue(providers);
expect(hasBundledWebSearchCredential({ config, env, searchConfig })).toBe(true);
expect(resolveBundledPluginWebSearchProvidersMock).toHaveBeenCalledWith({
config,
env,
bundledAllowlistCompat: true,
});
});
it("returns false when no bundled provider exposes a configured credential", () => {
resolveBundledPluginWebSearchProvidersMock.mockReturnValue([
createMockedBundledWebSearchProvider({
pluginId: "google",
providerId: "gemini",
envVars: ["GEMINI_API_KEY"],
}),
]);
expect(hasBundledWebSearchCredential({ config: baseCfg, env: {} })).toBe(false);
});
});

View File

@@ -1,100 +0,0 @@
import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js";
import type { PluginLoadOptions } from "./loader.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import type { PluginWebSearchProviderEntry } from "./types.js";
type BundledWebSearchProviderEntry = PluginWebSearchProviderEntry & { pluginId: string };
const bundledWebSearchProvidersCache = new Map<string, BundledWebSearchProviderEntry[]>();
function resolveBundledWebSearchManifestPlugins(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}) {
return loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}).plugins.filter(
(plugin) =>
plugin.origin === "bundled" && (plugin.contracts?.webSearchProviders?.length ?? 0) > 0,
);
}
function loadBundledWebSearchProviders(params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): BundledWebSearchProviderEntry[] {
const pluginIds = resolveBundledWebSearchPluginIds(params ?? {});
const cacheKey = pluginIds.join("\u0000");
const cached = bundledWebSearchProvidersCache.get(cacheKey);
if (cached) {
return cached;
}
const providers =
pluginIds.length === 0
? []
: loadBundledCapabilityRuntimeRegistry({
pluginIds,
pluginSdkResolution: "dist",
}).webSearchProviders.map((entry) => ({
pluginId: entry.pluginId,
...entry.provider,
}));
bundledWebSearchProvidersCache.set(cacheKey, providers);
return providers;
}
export function resolveBundledWebSearchPluginIds(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): string[] {
return resolveBundledWebSearchManifestPlugins(params)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
export function listBundledWebSearchPluginIds(params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): string[] {
return resolveBundledWebSearchPluginIds(params ?? {});
}
export function listBundledWebSearchProviders(params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): PluginWebSearchProviderEntry[] {
return loadBundledWebSearchProviders(params);
}
export function resolveBundledWebSearchPluginId(
providerId: string | undefined,
params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
},
): string | undefined {
if (!providerId) {
return undefined;
}
const normalizedProviderId = providerId.trim().toLowerCase();
if (!normalizedProviderId) {
return undefined;
}
return resolveBundledWebSearchManifestPlugins({
config: params?.config,
workspaceDir: params?.workspaceDir,
env: params?.env,
}).find((plugin) =>
plugin.contracts?.webSearchProviders?.some(
(candidate) => candidate.trim().toLowerCase() === normalizedProviderId,
),
)?.id;
}

View File

@@ -1,9 +1,11 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { withBundledPluginAllowlistCompat } from "../bundled-compat.js";
import { resolveBundledWebSearchPluginIds } from "../bundled-web-search.js";
import { loadPluginManifestRegistry } from "../manifest-registry.js";
import {
loadPluginManifestRegistry,
resolveManifestContractPluginIds,
} from "../manifest-registry.js";
import { __testing as providerTesting } from "../providers.js";
import { resolveBundledPluginWebSearchProviders } from "../web-search-providers.js";
import { resolvePluginWebSearchProviders } from "../web-search-providers.runtime.js";
import { providerContractCompatPluginIds } from "./registry.js";
import { uniqueSortedStrings } from "./testkit.js";
@@ -66,9 +68,14 @@ describe("plugin loader contract", () => {
env: { VITEST: "1" } as NodeJS.ProcessEnv,
});
webSearchPluginIds = uniqueSortedStrings(
resolveBundledPluginWebSearchProviders({}).map((entry) => entry.pluginId),
resolvePluginWebSearchProviders({ origin: "bundled" }).map((entry) => entry.pluginId),
);
bundledWebSearchPluginIds = uniqueSortedStrings(
resolveManifestContractPluginIds({
contract: "webSearchProviders",
origin: "bundled",
}),
);
bundledWebSearchPluginIds = uniqueSortedStrings(resolveBundledWebSearchPluginIds({}));
webSearchAllowlistCompatConfig = createAllowlistCompatConfig(webSearchPluginIds);
});

View File

@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import { resolveBundledWebFetchPluginIds } from "../bundled-web-fetch.js";
import { resolveBundledWebSearchPluginIds } from "../bundled-web-search.js";
import { loadPluginManifestRegistry } from "../manifest-registry.js";
import {
loadPluginManifestRegistry,
resolveManifestContractPluginIds,
} from "../manifest-registry.js";
import {
imageGenerationProviderContractRegistry,
mediaUnderstandingProviderContractRegistry,
@@ -137,7 +138,10 @@ describe("plugin contract registry", () => {
});
it("covers every bundled web fetch plugin from the shared resolver", () => {
const bundledWebFetchPluginIds = resolveBundledWebFetchPluginIds({});
const bundledWebFetchPluginIds = resolveManifestContractPluginIds({
contract: "webFetchProviders",
origin: "bundled",
});
expect(
uniqueSortedStrings(
@@ -152,7 +156,10 @@ describe("plugin contract registry", () => {
"loads bundled web fetch providers for each shared-resolver plugin",
{ timeout: REGISTRY_CONTRACT_TIMEOUT_MS },
() => {
for (const pluginId of resolveBundledWebFetchPluginIds({})) {
for (const pluginId of resolveManifestContractPluginIds({
contract: "webFetchProviders",
origin: "bundled",
})) {
expect(resolveWebFetchProviderContractEntriesForPluginId(pluginId).length).toBeGreaterThan(
0,
);
@@ -162,7 +169,10 @@ describe("plugin contract registry", () => {
);
it("covers every bundled web search plugin from the shared resolver", () => {
const bundledWebSearchPluginIds = resolveBundledWebSearchPluginIds({});
const bundledWebSearchPluginIds = resolveManifestContractPluginIds({
contract: "webSearchProviders",
origin: "bundled",
});
expect(
uniqueSortedStrings(
@@ -177,7 +187,10 @@ describe("plugin contract registry", () => {
"loads bundled web search providers for each shared-resolver plugin",
{ timeout: REGISTRY_CONTRACT_TIMEOUT_MS },
() => {
for (const pluginId of resolveBundledWebSearchPluginIds({})) {
for (const pluginId of resolveManifestContractPluginIds({
contract: "webSearchProviders",
origin: "bundled",
})) {
expect(resolveWebSearchProviderContractEntriesForPluginId(pluginId).length).toBeGreaterThan(
0,
);

View File

@@ -29,6 +29,8 @@ import type {
PluginOrigin,
} from "./types.js";
type PluginManifestContractListKey = "webFetchProviders" | "webSearchProviders";
type SeenIdEntry = {
candidate: PluginCandidate;
recordIndex: number;
@@ -98,6 +100,63 @@ export function clearPluginManifestRegistryCache(): void {
registryCache.clear();
}
function listContractValues(
plugin: PluginManifestRecord,
contract: PluginManifestContractListKey,
): readonly string[] {
return plugin.contracts?.[contract] ?? [];
}
export function resolveManifestContractPluginIds(params: {
contract: PluginManifestContractListKey;
origin?: PluginOrigin;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
onlyPluginIds?: readonly string[];
}): string[] {
const onlyPluginIdSet =
params.onlyPluginIds && params.onlyPluginIds.length > 0 ? new Set(params.onlyPluginIds) : null;
return loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})
.plugins.filter(
(plugin) =>
(!params.origin || plugin.origin === params.origin) &&
(!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)) &&
listContractValues(plugin, params.contract).length > 0,
)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
export function resolveManifestContractOwnerPluginId(params: {
contract: PluginManifestContractListKey;
value: string | undefined;
origin?: PluginOrigin;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): string | undefined {
const normalizedValue = params.value?.trim().toLowerCase();
if (!normalizedValue) {
return undefined;
}
return loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}).plugins.find(
(plugin) =>
(!params.origin || plugin.origin === params.origin) &&
listContractValues(plugin, params.contract).some(
(candidate) => candidate.trim().toLowerCase() === normalizedValue,
),
)?.id;
}
function resolveManifestCacheMs(env: NodeJS.ProcessEnv): number {
const raw = env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS?.trim();
if (raw === "" || raw === "0") {

View File

@@ -1,6 +1,7 @@
import type { OpenClawConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { isRecord } from "../utils.js";
import { withActivatedPluginIds } from "./activation-context.js";
import {
buildPluginSnapshotCacheEnvKey,
resolvePluginSnapshotCacheTtlMs,
@@ -13,7 +14,11 @@ import {
} from "./loader.js";
import type { PluginLoadOptions } from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js";
import {
loadPluginManifestRegistry,
resolveManifestContractPluginIds,
type PluginManifestRecord,
} from "./manifest-registry.js";
import type { PluginWebFetchProviderEntry } from "./types.js";
import {
resolveBundledWebFetchResolutionConfig,
@@ -46,11 +51,13 @@ function buildWebFetchSnapshotCacheKey(params: {
workspaceDir?: string;
bundledAllowlistCompat?: boolean;
onlyPluginIds?: readonly string[];
origin?: PluginManifestRecord["origin"];
env: NodeJS.ProcessEnv;
}): string {
return JSON.stringify({
workspaceDir: params.workspaceDir ?? "",
bundledAllowlistCompat: params.bundledAllowlistCompat === true,
origin: params.origin ?? "",
onlyPluginIds: [...new Set(params.onlyPluginIds ?? [])].toSorted((left, right) =>
left.localeCompare(right),
),
@@ -79,19 +86,30 @@ function resolveWebFetchCandidatePluginIds(params: {
workspaceDir?: string;
env?: PluginLoadOptions["env"];
onlyPluginIds?: readonly string[];
origin?: PluginManifestRecord["origin"];
}): string[] | undefined {
const registry = loadPluginManifestRegistry({
const contractIds = new Set(
resolveManifestContractPluginIds({
contract: "webFetchProviders",
origin: params.origin,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
onlyPluginIds: params.onlyPluginIds,
}),
);
const onlyPluginIdSet =
params.onlyPluginIds && params.onlyPluginIds.length > 0 ? new Set(params.onlyPluginIds) : null;
const ids = loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const onlyPluginIdSet =
params.onlyPluginIds && params.onlyPluginIds.length > 0 ? new Set(params.onlyPluginIds) : null;
const ids = registry.plugins
.filter(
})
.plugins.filter(
(plugin) =>
pluginManifestDeclaresWebFetch(plugin) &&
(!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)),
(!params.origin || plugin.origin === params.origin) &&
(!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)) &&
(contractIds.has(plugin.id) || pluginManifestDeclaresWebFetch(plugin)),
)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
@@ -106,6 +124,7 @@ function resolveWebFetchLoadOptions(params: {
onlyPluginIds?: readonly string[];
activate?: boolean;
cache?: boolean;
origin?: PluginManifestRecord["origin"];
}) {
const env = params.env ?? process.env;
const { config, activationSourceConfig, autoEnabledReasons } =
@@ -118,6 +137,7 @@ function resolveWebFetchLoadOptions(params: {
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: params.onlyPluginIds,
origin: params.origin,
});
return {
env,
@@ -156,8 +176,38 @@ export function resolvePluginWebFetchProviders(params: {
onlyPluginIds?: readonly string[];
activate?: boolean;
cache?: boolean;
mode?: "runtime" | "setup";
origin?: PluginManifestRecord["origin"];
}): PluginWebFetchProviderEntry[] {
const env = params.env ?? process.env;
if (params.mode === "setup") {
const pluginIds =
resolveWebFetchCandidatePluginIds({
config: params.config,
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: params.onlyPluginIds,
origin: params.origin,
}) ?? [];
if (pluginIds.length === 0) {
return [];
}
const registry = loadOpenClawPlugins({
config: withActivatedPluginIds({
config: params.config,
pluginIds,
}),
activationSourceConfig: params.config,
autoEnabledReasons: {},
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: pluginIds,
cache: params.cache ?? false,
activate: params.activate ?? false,
logger: createPluginLoaderLogger(log),
});
return mapRegistryWebFetchProviders({ registry, onlyPluginIds: pluginIds });
}
const cacheOwnerConfig = params.config;
const shouldMemoizeSnapshot =
params.activate !== true && params.cache !== true && shouldUsePluginSnapshotCache(env);
@@ -166,6 +216,7 @@ export function resolvePluginWebFetchProviders(params: {
workspaceDir: params.workspaceDir,
bundledAllowlistCompat: params.bundledAllowlistCompat,
onlyPluginIds: params.onlyPluginIds,
origin: params.origin,
env,
});
if (cacheOwnerConfig && shouldMemoizeSnapshot) {
@@ -212,6 +263,7 @@ export function resolveRuntimeWebFetchProviders(params: {
env?: PluginLoadOptions["env"];
bundledAllowlistCompat?: boolean;
onlyPluginIds?: readonly string[];
origin?: PluginManifestRecord["origin"];
}): PluginWebFetchProviderEntry[] {
const runtimeRegistry = resolveRuntimePluginRegistry(
params.config === undefined ? undefined : resolveWebFetchLoadOptions(params),

View File

@@ -1,7 +1,7 @@
import { resolveBundledPluginCompatibleActivationInputs } from "./activation-context.js";
import { resolveBundledWebFetchPluginIds } from "./bundled-web-fetch.js";
import { type NormalizedPluginsConfig } from "./config-state.js";
import type { PluginLoadOptions } from "./loader.js";
import { resolveManifestContractPluginIds } from "./manifest-registry.js";
import type { PluginWebFetchProviderEntry } from "./types.js";
function resolveBundledWebFetchCompatPluginIds(params: {
@@ -9,7 +9,9 @@ function resolveBundledWebFetchCompatPluginIds(params: {
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): string[] {
return resolveBundledWebFetchPluginIds({
return resolveManifestContractPluginIds({
contract: "webFetchProviders",
origin: "bundled",
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,

View File

@@ -1,36 +0,0 @@
import { listBundledWebFetchProviders as listBundledWebFetchProviderEntries } from "./bundled-web-fetch.js";
import { resolveEffectiveEnableState } from "./config-state.js";
import type { PluginLoadOptions } from "./loader.js";
import type { PluginWebFetchProviderEntry } from "./types.js";
import {
resolveBundledWebFetchResolutionConfig,
sortWebFetchProviders,
} from "./web-fetch-providers.shared.js";
function listBundledWebFetchProviders(): PluginWebFetchProviderEntry[] {
return sortWebFetchProviders(listBundledWebFetchProviderEntries());
}
export function resolveBundledPluginWebFetchProviders(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
bundledAllowlistCompat?: boolean;
onlyPluginIds?: readonly string[];
}): PluginWebFetchProviderEntry[] {
const { config, normalized } = resolveBundledWebFetchResolutionConfig(params);
const onlyPluginIdSet =
params.onlyPluginIds && params.onlyPluginIds.length > 0 ? new Set(params.onlyPluginIds) : null;
return listBundledWebFetchProviders().filter((provider) => {
if (onlyPluginIdSet && !onlyPluginIdSet.has(provider.pluginId)) {
return false;
}
return resolveEffectiveEnableState({
id: provider.pluginId,
origin: "bundled",
config: normalized,
rootConfig: config,
}).enabled;
});
}

View File

@@ -14,7 +14,11 @@ import {
} from "./loader.js";
import type { PluginLoadOptions } from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js";
import {
loadPluginManifestRegistry,
resolveManifestContractPluginIds,
type PluginManifestRecord,
} from "./manifest-registry.js";
import type { PluginWebSearchProviderEntry } from "./types.js";
import {
resolveBundledWebSearchResolutionConfig,
@@ -46,11 +50,13 @@ function buildWebSearchSnapshotCacheKey(params: {
workspaceDir?: string;
bundledAllowlistCompat?: boolean;
onlyPluginIds?: readonly string[];
origin?: PluginManifestRecord["origin"];
env: NodeJS.ProcessEnv;
}): string {
return JSON.stringify({
workspaceDir: params.workspaceDir ?? "",
bundledAllowlistCompat: params.bundledAllowlistCompat === true,
origin: params.origin ?? "",
onlyPluginIds: [...new Set(params.onlyPluginIds ?? [])].toSorted((left, right) =>
left.localeCompare(right),
),
@@ -79,19 +85,30 @@ function resolveWebSearchCandidatePluginIds(params: {
workspaceDir?: string;
env?: PluginLoadOptions["env"];
onlyPluginIds?: readonly string[];
origin?: PluginManifestRecord["origin"];
}): string[] | undefined {
const registry = loadPluginManifestRegistry({
const contractIds = new Set(
resolveManifestContractPluginIds({
contract: "webSearchProviders",
origin: params.origin,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
onlyPluginIds: params.onlyPluginIds,
}),
);
const onlyPluginIdSet =
params.onlyPluginIds && params.onlyPluginIds.length > 0 ? new Set(params.onlyPluginIds) : null;
const ids = loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const onlyPluginIdSet =
params.onlyPluginIds && params.onlyPluginIds.length > 0 ? new Set(params.onlyPluginIds) : null;
const ids = registry.plugins
.filter(
})
.plugins.filter(
(plugin) =>
pluginManifestDeclaresWebSearch(plugin) &&
(!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)),
(!params.origin || plugin.origin === params.origin) &&
(!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)) &&
(contractIds.has(plugin.id) || pluginManifestDeclaresWebSearch(plugin)),
)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
@@ -106,6 +123,7 @@ function resolveWebSearchLoadOptions(params: {
onlyPluginIds?: readonly string[];
activate?: boolean;
cache?: boolean;
origin?: PluginManifestRecord["origin"];
}) {
const env = params.env ?? process.env;
const { config, activationSourceConfig, autoEnabledReasons } =
@@ -118,6 +136,7 @@ function resolveWebSearchLoadOptions(params: {
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: params.onlyPluginIds,
origin: params.origin,
});
return {
env,
@@ -157,6 +176,7 @@ export function resolvePluginWebSearchProviders(params: {
activate?: boolean;
cache?: boolean;
mode?: "runtime" | "setup";
origin?: PluginManifestRecord["origin"];
}): PluginWebSearchProviderEntry[] {
const env = params.env ?? process.env;
if (params.mode === "setup") {
@@ -166,6 +186,7 @@ export function resolvePluginWebSearchProviders(params: {
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: params.onlyPluginIds,
origin: params.origin,
}) ?? [];
if (pluginIds.length === 0) {
return [];
@@ -194,6 +215,7 @@ export function resolvePluginWebSearchProviders(params: {
workspaceDir: params.workspaceDir,
bundledAllowlistCompat: params.bundledAllowlistCompat,
onlyPluginIds: params.onlyPluginIds,
origin: params.origin,
env,
});
if (cacheOwnerConfig && shouldMemoizeSnapshot) {
@@ -240,6 +262,7 @@ export function resolveRuntimeWebSearchProviders(params: {
env?: PluginLoadOptions["env"];
bundledAllowlistCompat?: boolean;
onlyPluginIds?: readonly string[];
origin?: PluginManifestRecord["origin"];
}): PluginWebSearchProviderEntry[] {
const runtimeRegistry = resolveRuntimePluginRegistry(
params.config === undefined ? undefined : resolveWebSearchLoadOptions(params),

View File

@@ -1,7 +1,7 @@
import { resolveBundledPluginCompatibleActivationInputs } from "./activation-context.js";
import { resolveBundledWebSearchPluginIds } from "./bundled-web-search.js";
import { type NormalizedPluginsConfig } from "./config-state.js";
import type { PluginLoadOptions } from "./loader.js";
import { resolveManifestContractPluginIds } from "./manifest-registry.js";
import type { PluginWebSearchProviderEntry } from "./types.js";
function resolveBundledWebSearchCompatPluginIds(params: {
@@ -9,7 +9,9 @@ function resolveBundledWebSearchCompatPluginIds(params: {
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): string[] {
return resolveBundledWebSearchPluginIds({
return resolveManifestContractPluginIds({
contract: "webSearchProviders",
origin: "bundled",
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,

View File

@@ -1,319 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginWebSearchProviderEntry } from "./types.js";
import { resolveBundledPluginWebSearchProviders } from "./web-search-providers.js";
const listBundledWebSearchProvidersMock = vi.hoisted(() => vi.fn());
const resolveBundledWebSearchPluginIdsMock = vi.hoisted(() => vi.fn());
vi.mock("./bundled-web-search.js", () => ({
listBundledWebSearchProviders: listBundledWebSearchProvidersMock,
resolveBundledWebSearchPluginIds: resolveBundledWebSearchPluginIdsMock,
}));
const EXPECTED_BUNDLED_WEB_SEARCH_PROVIDER_KEYS = [
"brave:brave",
"duckduckgo:duckduckgo",
"exa:exa",
"firecrawl:firecrawl",
"google:gemini",
"xai:grok",
"moonshot:kimi",
"perplexity:perplexity",
"searxng:searxng",
"tavily:tavily",
] as const;
const EXPECTED_BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS = [
"brave",
"duckduckgo",
"exa",
"firecrawl",
"google",
"xai",
"moonshot",
"perplexity",
"searxng",
"tavily",
] as const;
const EXPECTED_BUNDLED_WEB_SEARCH_CREDENTIAL_PATHS = [
"plugins.entries.brave.config.webSearch.apiKey",
"",
"plugins.entries.exa.config.webSearch.apiKey",
"plugins.entries.firecrawl.config.webSearch.apiKey",
"plugins.entries.google.config.webSearch.apiKey",
"plugins.entries.xai.config.webSearch.apiKey",
"plugins.entries.moonshot.config.webSearch.apiKey",
"plugins.entries.perplexity.config.webSearch.apiKey",
"plugins.entries.searxng.config.webSearch.baseUrl",
"plugins.entries.tavily.config.webSearch.apiKey",
] as const;
function createBundledWebSearchProviderEntry(params: {
pluginId: string;
providerId: string;
credentialPath: string;
order: number;
withApplySelectionConfig?: boolean;
withResolveRuntimeMetadata?: boolean;
}): PluginWebSearchProviderEntry {
return {
pluginId: params.pluginId,
id: params.providerId,
label: params.providerId,
hint: `${params.providerId} provider`,
envVars: [],
placeholder: `${params.providerId}-key`,
signupUrl: `https://example.com/${params.providerId}`,
autoDetectOrder: params.order,
credentialPath: params.credentialPath,
getCredentialValue: () => undefined,
setCredentialValue: () => {},
...(params.withApplySelectionConfig
? {
applySelectionConfig: () => ({
plugins: {
entries: {
[params.pluginId]: {
enabled: true,
},
},
},
}),
}
: {}),
...(params.withResolveRuntimeMetadata
? {
resolveRuntimeMetadata: () => ({
selectedProvider: params.providerId,
}),
}
: {}),
createTool: () => ({
description: params.providerId,
parameters: {},
execute: async () => ({}),
}),
};
}
const BUNDLED_WEB_SEARCH_PROVIDERS: PluginWebSearchProviderEntry[] = [
createBundledWebSearchProviderEntry({
pluginId: "duckduckgo",
providerId: "duckduckgo",
credentialPath: "",
order: 100,
}),
createBundledWebSearchProviderEntry({
pluginId: "moonshot",
providerId: "kimi",
credentialPath: "plugins.entries.moonshot.config.webSearch.apiKey",
order: 40,
}),
createBundledWebSearchProviderEntry({
pluginId: "brave",
providerId: "brave",
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
order: 10,
}),
createBundledWebSearchProviderEntry({
pluginId: "perplexity",
providerId: "perplexity",
credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey",
order: 50,
withResolveRuntimeMetadata: true,
}),
createBundledWebSearchProviderEntry({
pluginId: "firecrawl",
providerId: "firecrawl",
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
order: 60,
withApplySelectionConfig: true,
}),
createBundledWebSearchProviderEntry({
pluginId: "google",
providerId: "gemini",
credentialPath: "plugins.entries.google.config.webSearch.apiKey",
order: 20,
}),
createBundledWebSearchProviderEntry({
pluginId: "tavily",
providerId: "tavily",
credentialPath: "plugins.entries.tavily.config.webSearch.apiKey",
order: 80,
}),
createBundledWebSearchProviderEntry({
pluginId: "exa",
providerId: "exa",
credentialPath: "plugins.entries.exa.config.webSearch.apiKey",
order: 55,
}),
createBundledWebSearchProviderEntry({
pluginId: "searxng",
providerId: "searxng",
credentialPath: "plugins.entries.searxng.config.webSearch.baseUrl",
order: 70,
}),
createBundledWebSearchProviderEntry({
pluginId: "xai",
providerId: "grok",
credentialPath: "plugins.entries.xai.config.webSearch.apiKey",
order: 30,
}),
];
function toProviderKeys(
providers: ReturnType<typeof resolveBundledPluginWebSearchProviders>,
): string[] {
return providers.map((provider) => `${provider.pluginId}:${provider.id}`);
}
function expectBundledWebSearchProviders(
providers: ReturnType<typeof resolveBundledPluginWebSearchProviders>,
) {
expect(toProviderKeys(providers)).toEqual(EXPECTED_BUNDLED_WEB_SEARCH_PROVIDER_KEYS);
expect(providers.map((provider) => provider.credentialPath)).toEqual(
EXPECTED_BUNDLED_WEB_SEARCH_CREDENTIAL_PATHS,
);
}
function expectResolvedPluginIds(
providers: ReturnType<typeof resolveBundledPluginWebSearchProviders>,
expectedPluginIds: readonly string[],
) {
expect(providers.map((provider) => provider.pluginId)).toEqual(expectedPluginIds);
}
function expectResolvedPluginIdsExcluding(
providers: ReturnType<typeof resolveBundledPluginWebSearchProviders>,
unexpectedPluginIds: readonly string[],
) {
const pluginIds = providers.map((provider) => provider.pluginId);
for (const pluginId of unexpectedPluginIds) {
expect(pluginIds).not.toContain(pluginId);
}
}
function expectBundledWebSearchResolution(params: {
options?: Parameters<typeof resolveBundledPluginWebSearchProviders>[0];
expectedProviders?: "full";
expectedPluginIds?: readonly string[];
excludedPluginIds?: readonly string[];
}) {
const providers = resolveBundledPluginWebSearchProviders(params.options ?? {});
if (params.expectedProviders === "full") {
expectBundledWebSearchProviders(providers);
}
if (params.expectedPluginIds) {
expectResolvedPluginIds(providers, params.expectedPluginIds);
}
if (params.excludedPluginIds) {
expectResolvedPluginIdsExcluding(providers, params.excludedPluginIds);
}
}
describe("resolveBundledPluginWebSearchProviders", () => {
beforeEach(() => {
listBundledWebSearchProvidersMock.mockReset();
listBundledWebSearchProvidersMock.mockReturnValue(BUNDLED_WEB_SEARCH_PROVIDERS);
resolveBundledWebSearchPluginIdsMock.mockReset();
resolveBundledWebSearchPluginIdsMock.mockReturnValue([
...EXPECTED_BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS,
]);
});
it.each([
{
title: "returns bundled providers in alphabetical order",
options: {},
},
{
title: "can resolve bundled providers through the manifest-scoped loader path",
options: {
bundledAllowlistCompat: true,
},
},
] as const)("$title", ({ options }) => {
const providers = resolveBundledPluginWebSearchProviders(options);
expectBundledWebSearchProviders(providers);
expect(providers.find((provider) => provider.id === "firecrawl")?.applySelectionConfig).toEqual(
expect.any(Function),
);
expect(
providers.find((provider) => provider.id === "perplexity")?.resolveRuntimeMetadata,
).toEqual(expect.any(Function));
});
it.each([
{
title: "can augment restrictive allowlists for bundled compatibility",
params: {
config: {
plugins: {
allow: ["demo-other-plugin"],
},
},
bundledAllowlistCompat: true,
},
expectedPluginIds: EXPECTED_BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS,
},
{
title: "does not return bundled providers excluded by a restrictive allowlist without compat",
params: {
config: {
plugins: {
allow: ["demo-other-plugin"],
},
},
},
expectedPluginIds: [],
},
{
title: "returns no providers when plugins are globally disabled",
params: {
config: {
plugins: {
enabled: false,
},
},
},
expectedPluginIds: [],
},
{
title: "can scope bundled resolution to one plugin id",
params: {
config: {
tools: {
web: {
search: {
provider: "gemini",
},
},
},
},
bundledAllowlistCompat: true,
onlyPluginIds: ["google"],
},
expectedPluginIds: ["google"],
},
{
title: "preserves explicit bundled provider entry state",
params: {
config: {
plugins: {
entries: {
perplexity: { enabled: false },
},
},
},
},
excludedPluginIds: ["perplexity"],
},
])("$title", ({ params, expectedPluginIds, excludedPluginIds }) => {
expectBundledWebSearchResolution({
options: params,
expectedPluginIds,
excludedPluginIds,
});
});
});

View File

@@ -1,36 +0,0 @@
import { listBundledWebSearchProviders as listBundledWebSearchProviderEntries } from "./bundled-web-search.js";
import { resolveEffectivePluginActivationState } from "./config-state.js";
import type { PluginLoadOptions } from "./loader.js";
import type { PluginWebSearchProviderEntry } from "./types.js";
import {
resolveBundledWebSearchResolutionConfig,
sortWebSearchProviders,
} from "./web-search-providers.shared.js";
function listBundledWebSearchProviders(): PluginWebSearchProviderEntry[] {
return sortWebSearchProviders(listBundledWebSearchProviderEntries());
}
export function resolveBundledPluginWebSearchProviders(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
bundledAllowlistCompat?: boolean;
onlyPluginIds?: readonly string[];
}): PluginWebSearchProviderEntry[] {
const { config, normalized } = resolveBundledWebSearchResolutionConfig(params);
const onlyPluginIdSet =
params.onlyPluginIds && params.onlyPluginIds.length > 0 ? new Set(params.onlyPluginIds) : null;
return listBundledWebSearchProviders().filter((provider) => {
if (onlyPluginIdSet && !onlyPluginIdSet.has(provider.pluginId)) {
return false;
}
return resolveEffectivePluginActivationState({
id: provider.pluginId,
origin: "bundled",
config: normalized,
rootConfig: config,
}).activated;
});
}

View File

@@ -11,30 +11,15 @@ const { resolvePluginWebSearchProvidersMock } = vi.hoisted(() => ({
resolvePluginWebSearchProvidersMock: vi.fn(() => buildTestWebSearchProviders()),
}));
const { resolveBundledPluginWebSearchProvidersMock } = vi.hoisted(() => ({
resolveBundledPluginWebSearchProvidersMock: vi.fn(() => buildTestWebSearchProviders()),
}));
const { resolvePluginWebFetchProvidersMock } = vi.hoisted(() => ({
resolvePluginWebFetchProvidersMock: vi.fn(() => buildTestWebFetchProviders()),
}));
const { resolveBundledPluginWebFetchProvidersMock } = vi.hoisted(() => ({
resolveBundledPluginWebFetchProvidersMock: vi.fn(() => buildTestWebFetchProviders()),
}));
let bundledWebSearchProviders: typeof import("../plugins/web-search-providers.js");
let runtimeWebSearchProviders: typeof import("../plugins/web-search-providers.runtime.js");
let bundledWebFetchProviders: typeof import("../plugins/web-fetch-providers.js");
let runtimeWebFetchProviders: typeof import("../plugins/web-fetch-providers.runtime.js");
let secretResolve: typeof import("./resolve.js");
let createResolverContext: typeof import("./runtime-shared.js").createResolverContext;
let resolveRuntimeWebTools: typeof import("./runtime-web-tools.js").resolveRuntimeWebTools;
vi.mock("../plugins/web-search-providers.js", () => ({
resolveBundledPluginWebSearchProviders: resolveBundledPluginWebSearchProvidersMock,
}));
vi.mock("../plugins/web-search-providers.runtime.js", async () => {
const actual = await vi.importActual<typeof import("../plugins/web-search-providers.runtime.js")>(
"../plugins/web-search-providers.runtime.js",
@@ -45,10 +30,6 @@ vi.mock("../plugins/web-search-providers.runtime.js", async () => {
};
});
vi.mock("../plugins/web-fetch-providers.js", () => ({
resolveBundledPluginWebFetchProviders: resolveBundledPluginWebFetchProvidersMock,
}));
vi.mock("../plugins/web-fetch-providers.runtime.js", async () => {
const actual = await vi.importActual<typeof import("../plugins/web-fetch-providers.runtime.js")>(
"../plugins/web-fetch-providers.runtime.js",
@@ -264,9 +245,7 @@ function expectInactiveWebFetchProviderSecretRef(params: {
describe("runtime web tools resolution", () => {
beforeAll(async () => {
bundledWebSearchProviders = await import("../plugins/web-search-providers.js");
runtimeWebSearchProviders = await import("../plugins/web-search-providers.runtime.js");
bundledWebFetchProviders = await import("../plugins/web-fetch-providers.js");
runtimeWebFetchProviders = await import("../plugins/web-fetch-providers.runtime.js");
secretResolve = await import("./resolve.js");
({ createResolverContext } = await import("./runtime-shared.js"));
@@ -275,9 +254,7 @@ describe("runtime web tools resolution", () => {
beforeEach(() => {
runtimeWebSearchProviders.__testing.resetWebSearchProviderSnapshotCacheForTests();
vi.mocked(bundledWebSearchProviders.resolveBundledPluginWebSearchProviders).mockClear();
vi.mocked(runtimeWebSearchProviders.resolvePluginWebSearchProviders).mockClear();
vi.mocked(bundledWebFetchProviders.resolveBundledPluginWebFetchProviders).mockClear();
vi.mocked(runtimeWebFetchProviders.resolvePluginWebFetchProviders).mockClear();
});
@@ -665,9 +642,8 @@ describe("runtime web tools resolution", () => {
);
});
it("uses bundled provider resolution for configured bundled providers", async () => {
const bundledSpy = vi.mocked(bundledWebSearchProviders.resolveBundledPluginWebSearchProviders);
const genericSpy = vi.mocked(runtimeWebSearchProviders.resolvePluginWebSearchProviders);
it("uses bundled-only runtime provider resolution for configured bundled providers", async () => {
const runtimeSpy = vi.mocked(runtimeWebSearchProviders.resolvePluginWebSearchProviders);
const { metadata } = await runRuntimeWebTools({
config: asConfig({
@@ -698,13 +674,13 @@ describe("runtime web tools resolution", () => {
});
expect(metadata.search.selectedProvider).toBe("gemini");
expect(bundledSpy).toHaveBeenCalledWith(
expect(runtimeSpy).toHaveBeenCalledWith(
expect.objectContaining({
bundledAllowlistCompat: true,
onlyPluginIds: ["google"],
origin: "bundled",
}),
);
expect(genericSpy).not.toHaveBeenCalled();
});
it("does not resolve web fetch provider SecretRef when web fetch is inactive", async () => {
@@ -955,7 +931,6 @@ describe("runtime web tools resolution", () => {
});
it("keeps web fetch provider discovery bundled-only during runtime secret resolution", async () => {
const bundledSpy = vi.mocked(bundledWebFetchProviders.resolveBundledPluginWebFetchProviders);
const runtimeSpy = vi.mocked(runtimeWebFetchProviders.resolvePluginWebFetchProviders);
const { metadata } = await runRuntimeWebTools({
@@ -986,7 +961,11 @@ describe("runtime web tools resolution", () => {
});
expect(metadata.fetch.selectedProvider).toBe("firecrawl");
expect(bundledSpy).toHaveBeenCalled();
expect(runtimeSpy).not.toHaveBeenCalled();
expect(runtimeSpy).toHaveBeenCalledWith(
expect.objectContaining({
bundledAllowlistCompat: true,
origin: "bundled",
}),
);
});
});

View File

@@ -1,17 +1,17 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { resolveBundledWebFetchPluginId } from "../plugins/bundled-web-fetch-provider-ids.js";
import { listBundledWebSearchPluginIds } from "../plugins/bundled-web-search-ids.js";
import { resolveBundledWebSearchPluginId } from "../plugins/bundled-web-search-provider-ids.js";
import {
resolveManifestContractOwnerPluginId,
resolveManifestContractPluginIds,
} from "../plugins/manifest-registry.js";
import type {
PluginWebFetchProviderEntry,
PluginWebSearchProviderEntry,
WebFetchCredentialResolutionSource,
WebSearchCredentialResolutionSource,
} from "../plugins/types.js";
import { resolveBundledPluginWebFetchProviders } from "../plugins/web-fetch-providers.js";
import { resolvePluginWebFetchProviders } from "../plugins/web-fetch-providers.runtime.js";
import { sortWebFetchProvidersForAutoDetect } from "../plugins/web-fetch-providers.shared.js";
import { resolveBundledPluginWebSearchProviders } from "../plugins/web-search-providers.js";
import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js";
import { sortWebSearchProvidersForAutoDetect } from "../plugins/web-search-providers.shared.js";
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
@@ -101,7 +101,14 @@ function hasCustomWebSearchPluginRisk(config: OpenClawConfig): boolean {
return true;
}
const bundledPluginIds = new Set<string>(listBundledWebSearchPluginIds());
const bundledPluginIds = new Set<string>(
resolveManifestContractPluginIds({
contract: "webSearchProviders",
origin: "bundled",
config,
env: process.env,
}),
);
const hasNonBundledPluginId = (pluginId: string) => !bundledPluginIds.has(pluginId.trim());
if (Array.isArray(plugins.allow) && plugins.allow.some(hasNonBundledPluginId)) {
return true;
@@ -364,7 +371,13 @@ export async function resolveRuntimeWebTools(params: {
const search = isRecord(web?.search) ? web.search : undefined;
const rawProvider =
typeof search?.provider === "string" ? search.provider.trim().toLowerCase() : "";
const configuredBundledPluginId = resolveBundledWebSearchPluginId(rawProvider);
const configuredBundledPluginId = resolveManifestContractOwnerPluginId({
contract: "webSearchProviders",
value: rawProvider,
origin: "bundled",
config: params.sourceConfig,
env: { ...process.env, ...params.context.env },
});
const searchMetadata: RuntimeWebSearchMetadata = {
providerSource: "none",
@@ -373,17 +386,19 @@ export async function resolveRuntimeWebTools(params: {
const searchProviders = sortWebSearchProvidersForAutoDetect(
configuredBundledPluginId
? resolveBundledPluginWebSearchProviders({
? resolvePluginWebSearchProviders({
config: params.sourceConfig,
env: { ...process.env, ...params.context.env },
bundledAllowlistCompat: true,
onlyPluginIds: [configuredBundledPluginId],
origin: "bundled",
})
: !hasCustomWebSearchPluginRisk(params.sourceConfig)
? resolveBundledPluginWebSearchProviders({
? resolvePluginWebSearchProviders({
config: params.sourceConfig,
env: { ...process.env, ...params.context.env },
bundledAllowlistCompat: true,
origin: "bundled",
})
: resolvePluginWebSearchProviders({
config: params.sourceConfig,
@@ -664,23 +679,31 @@ export async function resolveRuntimeWebTools(params: {
const fetch = isRecord(web?.fetch) ? (web.fetch as FetchConfig) : undefined;
const rawFetchProvider =
typeof fetch?.provider === "string" ? fetch.provider.trim().toLowerCase() : "";
const configuredBundledFetchPluginId = resolveBundledWebFetchPluginId(rawFetchProvider);
const configuredBundledFetchPluginId = resolveManifestContractOwnerPluginId({
contract: "webFetchProviders",
value: rawFetchProvider,
origin: "bundled",
config: params.sourceConfig,
env: { ...process.env, ...params.context.env },
});
const fetchMetadata: RuntimeWebFetchMetadata = {
providerSource: "none",
diagnostics: [],
};
const fetchProviders = sortWebFetchProvidersForAutoDetect(
configuredBundledFetchPluginId
? resolveBundledPluginWebFetchProviders({
? resolvePluginWebFetchProviders({
config: params.sourceConfig,
env: { ...process.env, ...params.context.env },
bundledAllowlistCompat: true,
onlyPluginIds: [configuredBundledFetchPluginId],
origin: "bundled",
})
: resolveBundledPluginWebFetchProviders({
: resolvePluginWebFetchProviders({
config: params.sourceConfig,
env: { ...process.env, ...params.context.env },
bundledAllowlistCompat: true,
origin: "bundled",
}),
);
const hasConfiguredFetchSurface =

View File

@@ -11,32 +11,20 @@ import { listSecretTargetRegistryEntries } from "./target-registry.js";
type SecretRegistryEntry = ReturnType<typeof listSecretTargetRegistryEntries>[number];
const { resolveBundledPluginWebSearchProvidersMock, resolvePluginWebSearchProvidersMock } =
vi.hoisted(() => ({
resolveBundledPluginWebSearchProvidersMock: vi.fn(() => buildTestWebSearchProviders()),
resolvePluginWebSearchProvidersMock: vi.fn(() => buildTestWebSearchProviders()),
}));
const { resolveBundledPluginWebFetchProvidersMock, resolvePluginWebFetchProvidersMock } =
vi.hoisted(() => ({
resolveBundledPluginWebFetchProvidersMock: vi.fn(() => buildTestWebFetchProviders()),
resolvePluginWebFetchProvidersMock: vi.fn(() => buildTestWebFetchProviders()),
}));
const { resolvePluginWebSearchProvidersMock } = vi.hoisted(() => ({
resolvePluginWebSearchProvidersMock: vi.fn(() => buildTestWebSearchProviders()),
}));
const { resolvePluginWebFetchProvidersMock } = vi.hoisted(() => ({
resolvePluginWebFetchProvidersMock: vi.fn(() => buildTestWebFetchProviders()),
}));
let clearSecretsRuntimeSnapshot: typeof import("./runtime.js").clearSecretsRuntimeSnapshot;
let prepareSecretsRuntimeSnapshot: typeof import("./runtime.js").prepareSecretsRuntimeSnapshot;
vi.mock("../plugins/web-search-providers.js", () => ({
resolveBundledPluginWebSearchProviders: resolveBundledPluginWebSearchProvidersMock,
}));
vi.mock("../plugins/web-search-providers.runtime.js", () => ({
resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock,
}));
vi.mock("../plugins/web-fetch-providers.js", () => ({
resolveBundledPluginWebFetchProviders: resolveBundledPluginWebFetchProvidersMock,
}));
vi.mock("../plugins/web-fetch-providers.runtime.js", () => ({
resolvePluginWebFetchProviders: resolvePluginWebFetchProvidersMock,
}));
@@ -343,9 +331,7 @@ describe("secrets runtime target coverage", () => {
afterEach(() => {
clearSecretsRuntimeSnapshot();
resolveBundledPluginWebSearchProvidersMock.mockReset();
resolvePluginWebSearchProvidersMock.mockReset();
resolveBundledPluginWebFetchProvidersMock.mockReset();
resolvePluginWebFetchProvidersMock.mockReset();
});

View File

@@ -10,14 +10,8 @@ import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
type WebProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity" | "firecrawl";
const { resolveBundledPluginWebSearchProvidersMock, resolvePluginWebSearchProvidersMock } =
vi.hoisted(() => ({
resolveBundledPluginWebSearchProvidersMock: vi.fn(() => buildTestWebSearchProviders()),
resolvePluginWebSearchProvidersMock: vi.fn(() => buildTestWebSearchProviders()),
}));
vi.mock("../plugins/web-search-providers.js", () => ({
resolveBundledPluginWebSearchProviders: resolveBundledPluginWebSearchProvidersMock,
const { resolvePluginWebSearchProvidersMock } = vi.hoisted(() => ({
resolvePluginWebSearchProvidersMock: vi.fn(() => buildTestWebSearchProviders()),
}));
vi.mock("../plugins/web-search-providers.runtime.js", () => ({
@@ -132,8 +126,6 @@ describe("secrets runtime snapshot", () => {
});
beforeEach(() => {
resolveBundledPluginWebSearchProvidersMock.mockReset();
resolveBundledPluginWebSearchProvidersMock.mockReturnValue(buildTestWebSearchProviders());
resolvePluginWebSearchProvidersMock.mockReset();
resolvePluginWebSearchProvidersMock.mockReturnValue(buildTestWebSearchProviders());
});

View File

@@ -9,18 +9,15 @@ type TestPluginWebFetchConfig = {
};
};
const { resolveBundledPluginWebFetchProvidersMock, resolveRuntimeWebFetchProvidersMock } =
vi.hoisted(() => ({
resolveBundledPluginWebFetchProvidersMock: vi.fn<() => PluginWebFetchProviderEntry[]>(() => []),
const { resolvePluginWebFetchProvidersMock, resolveRuntimeWebFetchProvidersMock } = vi.hoisted(
() => ({
resolvePluginWebFetchProvidersMock: vi.fn<() => PluginWebFetchProviderEntry[]>(() => []),
resolveRuntimeWebFetchProvidersMock: vi.fn<() => PluginWebFetchProviderEntry[]>(() => []),
}));
vi.mock("../plugins/web-fetch-providers.js", () => ({
resolveBundledPluginWebFetchProviders: resolveBundledPluginWebFetchProvidersMock,
}));
}),
);
vi.mock("../plugins/web-fetch-providers.runtime.js", () => ({
resolvePluginWebFetchProviders: resolveRuntimeWebFetchProvidersMock,
resolvePluginWebFetchProviders: resolvePluginWebFetchProvidersMock,
resolveRuntimeWebFetchProviders: resolveRuntimeWebFetchProvidersMock,
}));
@@ -69,9 +66,9 @@ describe("web fetch runtime", () => {
beforeEach(() => {
vi.unstubAllEnvs();
resolveBundledPluginWebFetchProvidersMock.mockReset();
resolvePluginWebFetchProvidersMock.mockReset();
resolveRuntimeWebFetchProvidersMock.mockReset();
resolveBundledPluginWebFetchProvidersMock.mockReturnValue([]);
resolvePluginWebFetchProvidersMock.mockReturnValue([]);
resolveRuntimeWebFetchProvidersMock.mockReturnValue([]);
});
@@ -92,7 +89,7 @@ describe("web fetch runtime", () => {
return pluginConfig?.webFetch?.apiKey;
},
});
resolveBundledPluginWebFetchProvidersMock.mockReturnValue([provider]);
resolvePluginWebFetchProvidersMock.mockReturnValue([provider]);
const config: OpenClawConfig = {
plugins: {
@@ -133,7 +130,7 @@ describe("web fetch runtime", () => {
}),
}),
});
resolveBundledPluginWebFetchProvidersMock.mockReturnValue([provider]);
resolvePluginWebFetchProvidersMock.mockReturnValue([provider]);
resolveRuntimeWebFetchProvidersMock.mockReturnValue([provider]);
const runtimeWebFetch: RuntimeWebFetchMetadata = {
@@ -171,7 +168,7 @@ describe("web fetch runtime", () => {
credentialPath: "plugins.entries.firecrawl.config.webFetch.apiKey",
autoDetectOrder: 1,
});
resolveBundledPluginWebFetchProvidersMock.mockReturnValue([provider]);
resolvePluginWebFetchProvidersMock.mockReturnValue([provider]);
vi.stubEnv("FIRECRAWL_API_KEY", "firecrawl-env-key");
const resolved = resolveWebFetchDefinition({
@@ -189,7 +186,7 @@ describe("web fetch runtime", () => {
autoDetectOrder: 1,
getConfiguredCredentialValue: () => "firecrawl-key",
});
resolveBundledPluginWebFetchProvidersMock.mockReturnValue([provider]);
resolvePluginWebFetchProvidersMock.mockReturnValue([provider]);
const resolved = resolveWebFetchDefinition({
config: {
@@ -221,7 +218,7 @@ describe("web fetch runtime", () => {
autoDetectOrder: 0,
getConfiguredCredentialValue: () => "runtime-key",
});
resolveBundledPluginWebFetchProvidersMock.mockReturnValue([bundled]);
resolvePluginWebFetchProvidersMock.mockReturnValue([bundled]);
resolveRuntimeWebFetchProvidersMock.mockReturnValue([runtimeOnly]);
const resolved = resolveWebFetchDefinition({
@@ -248,7 +245,7 @@ describe("web fetch runtime", () => {
autoDetectOrder: 0,
getConfiguredCredentialValue: () => "runtime-key",
});
resolveBundledPluginWebFetchProvidersMock.mockReturnValue([bundled]);
resolvePluginWebFetchProvidersMock.mockReturnValue([bundled]);
resolveRuntimeWebFetchProvidersMock.mockReturnValue([runtimeOnly]);
const resolved = resolveWebFetchDefinition({

View File

@@ -5,7 +5,6 @@ import type {
PluginWebFetchProviderEntry,
WebFetchProviderToolDefinition,
} from "../plugins/types.js";
import { resolveBundledPluginWebFetchProviders } from "../plugins/web-fetch-providers.js";
import { resolvePluginWebFetchProviders } from "../plugins/web-fetch-providers.runtime.js";
import { sortWebFetchProvidersForAutoDetect } from "../plugins/web-fetch-providers.shared.js";
import type { RuntimeWebFetchMetadata } from "../secrets/runtime-web-tools.types.js";
@@ -86,9 +85,10 @@ function hasEntryCredential(
export function listWebFetchProviders(params?: {
config?: OpenClawConfig;
}): PluginWebFetchProviderEntry[] {
return resolveBundledPluginWebFetchProviders({
return resolvePluginWebFetchProviders({
config: params?.config,
bundledAllowlistCompat: true,
origin: "bundled",
});
}
@@ -108,9 +108,10 @@ export function resolveWebFetchProviderId(params: {
}): string {
const providers = sortWebFetchProvidersForAutoDetect(
params.providers ??
resolveBundledPluginWebFetchProviders({
resolvePluginWebFetchProviders({
config: params.config,
bundledAllowlistCompat: true,
origin: "bundled",
}),
);
const raw =
@@ -154,9 +155,10 @@ export function resolveWebFetchDefinition(
}
const providers = sortWebFetchProvidersForAutoDetect(
resolveBundledPluginWebFetchProviders({
resolvePluginWebFetchProviders({
config: options?.config,
bundledAllowlistCompat: true,
origin: "bundled",
}),
).filter(Boolean);
if (providers.length === 0) {

View File

@@ -8,20 +8,15 @@ type TestPluginWebSearchConfig = {
};
};
const { resolveBundledPluginWebSearchProvidersMock, resolveRuntimeWebSearchProvidersMock } =
vi.hoisted(() => ({
resolveBundledPluginWebSearchProvidersMock: vi.fn<() => PluginWebSearchProviderEntry[]>(
() => [],
),
const { resolvePluginWebSearchProvidersMock, resolveRuntimeWebSearchProvidersMock } = vi.hoisted(
() => ({
resolvePluginWebSearchProvidersMock: vi.fn<() => PluginWebSearchProviderEntry[]>(() => []),
resolveRuntimeWebSearchProvidersMock: vi.fn<() => PluginWebSearchProviderEntry[]>(() => []),
}));
vi.mock("../plugins/web-search-providers.js", () => ({
resolveBundledPluginWebSearchProviders: resolveBundledPluginWebSearchProvidersMock,
}));
}),
);
vi.mock("../plugins/web-search-providers.runtime.js", () => ({
resolvePluginWebSearchProviders: resolveRuntimeWebSearchProvidersMock,
resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock,
resolveRuntimeWebSearchProviders: resolveRuntimeWebSearchProvidersMock,
}));
@@ -71,9 +66,9 @@ describe("web search runtime", () => {
});
beforeEach(() => {
resolveBundledPluginWebSearchProvidersMock.mockReset();
resolvePluginWebSearchProvidersMock.mockReset();
resolveRuntimeWebSearchProvidersMock.mockReset();
resolveBundledPluginWebSearchProvidersMock.mockReturnValue([]);
resolvePluginWebSearchProvidersMock.mockReturnValue([]);
resolveRuntimeWebSearchProvidersMock.mockReturnValue([]);
});
@@ -127,7 +122,7 @@ describe("web search runtime", () => {
}),
});
resolveRuntimeWebSearchProvidersMock.mockReturnValue([provider]);
resolveBundledPluginWebSearchProvidersMock.mockReturnValue([provider]);
resolvePluginWebSearchProvidersMock.mockReturnValue([provider]);
const config: OpenClawConfig = {
plugins: {
@@ -174,7 +169,7 @@ describe("web search runtime", () => {
}),
});
resolveRuntimeWebSearchProvidersMock.mockReturnValue([provider]);
resolveBundledPluginWebSearchProvidersMock.mockReturnValue([provider]);
resolvePluginWebSearchProvidersMock.mockReturnValue([provider]);
const config: OpenClawConfig = {
plugins: {

View File

@@ -5,7 +5,6 @@ import type {
PluginWebSearchProviderEntry,
WebSearchProviderToolDefinition,
} from "../plugins/types.js";
import { resolveBundledPluginWebSearchProviders } from "../plugins/web-search-providers.js";
import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js";
import { resolveRuntimeWebSearchProviders } from "../plugins/web-search-providers.runtime.js";
import { sortWebSearchProvidersForAutoDetect } from "../plugins/web-search-providers.shared.js";
@@ -130,9 +129,10 @@ export function resolveWebSearchProviderId(params: {
}): string {
const providers = sortWebSearchProvidersForAutoDetect(
params.providers ??
resolveBundledPluginWebSearchProviders({
resolvePluginWebSearchProviders({
config: params.config,
bundledAllowlistCompat: true,
origin: "bundled",
}),
);
const raw =
@@ -188,9 +188,10 @@ export function resolveWebSearchDefinition(
config: options?.config,
bundledAllowlistCompat: true,
})
: resolveBundledPluginWebSearchProviders({
: resolvePluginWebSearchProviders({
config: options?.config,
bundledAllowlistCompat: true,
origin: "bundled",
}),
).filter(Boolean);
if (providers.length === 0) {

View File

@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { loadBundledCapabilityRuntimeRegistry } from "../../../src/plugins/bundled-capability-runtime.js";
import { BUNDLED_WEB_SEARCH_PLUGIN_IDS } from "../../../src/plugins/bundled-web-search-ids.js";
import { resolveBundledWebSearchPluginId } from "../../../src/plugins/bundled-web-search-provider-ids.js";
import { listBundledWebSearchProviders } from "../../../src/plugins/bundled-web-search.js";
import {
resolveManifestContractOwnerPluginId,
resolveManifestContractPluginIds,
} from "../../../src/plugins/manifest-registry.js";
import { resolvePluginWebSearchProviders } from "../../../src/plugins/web-search-providers.runtime.js";
type ComparableProvider = {
pluginId: string;
@@ -79,21 +81,31 @@ function sortComparableEntries(entries: ComparableProvider[]): ComparableProvide
export function describeBundledWebSearchFastPathContract(pluginId: string) {
describe(`${pluginId} bundled web search fast-path contract`, () => {
it("keeps provider-to-plugin ids aligned with bundled contracts", () => {
const providers = listBundledWebSearchProviders().filter(
(provider) => provider.pluginId === pluginId,
);
const providers = resolvePluginWebSearchProviders({
origin: "bundled",
}).filter((provider) => provider.pluginId === pluginId);
expect(providers.length).toBeGreaterThan(0);
for (const provider of providers) {
expect(resolveBundledWebSearchPluginId(provider.id)).toBe(pluginId);
expect(
resolveManifestContractOwnerPluginId({
contract: "webSearchProviders",
value: provider.id,
origin: "bundled",
}),
).toBe(pluginId);
}
});
it("keeps fast-path provider metadata aligned with the bundled runtime registry", async () => {
const fastPathProviders = listBundledWebSearchProviders().filter(
(provider) => provider.pluginId === pluginId,
);
const bundledWebSearchPluginIds = resolveManifestContractPluginIds({
contract: "webSearchProviders",
origin: "bundled",
});
const fastPathProviders = resolvePluginWebSearchProviders({
origin: "bundled",
}).filter((provider) => provider.pluginId === pluginId);
const bundledProviderEntries = loadBundledCapabilityRuntimeRegistry({
pluginIds: BUNDLED_WEB_SEARCH_PLUGIN_IDS,
pluginIds: bundledWebSearchPluginIds,
pluginSdkResolution: "dist",
})
.webSearchProviders.filter((entry) => entry.pluginId === pluginId)