feat(web-search): add plugin-backed search providers

This commit is contained in:
Peter Steinberger
2026-03-16 00:39:27 +00:00
parent 59bcac472e
commit e8156c8281
27 changed files with 3195 additions and 2364 deletions

View File

@@ -1,9 +1,15 @@
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { buildMoonshotProvider } from "../../src/agents/models-config.providers.static.js";
import {
createMoonshotThinkingWrapper,
resolveMoonshotThinkingType,
} from "../../src/agents/pi-embedded-runner/moonshot-stream-wrappers.js";
import {
createPluginBackedWebSearchProvider,
getScopedCredentialValue,
setScopedCredentialValue,
} from "../../src/agents/tools/web-search-plugin-factory.js";
import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js";
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
const PROVIDER_ID = "moonshot";
@@ -46,6 +52,21 @@ const moonshotPlugin = {
return createMoonshotThinkingWrapper(ctx.streamFn, thinkingType);
},
});
api.registerWebSearchProvider(
createPluginBackedWebSearchProvider({
id: "kimi",
label: "Kimi (Moonshot)",
hint: "Moonshot web search",
envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
placeholder: "sk-...",
signupUrl: "https://platform.moonshot.cn/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 40,
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "kimi"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "kimi", value),
}),
);
},
};

View File

@@ -0,0 +1,32 @@
import {
createPluginBackedWebSearchProvider,
getTopLevelCredentialValue,
setTopLevelCredentialValue,
} from "../../src/agents/tools/web-search-plugin-factory.js";
import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js";
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
const braveSearchPlugin = {
id: "web-search-brave",
name: "Web Search Brave Provider",
description: "Bundled Brave provider for the web_search tool",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerWebSearchProvider(
createPluginBackedWebSearchProvider({
id: "brave",
label: "Brave Search",
hint: "Structured results · country/language/time filters",
envVars: ["BRAVE_API_KEY"],
placeholder: "BSA...",
signupUrl: "https://brave.com/search/api/",
docsUrl: "https://docs.openclaw.ai/brave-search",
autoDetectOrder: 10,
getCredentialValue: getTopLevelCredentialValue,
setCredentialValue: setTopLevelCredentialValue,
}),
);
},
};
export default braveSearchPlugin;

View File

@@ -0,0 +1,8 @@
{
"id": "web-search-brave",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "@openclaw/web-search-brave",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw Brave web search provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,33 @@
import {
createPluginBackedWebSearchProvider,
getScopedCredentialValue,
setScopedCredentialValue,
} from "../../src/agents/tools/web-search-plugin-factory.js";
import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js";
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
const geminiSearchPlugin = {
id: "web-search-gemini",
name: "Web Search Gemini Provider",
description: "Bundled Gemini provider for the web_search tool",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerWebSearchProvider(
createPluginBackedWebSearchProvider({
id: "gemini",
label: "Gemini (Google Search)",
hint: "Google Search grounding · AI-synthesized",
envVars: ["GEMINI_API_KEY"],
placeholder: "AIza...",
signupUrl: "https://aistudio.google.com/apikey",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 20,
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "gemini"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "gemini", value),
}),
);
},
};
export default geminiSearchPlugin;

View File

@@ -0,0 +1,8 @@
{
"id": "web-search-gemini",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "@openclaw/web-search-gemini",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw Gemini web search provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,33 @@
import {
createPluginBackedWebSearchProvider,
getScopedCredentialValue,
setScopedCredentialValue,
} from "../../src/agents/tools/web-search-plugin-factory.js";
import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js";
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
const grokSearchPlugin = {
id: "web-search-grok",
name: "Web Search Grok Provider",
description: "Bundled Grok provider for the web_search tool",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerWebSearchProvider(
createPluginBackedWebSearchProvider({
id: "grok",
label: "Grok (xAI)",
hint: "xAI web-grounded responses",
envVars: ["XAI_API_KEY"],
placeholder: "xai-...",
signupUrl: "https://console.x.ai/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 30,
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "grok"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "grok", value),
}),
);
},
};
export default grokSearchPlugin;

View File

@@ -0,0 +1,8 @@
{
"id": "web-search-grok",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "@openclaw/web-search-grok",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw Grok web search provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,33 @@
import {
createPluginBackedWebSearchProvider,
getScopedCredentialValue,
setScopedCredentialValue,
} from "../../src/agents/tools/web-search-plugin-factory.js";
import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js";
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
const perplexitySearchPlugin = {
id: "web-search-perplexity",
name: "Web Search Perplexity Provider",
description: "Bundled Perplexity provider for the web_search tool",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerWebSearchProvider(
createPluginBackedWebSearchProvider({
id: "perplexity",
label: "Perplexity Search",
hint: "Structured results · domain/country/language/time filters",
envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
placeholder: "pplx-...",
signupUrl: "https://www.perplexity.ai/settings/api",
docsUrl: "https://docs.openclaw.ai/perplexity",
autoDetectOrder: 50,
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "perplexity"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "perplexity", value),
}),
);
},
};
export default perplexitySearchPlugin;

