From a464f5926b86b3108fc2f7e718cca41e1b84bd01 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 13:32:55 -0400 Subject: [PATCH] Secrets: avoid broad web search discovery for single plugin config Add an Exa web-search contract artifact and use single bundled plugin-scoped webSearch config as a provider hint. This keeps runtime secret resolution on metadata-only surfaces instead of importing full provider tool implementations. --- .../exa/src/exa-web-search-provider.test.ts | 26 ++++++++++++++++ extensions/exa/web-search-contract-api.ts | 29 ++++++++++++++++++ src/secrets/runtime-web-tools.test.ts | 30 +++++++++++++++++++ src/secrets/runtime-web-tools.ts | 22 +++++++------- 4 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 extensions/exa/web-search-contract-api.ts diff --git a/extensions/exa/src/exa-web-search-provider.test.ts b/extensions/exa/src/exa-web-search-provider.test.ts index 6d16fcc2a02..9de72bc89f2 100644 --- a/extensions/exa/src/exa-web-search-provider.test.ts +++ b/extensions/exa/src/exa-web-search-provider.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { createExaWebSearchProvider as createContractExaWebSearchProvider } from "../web-search-contract-api.js"; import { __testing, createExaWebSearchProvider } from "./exa-web-search-provider.js"; describe("exa web search provider", () => { @@ -15,6 +16,31 @@ describe("exa web search provider", () => { expect(applied.plugins?.entries?.exa?.enabled).toBe(true); }); + it("keeps the lightweight contract surface aligned with provider metadata", () => { + const provider = createExaWebSearchProvider(); + const contractProvider = createContractExaWebSearchProvider(); + if (!contractProvider.applySelectionConfig) { + throw new Error("Expected contract applySelectionConfig to be defined"); + } + const applied = contractProvider.applySelectionConfig({}); + + expect(contractProvider).toMatchObject({ + id: provider.id, + label: provider.label, + hint: provider.hint, + onboardingScopes: provider.onboardingScopes, + credentialLabel: provider.credentialLabel, + envVars: provider.envVars, + placeholder: provider.placeholder, + signupUrl: provider.signupUrl, + docsUrl: provider.docsUrl, + autoDetectOrder: provider.autoDetectOrder, + credentialPath: provider.credentialPath, + }); + expect(contractProvider.createTool({ config: {}, searchConfig: {} })).toBeNull(); + expect(applied.plugins?.entries?.exa?.enabled).toBe(true); + }); + it("prefers scoped configured api keys over environment fallbacks", () => { expect(__testing.resolveExaApiKey({ apiKey: "exa-secret" })).toBe("exa-secret"); }); diff --git a/extensions/exa/web-search-contract-api.ts b/extensions/exa/web-search-contract-api.ts new file mode 100644 index 00000000000..bfda3901e63 --- /dev/null +++ b/extensions/exa/web-search-contract-api.ts @@ -0,0 +1,29 @@ +import { + createWebSearchProviderContractFields, + type WebSearchProviderPlugin, +} from "openclaw/plugin-sdk/provider-web-search-contract"; + +export function createExaWebSearchProvider(): WebSearchProviderPlugin { + const credentialPath = "plugins.entries.exa.config.webSearch.apiKey"; + + return { + id: "exa", + label: "Exa Search", + hint: "Neural + keyword search with date filters and content extraction", + onboardingScopes: ["text-inference"], + credentialLabel: "Exa API key", + envVars: ["EXA_API_KEY"], + placeholder: "exa-...", + signupUrl: "https://exa.ai/", + docsUrl: "https://docs.openclaw.ai/tools/web", + autoDetectOrder: 65, + credentialPath, + ...createWebSearchProviderContractFields({ + credentialPath, + searchCredential: { type: "scoped", scopeId: "exa" }, + configuredCredential: { pluginId: "exa" }, + selectionPluginId: "exa", + }), + createTool: () => null, + }; +} diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts index c946b6eb170..0d3b3bab7a1 100644 --- a/src/secrets/runtime-web-tools.test.ts +++ b/src/secrets/runtime-web-tools.test.ts @@ -871,6 +871,36 @@ describe("runtime web tools resolution", () => { expect(resolvePluginWebSearchProvidersMock).not.toHaveBeenCalled(); }); + it("uses single plugin-scoped web search config as a bundled provider hint", async () => { + const { metadata } = await runRuntimeWebTools({ + config: asConfig({ + plugins: { + entries: { + google: { + enabled: true, + config: { + webSearch: { + apiKey: { source: "env", provider: "default", id: "GOOGLE_PROVIDER_REF" }, + }, + }, + }, + }, + }, + }), + env: { + GOOGLE_PROVIDER_REF: "google-provider-key", + }, + }); + + expect(metadata.search.selectedProvider).toBe("gemini"); + expect(resolveBundledExplicitWebSearchProvidersFromPublicArtifactsMock).toHaveBeenCalledWith({ + onlyPluginIds: ["google"], + }); + expect(resolveManifestContractOwnerPluginIdMock).not.toHaveBeenCalled(); + expect(resolveBundledWebSearchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled(); + expect(resolvePluginWebSearchProvidersMock).not.toHaveBeenCalled(); + }); + it("limits legacy top-level web search apiKey auto-detect to compatibility owners", async () => { const { metadata } = await runRuntimeWebTools({ config: asConfig({ diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index 3d517513414..e63f45adc3a 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -542,18 +542,18 @@ export async function resolveRuntimeWebTools(params: { } const rawProvider = normalizeLowercaseStringOrEmpty(search?.provider); let configuredBundledWebSearchPluginIdHint: string | undefined; - if (rawProvider && hasPluginWebSearchConfig) { - configuredBundledWebSearchPluginIdHint = inferExactBundledPluginScopedWebToolConfigOwner({ - config: params.sourceConfig, - key: "webSearch", - pluginId: rawProvider, - }); - if (!configuredBundledWebSearchPluginIdHint && !(await getHasCustomWebSearchRisk())) { - configuredBundledWebSearchPluginIdHint = inferSingleBundledPluginScopedWebToolConfigOwner( - params.sourceConfig, - "webSearch", - ); + if (hasPluginWebSearchConfig && !(await getHasCustomWebSearchRisk())) { + if (rawProvider) { + configuredBundledWebSearchPluginIdHint = inferExactBundledPluginScopedWebToolConfigOwner({ + config: params.sourceConfig, + key: "webSearch", + pluginId: rawProvider, + }); } + configuredBundledWebSearchPluginIdHint ??= inferSingleBundledPluginScopedWebToolConfigOwner( + params.sourceConfig, + "webSearch", + ); } const searchMetadata: RuntimeWebSearchMetadata = { providerSource: "none",