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.
This commit is contained in:
Gustavo Madeira Santana
2026-04-17 13:32:55 -04:00
parent 20cf51169b
commit a464f5926b
4 changed files with 96 additions and 11 deletions

View File

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

View File

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

View File

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

View File

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