View File

@@ -0,0 +1,8 @@
{
"id": "web-search-perplexity",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "@openclaw/web-search-perplexity",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw Perplexity web search provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { WebSearchProviderPlugin } from "../../plugins/types.js";
import { createWebSearchTool as createLegacyWebSearchTool } from "./web-search-core.js";
function cloneWithDescriptors<T extends object>(value: T | undefined): T {
const next = Object.create(Object.getPrototypeOf(value ?? {})) as T;
if (value) {
Object.defineProperties(next, Object.getOwnPropertyDescriptors(value));
}
return next;
}
function withForcedProvider(config: OpenClawConfig | undefined, provider: string): OpenClawConfig {
const next = cloneWithDescriptors(config ?? {});
const tools = cloneWithDescriptors(next.tools ?? {});
const web = cloneWithDescriptors(tools.web ?? {});
const search = cloneWithDescriptors(web.search ?? {});
search.provider = provider;
web.search = search;
tools.web = web;
next.tools = tools;
return next;
}
export function createPluginBackedWebSearchProvider(
provider: Omit<WebSearchProviderPlugin, "createTool">,
): WebSearchProviderPlugin {
return {
...provider,
createTool: (ctx) => {
const tool = createLegacyWebSearchTool({
config: withForcedProvider(ctx.config, provider.id),
runtimeWebSearch: ctx.runtimeMetadata,
});
if (!tool) {
return null;
}
return {
description: tool.description,
parameters: tool.parameters as Record<string, unknown>,
execute: async (args) => {
const result = await tool.execute(`web-search:${provider.id}`, args);
return (result.details ?? {}) as Record<string, unknown>;
},
};
},
};
}
export function getTopLevelCredentialValue(searchConfig?: Record<string, unknown>): unknown {
return searchConfig?.apiKey;
}
export function setTopLevelCredentialValue(
searchConfigTarget: Record<string, unknown>,
value: unknown,
): void {
searchConfigTarget.apiKey = value;
}
export function getScopedCredentialValue(
searchConfig: Record<string, unknown> | undefined,
key: string,
): unknown {
const scoped = searchConfig?.[key];
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
return undefined;
}
return (scoped as Record<string, unknown>).apiKey;
}
export function setScopedCredentialValue(
searchConfigTarget: Record<string, unknown>,
key: string,
value: unknown,
): void {
const scoped = searchConfigTarget[key];
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
searchConfigTarget[key] = { apiKey: value };
return;
}
(scoped as Record<string, unknown>).apiKey = value;
}

View File

@@ -1,48 +1,48 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
const { withStrictWebToolsEndpointMock } = vi.hoisted(() => ({
withStrictWebToolsEndpointMock: vi.fn(),
}));
vi.mock("../../infra/net/fetch-guard.js", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
vi.mock("./web-guarded-fetch.js", () => ({
withStrictWebToolsEndpoint: withStrictWebToolsEndpointMock,
}));
import { __testing } from "./web-search.js";
describe("web_search redirect resolution hardening", () => {
const { resolveRedirectUrl } = __testing;
async function resolveRedirectUrl() {
const module = await import("./web-search-citation-redirect.js");
return module.resolveCitationRedirectUrl;
}
beforeEach(() => {
fetchWithSsrFGuardMock.mockReset();
vi.resetModules();
withStrictWebToolsEndpointMock.mockReset();
});
it("resolves redirects via SSRF-guarded HEAD requests", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(null, { status: 200 }),
finalUrl: "https://example.com/final",
release,
const resolve = await resolveRedirectUrl();
withStrictWebToolsEndpointMock.mockImplementation(async (_params, run) => {
return await run({
response: new Response(null, { status: 200 }),
finalUrl: "https://example.com/final",
});
});
const resolved = await resolveRedirectUrl("https://example.com/start");
const resolved = await resolve("https://example.com/start");
expect(resolved).toBe("https://example.com/final");
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect(withStrictWebToolsEndpointMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://example.com/start",
timeoutMs: 5000,
init: { method: "HEAD" },
}),
expect.any(Function),
);
expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.proxy).toBeUndefined();
expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.policy).toBeUndefined();
expect(release).toHaveBeenCalledTimes(1);
});
it("falls back to the original URL when guarded resolution fails", async () => {
fetchWithSsrFGuardMock.mockRejectedValue(new Error("blocked"));
await expect(resolveRedirectUrl("https://example.com/start")).resolves.toBe(
"https://example.com/start",
);
const resolve = await resolveRedirectUrl();
withStrictWebToolsEndpointMock.mockRejectedValue(new Error("blocked"));
await expect(resolve("https://example.com/start")).resolves.toBe("https://example.com/start");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -244,18 +244,66 @@ describe("setupSearch", () => {
});
it("stores env-backed SecretRef when secretInputMode=ref for perplexity", async () => {
const originalPerplexity = process.env.PERPLEXITY_API_KEY;
const originalOpenRouter = process.env.OPENROUTER_API_KEY;
delete process.env.PERPLEXITY_API_KEY;
delete process.env.OPENROUTER_API_KEY;
const cfg: OpenClawConfig = {};
const { prompter } = createPrompter({ selectValue: "perplexity" });
const result = await setupSearch(cfg, runtime, prompter, {
secretInputMode: "ref", // pragma: allowlist secret
});
expect(result.tools?.web?.search?.provider).toBe("perplexity");
expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({
source: "env",
provider: "default",
id: "PERPLEXITY_API_KEY", // pragma: allowlist secret
});
expect(prompter.text).not.toHaveBeenCalled();
try {
const { prompter } = createPrompter({ selectValue: "perplexity" });
const result = await setupSearch(cfg, runtime, prompter, {
secretInputMode: "ref", // pragma: allowlist secret
});
expect(result.tools?.web?.search?.provider).toBe("perplexity");
expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({
source: "env",
provider: "default",
id: "PERPLEXITY_API_KEY", // pragma: allowlist secret
});
expect(prompter.text).not.toHaveBeenCalled();
} finally {
if (originalPerplexity === undefined) {
delete process.env.PERPLEXITY_API_KEY;
} else {
process.env.PERPLEXITY_API_KEY = originalPerplexity;
}
if (originalOpenRouter === undefined) {
delete process.env.OPENROUTER_API_KEY;
} else {
process.env.OPENROUTER_API_KEY = originalOpenRouter;
}
}
});
it("prefers detected OPENROUTER_API_KEY SecretRef for perplexity ref mode", async () => {
const originalPerplexity = process.env.PERPLEXITY_API_KEY;
const originalOpenRouter = process.env.OPENROUTER_API_KEY;
delete process.env.PERPLEXITY_API_KEY;
process.env.OPENROUTER_API_KEY = "sk-or-test";
const cfg: OpenClawConfig = {};
try {
const { prompter } = createPrompter({ selectValue: "perplexity" });
const result = await setupSearch(cfg, runtime, prompter, {
secretInputMode: "ref", // pragma: allowlist secret
});
expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({
source: "env",
provider: "default",
id: "OPENROUTER_API_KEY", // pragma: allowlist secret
});
expect(prompter.text).not.toHaveBeenCalled();
} finally {
if (originalPerplexity === undefined) {
delete process.env.PERPLEXITY_API_KEY;
} else {
process.env.PERPLEXITY_API_KEY = originalPerplexity;
}
if (originalOpenRouter === undefined) {
delete process.env.OPENROUTER_API_KEY;
} else {
process.env.OPENROUTER_API_KEY = originalOpenRouter;
}
}
});
it("stores env-backed SecretRef when secretInputMode=ref for brave", async () => {

View File

@@ -6,11 +6,12 @@ import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "../config/types.secrets.js";
import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type { SecretInputMode } from "./onboard-types.js";
export type SearchProvider = "brave" | "gemini" | "grok" | "kimi" | "perplexity";
export type SearchProvider = string;
type SearchProviderEntry = {
value: SearchProvider;
@@ -21,48 +22,17 @@ type SearchProviderEntry = {
signupUrl: string;
};
export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] = [
{
value: "brave",
label: "Brave Search",
hint: "Structured results · country/language/time filters",
envKeys: ["BRAVE_API_KEY"],
placeholder: "BSA...",
signupUrl: "https://brave.com/search/api/",
},
{
value: "gemini",
label: "Gemini (Google Search)",
hint: "Google Search grounding · AI-synthesized",
envKeys: ["GEMINI_API_KEY"],
placeholder: "AIza...",
signupUrl: "https://aistudio.google.com/apikey",
},
{
value: "grok",
label: "Grok (xAI)",
hint: "xAI web-grounded responses",
envKeys: ["XAI_API_KEY"],
placeholder: "xai-...",
signupUrl: "https://console.x.ai/",
},
{
value: "kimi",
label: "Kimi (Moonshot)",
hint: "Moonshot web search",
envKeys: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
placeholder: "sk-...",
signupUrl: "https://platform.moonshot.cn/",
},
{
value: "perplexity",
label: "Perplexity Search",
hint: "Structured results · domain/country/language/time filters",
envKeys: ["PERPLEXITY_API_KEY"],
placeholder: "pplx-...",
signupUrl: "https://www.perplexity.ai/settings/api",
},
] as const;
export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] =
resolvePluginWebSearchProviders({
bundledAllowlistCompat: true,
}).map((provider) => ({
value: provider.id,
label: provider.label,
hint: provider.hint,
envKeys: provider.envVars,
placeholder: provider.placeholder,
signupUrl: provider.signupUrl,
}));
export function hasKeyInEnv(entry: SearchProviderEntry): boolean {
return entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
@@ -70,18 +40,11 @@ export function hasKeyInEnv(entry: SearchProviderEntry): boolean {
function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown {
const search = config.tools?.web?.search;
switch (provider) {
case "brave":
return search?.apiKey;
case "gemini":
return search?.gemini?.apiKey;
case "grok":
return search?.grok?.apiKey;
case "kimi":
return search?.kimi?.apiKey;
case "perplexity":
return search?.perplexity?.apiKey;
}
const entry = resolvePluginWebSearchProviders({
config,
bundledAllowlistCompat: true,
}).find((candidate) => candidate.id === provider);
return entry?.getCredentialValue(search as Record<string, unknown> | undefined);
}
/** Returns the plaintext key string, or undefined for SecretRefs/missing. */
@@ -128,22 +91,12 @@ export function applySearchKey(
key: SecretInput,
): OpenClawConfig {
const search = { ...config.tools?.web?.search, provider, enabled: true };
switch (provider) {
case "brave":
search.apiKey = key;
break;
case "gemini":
search.gemini = { ...search.gemini, apiKey: key };
break;
case "grok":
search.grok = { ...search.grok, apiKey: key };
break;
case "kimi":
search.kimi = { ...search.kimi, apiKey: key };
break;
case "perplexity":
search.perplexity = { ...search.perplexity, apiKey: key };
break;
const entry = resolvePluginWebSearchProviders({
config,
bundledAllowlistCompat: true,
}).find((candidate) => candidate.id === provider);
if (entry) {
entry.setCredentialValue(search as Record<string, unknown>, key);
}
return {
...config,
@@ -225,7 +178,7 @@ export async function setupSearch(
return SEARCH_PROVIDER_OPTIONS[0].value;
})();
type PickerValue = SearchProvider | "__skip__";
type PickerValue = string;
const choice = await prompter.select<PickerValue>({
message: "Search provider",
options: [
@@ -236,7 +189,7 @@ export async function setupSearch(
hint: "Configure later with openclaw configure --section web",
},
],
initialValue: defaultProvider as PickerValue,
initialValue: defaultProvider,
});
if (choice === "__skip__") {

View File

@@ -6,6 +6,40 @@ vi.mock("../runtime.js", () => ({
defaultRuntime: { log: vi.fn(), error: vi.fn() },
}));
vi.mock("../plugins/web-search-providers.js", () => {
const getScoped = (key: string) => (search?: Record<string, unknown>) =>
(search?.[key] as { apiKey?: unknown } | undefined)?.apiKey;
return {
resolvePluginWebSearchProviders: () => [
{
id: "brave",
envVars: ["BRAVE_API_KEY"],
getCredentialValue: (search?: Record<string, unknown>) => search?.apiKey,
},
{
id: "gemini",
envVars: ["GEMINI_API_KEY"],
getCredentialValue: getScoped("gemini"),
},
{
id: "grok",
envVars: ["XAI_API_KEY"],
getCredentialValue: getScoped("grok"),
},
{
id: "kimi",
envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
getCredentialValue: getScoped("kimi"),
},
{
id: "perplexity",
envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
getCredentialValue: getScoped("perplexity"),
},
],
};
});
const { __testing } = await import("../agents/tools/web-search.js");
const { resolveSearchProvider } = __testing;

View File

@@ -359,6 +359,7 @@ function createPluginRecord(params: {
hookNames: [],
channelIds: [],
providerIds: [],
webSearchProviderIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],

View File

@@ -47,6 +47,7 @@ import type {
PluginHookName,
PluginHookHandlerMap,
PluginHookRegistration as TypedPluginHookRegistration,
WebSearchProviderPlugin,
} from "./types.js";
export type PluginToolRegistration = {
@@ -103,6 +104,14 @@ export type PluginProviderRegistration = {
rootDir?: string;
};
export type PluginWebSearchProviderRegistration = {
pluginId: string;
pluginName?: string;
provider: WebSearchProviderPlugin;
source: string;
rootDir?: string;
};
export type PluginHookRegistration = {
pluginId: string;
entry: HookEntry;
@@ -147,6 +156,7 @@ export type PluginRecord = {
hookNames: string[];
channelIds: string[];
providerIds: string[];
webSearchProviderIds: string[];
gatewayMethods: string[];
cliCommands: string[];
services: string[];
@@ -166,6 +176,7 @@ export type PluginRegistry = {
channels: PluginChannelRegistration[];
channelSetups: PluginChannelSetupRegistration[];
providers: PluginProviderRegistration[];
webSearchProviders: PluginWebSearchProviderRegistration[];
gatewayHandlers: GatewayRequestHandlers;
httpRoutes: PluginHttpRouteRegistration[];
cliRegistrars: PluginCliRegistration[];
@@ -210,6 +221,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
channels: [],
channelSetups: [],
providers: [],
webSearchProviders: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],
@@ -541,6 +553,37 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
};
const registerWebSearchProvider = (record: PluginRecord, provider: WebSearchProviderPlugin) => {
const id = provider.id.trim();
if (!id) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "web search provider registration missing id",
});
return;
}
const existing = registry.webSearchProviders.find((entry) => entry.provider.id === id);
if (existing) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `web search provider already registered: ${id} (${existing.pluginId})`,
});
return;
}
record.webSearchProviderIds.push(id);
registry.webSearchProviders.push({
pluginId: record.id,
pluginName: record.name,
provider,
source: record.source,
rootDir: record.rootDir,
});
};
const registerCli = (
record: PluginRecord,
registrar: OpenClawPluginCliRegistrar,
@@ -749,6 +792,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerChannel: (registration) => registerChannel(record, registration, registrationMode),
registerProvider:
registrationMode === "full" ? (provider) => registerProvider(record, provider) : () => {},
registerWebSearchProvider:
registrationMode === "full"
? (provider) => registerWebSearchProvider(record, provider)
: () => {},
registerGatewayMethod:
registrationMode === "full"
? (method, handler) => registerGatewayMethod(record, method, handler)
@@ -818,6 +865,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerTool,
registerChannel,
registerProvider,
registerWebSearchProvider,
registerGatewayMethod,
registerCli,
registerService,

View File

@@ -25,6 +25,7 @@ import type { InternalHookHandler } from "../hooks/internal-hooks.js";
import type { HookEntry } from "../hooks/types.js";
import type { ProviderUsageSnapshot } from "../infra/provider-usage.types.js";
import type { RuntimeEnv } from "../runtime.js";
import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type { PluginRuntime } from "./runtime/types.js";
@@ -565,6 +566,34 @@ export type ProviderPlugin = {
onModelSelected?: (ctx: ProviderModelSelectedContext) => Promise<void>;
};
export type WebSearchProviderId = string;
export type WebSearchProviderToolDefinition = {
description: string;
parameters: Record<string, unknown>;
execute: (args: Record<string, unknown>) => Promise<Record<string, unknown>>;
};
export type WebSearchProviderContext = {
config?: OpenClawConfig;
searchConfig?: Record<string, unknown>;
runtimeMetadata?: RuntimeWebSearchMetadata;
};
export type WebSearchProviderPlugin = {
id: WebSearchProviderId;
label: string;
hint: string;
envVars: string[];
placeholder: string;
signupUrl: string;
docsUrl?: string;
autoDetectOrder?: number;
getCredentialValue: (searchConfig?: Record<string, unknown>) => unknown;
setCredentialValue: (searchConfigTarget: Record<string, unknown>, value: unknown) => void;
createTool: (ctx: WebSearchProviderContext) => WebSearchProviderToolDefinition | null;
};
export type OpenClawPluginGatewayMethod = {
method: string;
handler: GatewayRequestHandler;
@@ -868,6 +897,7 @@ export type OpenClawPluginApi = {
registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void;
registerService: (service: OpenClawPluginService) => void;
registerProvider: (provider: ProviderPlugin) => void;
registerWebSearchProvider: (provider: WebSearchProviderPlugin) => void;
registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void;
/**
* Register a custom command that bypasses the LLM agent.

View File

@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolvePluginWebSearchProviders } from "./web-search-providers.js";
const loadOpenClawPluginsMock = vi.fn();
vi.mock("./loader.js", () => ({
loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args),
}));
describe("resolvePluginWebSearchProviders", () => {
beforeEach(() => {
loadOpenClawPluginsMock.mockReset();
loadOpenClawPluginsMock.mockReturnValue({
webSearchProviders: [
{
pluginId: "web-search-gemini",
provider: {
id: "gemini",
label: "Gemini",
hint: "hint",
envVars: ["GEMINI_API_KEY"],
placeholder: "AIza...",
signupUrl: "https://example.com",
autoDetectOrder: 20,
},
},
{
pluginId: "web-search-brave",
provider: {
id: "brave",
label: "Brave",
hint: "hint",
envVars: ["BRAVE_API_KEY"],
placeholder: "BSA...",
signupUrl: "https://example.com",
autoDetectOrder: 10,
},
},
],
});
});
it("forwards an explicit env to plugin loading", () => {
const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv;
const providers = resolvePluginWebSearchProviders({
workspaceDir: "/workspace/explicit",
env,
});
expect(providers.map((provider) => provider.id)).toEqual(["brave", "gemini"]);
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: "/workspace/explicit",
env,
}),
);
});
it("can augment restrictive allowlists for bundled compatibility", () => {
resolvePluginWebSearchProviders({
config: {
plugins: {
allow: ["openrouter"],
},
},
bundledAllowlistCompat: true,
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: expect.arrayContaining([
"openrouter",
"web-search-brave",
"web-search-perplexity",
]),
}),
}),
}),
);
});
it("auto-enables bundled web search provider plugins when entries are missing", () => {
resolvePluginWebSearchProviders({
config: {
plugins: {
entries: {
openrouter: { enabled: true },
},
},
},
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: expect.objectContaining({
entries: expect.objectContaining({
openrouter: { enabled: true },
"web-search-brave": { enabled: true },
"web-search-gemini": { enabled: true },
"web-search-grok": { enabled: true },
moonshot: { enabled: true },
"web-search-perplexity": { enabled: true },
}),
}),
}),
}),
);
});
it("preserves explicit bundled provider entry state", () => {
resolvePluginWebSearchProviders({
config: {
plugins: {
entries: {
"web-search-perplexity": { enabled: false },
},
},
},
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: expect.objectContaining({
entries: expect.objectContaining({
"web-search-perplexity": { enabled: false },
}),
}),
}),
}),
);
});
});

View File

@@ -0,0 +1,110 @@
import { createSubsystemLogger } from "../logging/subsystem.js";
import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
import type { WebSearchProviderPlugin } from "./types.js";
const log = createSubsystemLogger("plugins");
const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [
"web-search-brave",
"web-search-gemini",
"web-search-grok",
"moonshot",
"web-search-perplexity",
] as const;
function withBundledWebSearchAllowlistCompat(
config: PluginLoadOptions["config"],
): PluginLoadOptions["config"] {
const allow = config?.plugins?.allow;
if (!Array.isArray(allow) || allow.length === 0) {
return config;
}
const allowSet = new Set(allow.map((entry) => entry.trim()).filter(Boolean));
let changed = false;
for (const pluginId of BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS) {
if (!allowSet.has(pluginId)) {
allowSet.add(pluginId);
changed = true;
}
}
if (!changed) {
return config;
}
return {
...config,
plugins: {
...config?.plugins,
allow: [...allowSet],
},
};
}
function withBundledWebSearchEnablementCompat(
config: PluginLoadOptions["config"],
): PluginLoadOptions["config"] {
const existingEntries = config?.plugins?.entries ?? {};
let changed = false;
const nextEntries: Record<string, unknown> = { ...existingEntries };
for (const pluginId of BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS) {
if (existingEntries[pluginId] !== undefined) {
continue;
}
nextEntries[pluginId] = { enabled: true };
changed = true;
}
if (!changed) {
return config;
}
return {
...config,
plugins: {
...config?.plugins,
entries: {
...existingEntries,
...nextEntries,
},
},
};
}
export function resolvePluginWebSearchProviders(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
bundledAllowlistCompat?: boolean;
}): WebSearchProviderPlugin[] {
const allowlistCompat = params.bundledAllowlistCompat
? withBundledWebSearchAllowlistCompat(params.config)
: params.config;
const config = withBundledWebSearchEnablementCompat(allowlistCompat);
const registry = loadOpenClawPlugins({
config,
workspaceDir: params.workspaceDir,
env: params.env,
logger: createPluginLoaderLogger(log),
activate: false,
cache: false,
onlyPluginIds: [...BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS],
});
return registry.webSearchProviders
.map((entry) => ({
...entry.provider,
pluginId: entry.pluginId,
}))
.toSorted((a, b) => {
const aOrder = a.autoDetectOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.autoDetectOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id.localeCompare(b.id);
});
}

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.js";
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
import { secretRefKey } from "./ref-contract.js";
import { resolveSecretRefValues } from "./resolve.js";
@@ -9,53 +10,28 @@ import {
type ResolverContext,
type SecretDefaults,
} from "./runtime-shared.js";
import type {
RuntimeWebDiagnostic,
RuntimeWebDiagnosticCode,
RuntimeWebFetchFirecrawlMetadata,
RuntimeWebSearchMetadata,
RuntimeWebToolsMetadata,
} from "./runtime-web-tools.types.js";
const WEB_SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const;
const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
type WebSearchProvider = (typeof WEB_SEARCH_PROVIDERS)[number];
type WebSearchProvider = string;
type SecretResolutionSource = "config" | "secretRef" | "env" | "missing"; // pragma: allowlist secret
type RuntimeWebProviderSource = "configured" | "auto-detect" | "none";
export type RuntimeWebDiagnosticCode =
| "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT"
| "WEB_SEARCH_AUTODETECT_SELECTED"
| "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED"
| "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK"
| "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED"
| "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK";
export type RuntimeWebDiagnostic = {
code: RuntimeWebDiagnosticCode;
message: string;
path?: string;
};
export type RuntimeWebSearchMetadata = {
providerConfigured?: WebSearchProvider;
providerSource: RuntimeWebProviderSource;
selectedProvider?: WebSearchProvider;
selectedProviderKeySource?: SecretResolutionSource;
perplexityTransport?: "search_api" | "chat_completions";
diagnostics: RuntimeWebDiagnostic[];
};
export type RuntimeWebFetchFirecrawlMetadata = {
active: boolean;
apiKeySource: SecretResolutionSource;
diagnostics: RuntimeWebDiagnostic[];
};
export type RuntimeWebToolsMetadata = {
search: RuntimeWebSearchMetadata;
fetch: {
firecrawl: RuntimeWebFetchFirecrawlMetadata;
};
diagnostics: RuntimeWebDiagnostic[];
export type {
RuntimeWebDiagnostic,
RuntimeWebDiagnosticCode,
RuntimeWebFetchFirecrawlMetadata,
RuntimeWebSearchMetadata,
RuntimeWebToolsMetadata,
};
type FetchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
@@ -77,18 +53,15 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeProvider(value: unknown): WebSearchProvider | undefined {
function normalizeProvider(
value: unknown,
providers: ReturnType<typeof resolvePluginWebSearchProviders>,
): WebSearchProvider | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (
normalized === "brave" ||
normalized === "gemini" ||
normalized === "grok" ||
normalized === "kimi" ||
normalized === "perplexity"
) {
if (providers.some((provider) => provider.id === normalized)) {
return normalized;
}
return undefined;
@@ -293,16 +266,18 @@ function setResolvedWebSearchApiKey(params: {
resolvedConfig: OpenClawConfig;
provider: WebSearchProvider;
value: string;
sourceConfig: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): void {
const tools = ensureObject(params.resolvedConfig as Record<string, unknown>, "tools");
const web = ensureObject(tools, "web");
const search = ensureObject(web, "search");
if (params.provider === "brave") {
search.apiKey = params.value;
return;
}
const providerConfig = ensureObject(search, params.provider);
providerConfig.apiKey = params.value;
const provider = resolvePluginWebSearchProviders({
config: params.sourceConfig,
env: params.env,
bundledAllowlistCompat: true,
}).find((entry) => entry.id === params.provider);
provider?.setCredentialValue(search, params.value);
}
function setResolvedFirecrawlApiKey(params: {
@@ -316,34 +291,8 @@ function setResolvedFirecrawlApiKey(params: {
firecrawl.apiKey = params.value;
}
function envVarsForProvider(provider: WebSearchProvider): string[] {
if (provider === "brave") {
return ["BRAVE_API_KEY"];
}
if (provider === "gemini") {
return ["GEMINI_API_KEY"];
}
if (provider === "grok") {
return ["XAI_API_KEY"];
}
if (provider === "kimi") {
return ["KIMI_API_KEY", "MOONSHOT_API_KEY"];
}
return ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"];
}
function resolveProviderKeyValue(
search: Record<string, unknown>,
provider: WebSearchProvider,
): unknown {
if (provider === "brave") {
return search.apiKey;
}
const scoped = search[provider];
if (!isRecord(scoped)) {
return undefined;
}
return scoped.apiKey;
function keyPathForProvider(provider: WebSearchProvider): string {
return provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
}
function hasConfiguredSecretRef(value: unknown, defaults: SecretDefaults | undefined): boolean {
@@ -366,6 +315,11 @@ export async function resolveRuntimeWebTools(params: {
const tools = isRecord(params.sourceConfig.tools) ? params.sourceConfig.tools : undefined;
const web = isRecord(tools?.web) ? tools.web : undefined;
const search = isRecord(web?.search) ? web.search : undefined;
const providers = resolvePluginWebSearchProviders({
config: params.sourceConfig,
env: params.context.env,
bundledAllowlistCompat: true,
});
const searchMetadata: RuntimeWebSearchMetadata = {
providerSource: "none",
@@ -375,7 +329,7 @@ export async function resolveRuntimeWebTools(params: {
const searchEnabled = search?.enabled !== false;
const rawProvider =
typeof search?.provider === "string" ? search.provider.trim().toLowerCase() : "";
const configuredProvider = normalizeProvider(rawProvider);
const configuredProvider = normalizeProvider(rawProvider, providers);
if (rawProvider && !configuredProvider) {
const diagnostic: RuntimeWebDiagnostic = {
@@ -398,7 +352,9 @@ export async function resolveRuntimeWebTools(params: {
}
if (searchEnabled && search) {
const candidates = configuredProvider ? [configuredProvider] : [...WEB_SEARCH_PROVIDERS];
const candidates = configuredProvider
? providers.filter((provider) => provider.id === configuredProvider)
: providers;
const unresolvedWithoutFallback: Array<{
provider: WebSearchProvider;
path: string;
@@ -409,16 +365,15 @@ export async function resolveRuntimeWebTools(params: {
let selectedResolution: SecretResolutionResult | undefined;
for (const provider of candidates) {
const path =
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
const value = resolveProviderKeyValue(search, provider);
const path = keyPathForProvider(provider.id);
const value = provider.getCredentialValue(search);
const resolution = await resolveSecretInputWithEnvFallback({
sourceConfig: params.sourceConfig,
context: params.context,
defaults,
value,
path,
envVars: envVarsForProvider(provider),
envVars: provider.envVars,
});
if (resolution.secretRefConfigured && resolution.fallbackUsedAfterRefFailure) {
@@ -440,32 +395,36 @@ export async function resolveRuntimeWebTools(params: {
if (resolution.secretRefConfigured && !resolution.value && resolution.unresolvedRefReason) {
unresolvedWithoutFallback.push({
provider,
provider: provider.id,
path,
reason: resolution.unresolvedRefReason,
});
}
if (configuredProvider) {
selectedProvider = provider;
selectedProvider = provider.id;
selectedResolution = resolution;
if (resolution.value) {
setResolvedWebSearchApiKey({
resolvedConfig: params.resolvedConfig,
provider,
provider: provider.id,
value: resolution.value,
sourceConfig: params.sourceConfig,
env: params.context.env,
});
}
break;
}
if (resolution.value) {
selectedProvider = provider;
selectedProvider = provider.id;
selectedResolution = resolution;
setResolvedWebSearchApiKey({
resolvedConfig: params.resolvedConfig,
provider,
provider: provider.id,
value: resolution.value,
sourceConfig: params.sourceConfig,
env: params.context.env,
});
break;
}
@@ -526,13 +485,12 @@ export async function resolveRuntimeWebTools(params: {
}
if (searchEnabled && search && !configuredProvider && searchMetadata.selectedProvider) {
for (const provider of WEB_SEARCH_PROVIDERS) {
if (provider === searchMetadata.selectedProvider) {
for (const provider of providers) {
if (provider.id === searchMetadata.selectedProvider) {
continue;
}
const path =
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
const value = resolveProviderKeyValue(search, provider);
const path = keyPathForProvider(provider.id);
const value = provider.getCredentialValue(search);
if (!hasConfiguredSecretRef(value, defaults)) {
continue;
}
@@ -543,10 +501,9 @@ export async function resolveRuntimeWebTools(params: {
});
}
} else if (search && !searchEnabled) {
for (const provider of WEB_SEARCH_PROVIDERS) {
const path =
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
const value = resolveProviderKeyValue(search, provider);
for (const provider of providers) {
const path = keyPathForProvider(provider.id);
const value = provider.getCredentialValue(search);
if (!hasConfiguredSecretRef(value, defaults)) {
continue;
}
@@ -559,13 +516,12 @@ export async function resolveRuntimeWebTools(params: {
}
if (searchEnabled && search && configuredProvider) {
for (const provider of WEB_SEARCH_PROVIDERS) {
if (provider === configuredProvider) {
for (const provider of providers) {
if (provider.id === configuredProvider) {
continue;
}
const path =
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
const value = resolveProviderKeyValue(search, provider);
const path = keyPathForProvider(provider.id);
const value = provider.getCredentialValue(search);
if (!hasConfiguredSecretRef(value, defaults)) {
continue;
}

View File

@@ -0,0 +1,36 @@
export type RuntimeWebDiagnosticCode =
| "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT"
| "WEB_SEARCH_AUTODETECT_SELECTED"
| "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED"
| "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK"
| "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED"
| "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK";
export type RuntimeWebDiagnostic = {
code: RuntimeWebDiagnosticCode;
message: string;
path?: string;
};
export type RuntimeWebSearchMetadata = {
providerConfigured?: string;
providerSource: "configured" | "auto-detect" | "none";
selectedProvider?: string;
selectedProviderKeySource?: "config" | "secretRef" | "env" | "missing";
perplexityTransport?: "search_api" | "chat_completions";
diagnostics: RuntimeWebDiagnostic[];
};
export type RuntimeWebFetchFirecrawlMetadata = {
active: boolean;
apiKeySource: "config" | "secretRef" | "env" | "missing";
diagnostics: RuntimeWebDiagnostic[];
};
export type RuntimeWebToolsMetadata = {
search: RuntimeWebSearchMetadata;
fetch: {
firecrawl: RuntimeWebFetchFirecrawlMetadata;
};
diagnostics: RuntimeWebDiagnostic[];
};