From 32ae84109811ffe4c376a1dc5937b4c2ab9f1aff Mon Sep 17 00:00:00 2001 From: Charles Dusek <38732970+cgdusek@users.noreply.github.com> Date: Wed, 1 Apr 2026 05:24:33 -0500 Subject: [PATCH] feat(web-search): add SearXNG as bundled web search provider plugin (#57317) * feat(web-search): add bundled searxng plugin * test(web-search): cover searxng config wiring * test(web-search): include searxng in bundled provider inventory * test(web-search): keep searxng ordering aligned * fix(web-search): sanitize searxng result rows --------- Co-authored-by: Vincent Koc --- extensions/searxng/index.ts | 11 + extensions/searxng/openclaw.plugin.json | 41 +++ extensions/searxng/package.json | 12 + extensions/searxng/src/config.ts | 85 +++++++ extensions/searxng/src/searxng-client.test.ts | 92 +++++++ extensions/searxng/src/searxng-client.ts | 236 ++++++++++++++++++ .../src/searxng-search-provider.test.ts | 163 ++++++++++++ .../searxng/src/searxng-search-provider.ts | 76 ++++++ extensions/searxng/web-search-provider.ts | 1 + pnpm-lock.yaml | 2 + src/config/config.web-search-provider.test.ts | 47 +++- src/plugins/web-search-providers.test.ts | 3 + 12 files changed, 766 insertions(+), 3 deletions(-) create mode 100644 extensions/searxng/index.ts create mode 100644 extensions/searxng/openclaw.plugin.json create mode 100644 extensions/searxng/package.json create mode 100644 extensions/searxng/src/config.ts create mode 100644 extensions/searxng/src/searxng-client.test.ts create mode 100644 extensions/searxng/src/searxng-client.ts create mode 100644 extensions/searxng/src/searxng-search-provider.test.ts create mode 100644 extensions/searxng/src/searxng-search-provider.ts create mode 100644 extensions/searxng/web-search-provider.ts diff --git a/extensions/searxng/index.ts b/extensions/searxng/index.ts new file mode 100644 index 00000000000..070c7d5346e --- /dev/null +++ b/extensions/searxng/index.ts @@ -0,0 +1,11 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { createSearxngWebSearchProvider } from "./src/searxng-search-provider.js"; + +export default definePluginEntry({ + id: "searxng", + name: "SearXNG Plugin", + description: "Bundled SearXNG web search plugin", + register(api) { + api.registerWebSearchProvider(createSearxngWebSearchProvider()); + }, +}); diff --git a/extensions/searxng/openclaw.plugin.json b/extensions/searxng/openclaw.plugin.json new file mode 100644 index 00000000000..c6afd618d19 --- /dev/null +++ b/extensions/searxng/openclaw.plugin.json @@ -0,0 +1,41 @@ +{ + "id": "searxng", + "uiHints": { + "webSearch.baseUrl": { + "label": "SearXNG Base URL", + "help": "Base URL of your SearXNG instance, such as http://localhost:8080 or https://search.example.com/searxng." + }, + "webSearch.categories": { + "label": "SearXNG Categories", + "help": "Optional comma-separated categories such as general, news, or science." + }, + "webSearch.language": { + "label": "SearXNG Language", + "help": "Optional language code for results such as en, de, or fr." + } + }, + "contracts": { + "webSearchProviders": ["searxng"] + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "webSearch": { + "type": "object", + "additionalProperties": false, + "properties": { + "baseUrl": { + "type": ["string", "object"] + }, + "categories": { + "type": "string" + }, + "language": { + "type": "string" + } + } + } + } + } +} diff --git a/extensions/searxng/package.json b/extensions/searxng/package.json new file mode 100644 index 00000000000..3e65a0ff993 --- /dev/null +++ b/extensions/searxng/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/searxng-plugin", + "version": "2026.4.1", + "private": true, + "description": "OpenClaw SearXNG plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/searxng/src/config.ts b/extensions/searxng/src/config.ts new file mode 100644 index 00000000000..8e83aac9803 --- /dev/null +++ b/extensions/searxng/src/config.ts @@ -0,0 +1,85 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + normalizeResolvedSecretInputString, + normalizeSecretInput, +} from "openclaw/plugin-sdk/secret-input"; + +type SearxngPluginConfig = { + webSearch?: { + baseUrl?: unknown; + categories?: string; + language?: string; + }; +}; + +function normalizeConfiguredString(value: unknown, path: string): string | undefined { + try { + return normalizeSecretInput( + normalizeResolvedSecretInputString({ + value, + path, + }), + ); + } catch { + return undefined; + } +} + +function readInlineEnvSecretRefValue(value: unknown, env: NodeJS.ProcessEnv): string | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const record = value as { source?: unknown; id?: unknown }; + if (record.source !== "env" || typeof record.id !== "string") { + return undefined; + } + return normalizeSecretInput(env[record.id]); +} + +function normalizeTrimmedString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function normalizeBaseUrl(value: string | undefined): string | undefined { + return value?.replace(/\/+$/u, "") || undefined; +} + +export function resolveSearxngWebSearchConfig( + config?: OpenClawConfig, +): SearxngPluginConfig["webSearch"] | undefined { + const pluginConfig = config?.plugins?.entries?.searxng?.config as SearxngPluginConfig | undefined; + const webSearch = pluginConfig?.webSearch; + if (webSearch && typeof webSearch === "object" && !Array.isArray(webSearch)) { + return webSearch; + } + return undefined; +} + +export function resolveSearxngBaseUrl( + config?: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const webSearch = resolveSearxngWebSearchConfig(config); + return ( + normalizeBaseUrl( + normalizeConfiguredString( + webSearch?.baseUrl, + "plugins.entries.searxng.config.webSearch.baseUrl", + ), + ) ?? + normalizeBaseUrl(readInlineEnvSecretRefValue(webSearch?.baseUrl, env)) ?? + normalizeBaseUrl(normalizeSecretInput(env.SEARXNG_BASE_URL)) + ); +} + +export function resolveSearxngCategories(config?: OpenClawConfig): string | undefined { + return normalizeTrimmedString(resolveSearxngWebSearchConfig(config)?.categories); +} + +export function resolveSearxngLanguage(config?: OpenClawConfig): string | undefined { + return normalizeTrimmedString(resolveSearxngWebSearchConfig(config)?.language); +} diff --git a/extensions/searxng/src/searxng-client.test.ts b/extensions/searxng/src/searxng-client.test.ts new file mode 100644 index 00000000000..7bff1f7a24e --- /dev/null +++ b/extensions/searxng/src/searxng-client.test.ts @@ -0,0 +1,92 @@ +import type { LookupFn } from "openclaw/plugin-sdk/ssrf-runtime"; +import { describe, expect, it, vi } from "vitest"; +import { __testing } from "./searxng-client.js"; + +function createLookupFn(addresses: Array<{ address: string; family: number }>): LookupFn { + return vi.fn(async (_hostname: string, options?: unknown) => { + if (typeof options === "number" || !options || !(options as { all?: boolean }).all) { + return addresses[0]; + } + return addresses; + }) as unknown as LookupFn; +} + +describe("searxng client", () => { + it("preserves a configured base-path prefix when building the search URL", () => { + expect( + __testing.buildSearxngSearchUrl({ + baseUrl: "https://search.example.com/searxng", + query: "openclaw", + categories: "general,news", + language: "en", + }), + ).toBe( + "https://search.example.com/searxng/search?q=openclaw&format=json&categories=general%2Cnews&language=en", + ); + }); + + it("parses SearXNG JSON results and applies the requested count cap", () => { + expect( + __testing.parseSearxngResponseText( + JSON.stringify({ + results: [ + { title: "One", url: "https://example.com/1", content: "A" }, + { title: "Two", url: "https://example.com/2", content: "B" }, + ], + }), + 1, + ), + ).toEqual([{ title: "One", url: "https://example.com/1", content: "A" }]); + }); + + it("drops malformed result rows instead of failing the whole response", () => { + expect( + __testing.parseSearxngResponseText( + JSON.stringify({ + results: [ + { title: "One", url: "https://example.com/1", content: "A" }, + { title: { text: "bad" }, url: "https://example.com/2" }, + { title: "Three", url: 3, content: "bad-url" }, + { title: "Four", url: "https://example.com/4", content: { text: "bad" } }, + ], + }), + 10, + ), + ).toEqual([ + { title: "One", url: "https://example.com/1", content: "A" }, + { title: "Four", url: "https://example.com/4", content: undefined }, + ]); + }); + + it("rejects invalid JSON bodies", () => { + expect(() => __testing.parseSearxngResponseText("{", 5)).toThrow( + "SearXNG returned invalid JSON.", + ); + }); + + it("allows https public hosts", async () => { + await expect( + __testing.validateSearxngBaseUrl("https://search.example.com/searxng"), + ).resolves.toBeUndefined(); + }); + + it("allows cleartext private-network hosts", async () => { + await expect( + __testing.validateSearxngBaseUrl( + "http://matrix-synapse:8080", + createLookupFn([{ address: "10.0.0.5", family: 4 }]), + ), + ).resolves.toBeUndefined(); + }); + + it("rejects cleartext public hosts", async () => { + await expect( + __testing.validateSearxngBaseUrl( + "http://search.example.com:8080", + createLookupFn([{ address: "93.184.216.34", family: 4 }]), + ), + ).rejects.toThrow( + "SearXNG HTTP base URL must target a trusted private or loopback host. Use https:// for public hosts.", + ); + }); +}); diff --git a/extensions/searxng/src/searxng-client.ts b/extensions/searxng/src/searxng-client.ts new file mode 100644 index 00000000000..4ccc51faad4 --- /dev/null +++ b/extensions/searxng/src/searxng-client.ts @@ -0,0 +1,236 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + DEFAULT_CACHE_TTL_MINUTES, + DEFAULT_SEARCH_COUNT, + normalizeCacheKey, + readCache, + readResponseText, + resolveCacheTtlMs, + resolveSearchCount, + resolveSiteName, + resolveTimeoutSeconds, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCache, +} from "openclaw/plugin-sdk/provider-web-search"; +import { + assertHttpUrlTargetsPrivateNetwork, + type LookupFn, +} from "openclaw/plugin-sdk/ssrf-runtime"; +import { + resolveSearxngBaseUrl, + resolveSearxngCategories, + resolveSearxngLanguage, +} from "./config.js"; + +const DEFAULT_TIMEOUT_SECONDS = 20; +const MAX_RESPONSE_BYTES = 1_000_000; + +const SEARXNG_SEARCH_CACHE = new Map< + string, + { value: Record; insertedAt: number; expiresAt: number } +>(); + +type SearxngResult = { + url: string; + title: string; + content?: string; +}; + +type SearxngResponse = { + results?: SearxngResult[]; +}; + +function normalizeSearxngResult(value: unknown): SearxngResult | null { + if (!value || typeof value !== "object") { + return null; + } + + const candidate = value as { url?: unknown; title?: unknown; content?: unknown }; + if (typeof candidate.url !== "string" || typeof candidate.title !== "string") { + return null; + } + + return { + url: candidate.url, + title: candidate.title, + content: typeof candidate.content === "string" ? candidate.content : undefined, + }; +} + +function buildSearxngSearchUrl(params: { + baseUrl: string; + query: string; + categories?: string; + language?: string; +}): string { + const url = new URL(params.baseUrl); + const pathname = url.pathname.endsWith("/") ? `${url.pathname}search` : `${url.pathname}/search`; + url.pathname = pathname; + url.search = ""; + url.searchParams.set("q", params.query); + url.searchParams.set("format", "json"); + if (params.categories) { + url.searchParams.set("categories", params.categories); + } + if (params.language) { + url.searchParams.set("language", params.language); + } + return url.toString(); +} + +async function validateSearxngBaseUrl(baseUrl: string, lookupFn?: LookupFn): Promise { + let parsed: URL; + try { + parsed = new URL(baseUrl); + } catch { + throw new Error("SearXNG base URL must be a valid http:// or https:// URL."); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("SearXNG base URL must use http:// or https://."); + } + + if (parsed.protocol === "http:") { + await assertHttpUrlTargetsPrivateNetwork(parsed.toString(), { + allowPrivateNetwork: true, + lookupFn, + errorMessage: + "SearXNG HTTP base URL must target a trusted private or loopback host. Use https:// for public hosts.", + }); + } +} + +function parseSearxngResponseText(text: string, count: number): SearxngResult[] { + let parsed: unknown; + try { + parsed = JSON.parse(text) as SearxngResponse; + } catch { + throw new Error("SearXNG returned invalid JSON."); + } + + if (!parsed || typeof parsed !== "object") { + return []; + } + + const response = parsed as SearxngResponse; + const rawResults = Array.isArray(response.results) ? response.results : []; + const results: SearxngResult[] = []; + + for (const rawResult of rawResults) { + const result = normalizeSearxngResult(rawResult); + if (result) { + results.push(result); + } + if (results.length >= count) { + break; + } + } + + return results; +} + +export async function runSearxngSearch(params: { + config?: OpenClawConfig; + query: string; + count?: number; + categories?: string; + language?: string; + baseUrl?: string; + timeoutSeconds?: number; + cacheTtlMinutes?: number; +}): Promise> { + const count = resolveSearchCount(params.count, DEFAULT_SEARCH_COUNT); + const categories = params.categories ?? resolveSearxngCategories(params.config); + const language = params.language ?? resolveSearxngLanguage(params.config); + const baseUrl = params.baseUrl ?? resolveSearxngBaseUrl(params.config); + const timeoutSeconds = resolveTimeoutSeconds(params.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS); + const cacheTtlMs = resolveCacheTtlMs(params.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES); + + if (!baseUrl) { + throw new Error( + "SearXNG base URL is not configured. Set SEARXNG_BASE_URL or configure plugins.entries.searxng.config.webSearch.baseUrl.", + ); + } + await validateSearxngBaseUrl(baseUrl); + + const cacheKey = normalizeCacheKey( + JSON.stringify({ + provider: "searxng", + query: params.query, + count, + categories: categories ?? "", + language: language ?? "", + baseUrl, + }), + ); + const cached = readCache(SEARXNG_SEARCH_CACHE, cacheKey); + if (cached) { + return { ...cached.value, cached: true }; + } + + const url = buildSearxngSearchUrl({ + baseUrl, + query: params.query, + categories, + language, + }); + + const startedAt = Date.now(); + const results = await withTrustedWebSearchEndpoint( + { + url, + timeoutSeconds, + init: { + method: "GET", + headers: { + Accept: "application/json", + }, + }, + }, + async (response) => { + if (!response.ok) { + const detail = (await readResponseText(response, { maxBytes: 64_000 })).text; + throw new Error( + `SearXNG search error (${response.status}): ${detail || response.statusText}`, + ); + } + + const body = await readResponseText(response, { maxBytes: MAX_RESPONSE_BYTES }); + if (body.truncated) { + throw new Error("SearXNG response too large."); + } + return parseSearxngResponseText(body.text, count); + }, + ); + + const payload = { + query: params.query, + provider: "searxng", + count: results.length, + tookMs: Date.now() - startedAt, + externalContent: { + untrusted: true, + source: "web_search", + provider: "searxng", + wrapped: true, + }, + results: results.map((result) => ({ + title: wrapWebContent(result.title, "web_search"), + url: result.url, + snippet: result.content ? wrapWebContent(result.content, "web_search") : "", + siteName: resolveSiteName(result.url) || undefined, + })), + } satisfies Record; + + writeCache(SEARXNG_SEARCH_CACHE, cacheKey, payload, cacheTtlMs); + return payload; +} + +export const __testing = { + buildSearxngSearchUrl, + normalizeSearxngResult, + parseSearxngResponseText, + validateSearxngBaseUrl, + SEARXNG_SEARCH_CACHE, +}; diff --git a/extensions/searxng/src/searxng-search-provider.test.ts b/extensions/searxng/src/searxng-search-provider.test.ts new file mode 100644 index 00000000000..19236e76203 --- /dev/null +++ b/extensions/searxng/src/searxng-search-provider.test.ts @@ -0,0 +1,163 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + resolveSearxngBaseUrl, + resolveSearxngCategories, + resolveSearxngLanguage, +} from "./config.js"; + +const { runSearxngSearch } = vi.hoisted(() => ({ + runSearxngSearch: vi.fn(async (params: Record) => params), +})); + +vi.mock("./searxng-client.js", () => ({ + runSearxngSearch, +})); + +describe("searxng web search provider", () => { + let createSearxngWebSearchProvider: typeof import("./searxng-search-provider.js").createSearxngWebSearchProvider; + let plugin: typeof import("../index.js").default; + + beforeAll(async () => { + vi.resetModules(); + ({ createSearxngWebSearchProvider } = await import("./searxng-search-provider.js")); + ({ default: plugin } = await import("../index.js")); + }); + + beforeEach(() => { + runSearxngSearch.mockReset(); + runSearxngSearch.mockImplementation(async (params: Record) => params); + }); + + it("registers a setup-visible web search provider", () => { + const webSearchProviders: unknown[] = []; + + plugin.register({ + registerWebSearchProvider(provider: unknown) { + webSearchProviders.push(provider); + }, + } as never); + + expect(plugin.id).toBe("searxng"); + expect(webSearchProviders).toHaveLength(1); + + const provider = webSearchProviders[0] as Record; + expect(provider.id).toBe("searxng"); + expect(provider.requiresCredential).toBe(true); + expect(provider.envVars).toEqual(["SEARXNG_BASE_URL"]); + expect(provider.onboardingScopes).toEqual(["text-inference"]); + }); + + it("exposes credential metadata and enables the plugin in config", () => { + const provider = createSearxngWebSearchProvider(); + if (!provider.applySelectionConfig) { + throw new Error("Expected applySelectionConfig to be defined"); + } + const applied = provider.applySelectionConfig({}); + + expect(provider.id).toBe("searxng"); + expect(provider.label).toBe("SearXNG Search"); + expect(provider.requiresCredential).toBe(true); + expect(provider.credentialPath).toBe("plugins.entries.searxng.config.webSearch.baseUrl"); + expect(applied.plugins?.entries?.searxng?.enabled).toBe(true); + }); + + it("maps generic tool arguments into SearXNG search params", async () => { + const provider = createSearxngWebSearchProvider(); + const tool = provider.createTool({ + config: { test: true }, + } as never); + if (!tool) { + throw new Error("Expected tool definition"); + } + + const result = await tool.execute({ + query: "openclaw docs", + count: 4, + categories: "general,news", + language: "en", + }); + + expect(runSearxngSearch).toHaveBeenCalledWith({ + config: { test: true }, + query: "openclaw docs", + count: 4, + categories: "general,news", + language: "en", + }); + expect(result).toEqual({ + config: { test: true }, + query: "openclaw docs", + count: 4, + categories: "general,news", + language: "en", + }); + }); + + it("reads base URL from plugin config SecretRef, then env var, stripping trailing slashes", () => { + expect( + resolveSearxngBaseUrl( + { + plugins: { + entries: { + searxng: { + config: { + webSearch: { + baseUrl: { + source: "env", + provider: "default", + id: "SEARXNG_BASE_URL", + }, + }, + }, + }, + }, + }, + } as never, + { SEARXNG_BASE_URL: "http://localhost:8888/" }, + ), + ).toBe("http://localhost:8888"); + + expect( + resolveSearxngBaseUrl({} as never, { + SEARXNG_BASE_URL: "https://search.local/searxng///", + }), + ).toBe("https://search.local/searxng"); + + expect(resolveSearxngBaseUrl({} as never, {})).toBeUndefined(); + }); + + it("reads categories and language from plugin config", () => { + const config = { + plugins: { + entries: { + searxng: { + config: { + webSearch: { + categories: "general,news", + language: "de", + }, + }, + }, + }, + }, + } as never; + + expect(resolveSearxngCategories(config)).toBe("general,news"); + expect(resolveSearxngLanguage(config)).toBe("de"); + }); + + it("persists base URL to plugin config via setConfiguredCredentialValue", () => { + const provider = createSearxngWebSearchProvider(); + const config = {} as Record; + + provider.setConfiguredCredentialValue!(config, "http://search.local:9000"); + + expect( + ( + config as { + plugins?: { entries?: { searxng?: { config?: { webSearch?: { baseUrl?: string } } } } }; + } + ).plugins?.entries?.searxng?.config?.webSearch?.baseUrl, + ).toBe("http://search.local:9000"); + }); +}); diff --git a/extensions/searxng/src/searxng-search-provider.ts b/extensions/searxng/src/searxng-search-provider.ts new file mode 100644 index 00000000000..5a95a93a07d --- /dev/null +++ b/extensions/searxng/src/searxng-search-provider.ts @@ -0,0 +1,76 @@ +import { Type } from "@sinclair/typebox"; +import { + enablePluginInConfig, + getScopedCredentialValue, + readNumberParam, + readStringParam, + resolveProviderWebSearchPluginConfig, + setScopedCredentialValue, + setProviderWebSearchPluginConfigValue, + type WebSearchProviderPlugin, +} from "openclaw/plugin-sdk/provider-web-search"; +import { runSearxngSearch } from "./searxng-client.js"; + +const SearxngSearchSchema = Type.Object( + { + query: Type.String({ description: "Search query string." }), + count: Type.Optional( + Type.Number({ + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, + }), + ), + categories: Type.Optional( + Type.String({ + description: + "Optional comma-separated search categories such as general, news, or science.", + }), + ), + language: Type.Optional( + Type.String({ + description: "Optional language code for results such as en, de, or fr.", + }), + ), + }, + { additionalProperties: false }, +); + +export function createSearxngWebSearchProvider(): WebSearchProviderPlugin { + return { + id: "searxng", + label: "SearXNG Search", + hint: "Self-hosted meta-search with no API key required", + onboardingScopes: ["text-inference"], + requiresCredential: true, + credentialLabel: "SearXNG Base URL", + envVars: ["SEARXNG_BASE_URL"], + placeholder: "http://localhost:8080", + signupUrl: "https://docs.searxng.org/", + autoDetectOrder: 200, + credentialPath: "plugins.entries.searxng.config.webSearch.baseUrl", + inactiveSecretPaths: ["plugins.entries.searxng.config.webSearch.baseUrl"], + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "searxng"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "searxng", value), + getConfiguredCredentialValue: (config) => + resolveProviderWebSearchPluginConfig(config, "searxng")?.baseUrl, + setConfiguredCredentialValue: (configTarget, value) => { + setProviderWebSearchPluginConfigValue(configTarget, "searxng", "baseUrl", value); + }, + applySelectionConfig: (config) => enablePluginInConfig(config, "searxng").config, + createTool: (ctx) => ({ + description: + "Search the web using a self-hosted SearXNG instance. Returns titles, URLs, and snippets.", + parameters: SearxngSearchSchema, + execute: async (args) => + await runSearxngSearch({ + config: ctx.config, + query: readStringParam(args, "query", { required: true }), + count: readNumberParam(args, "count", { integer: true }), + categories: readStringParam(args, "categories"), + language: readStringParam(args, "language"), + }), + }), + }; +} diff --git a/extensions/searxng/web-search-provider.ts b/extensions/searxng/web-search-provider.ts new file mode 100644 index 00000000000..14ddec9bd4c --- /dev/null +++ b/extensions/searxng/web-search-provider.ts @@ -0,0 +1 @@ +export { createSearxngWebSearchProvider } from "./src/searxng-search-provider.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32fa22fbd4d..3fa8d8e1c4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -583,6 +583,8 @@ importers: specifier: workspace:* version: link:../.. + extensions/searxng: {} + extensions/sglang: {} extensions/signal: {} diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index 2610f5f1911..46e6c042db8 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -7,13 +7,21 @@ vi.mock("../runtime.js", () => ({ const getScopedWebSearchCredential = (key: string) => (search?: Record) => (search?.[key] as { apiKey?: unknown } | undefined)?.apiKey; -const getConfiguredPluginWebSearchCredential = +const getConfiguredPluginWebSearchConfig = (pluginId: string) => (config?: Record) => ( config?.plugins as - | { entries?: Record } + | { + entries?: Record< + string, + { config?: { webSearch?: { apiKey?: unknown; baseUrl?: unknown } } } + >; + } | undefined - )?.entries?.[pluginId]?.config?.webSearch?.apiKey; + )?.entries?.[pluginId]?.config?.webSearch; +const getConfiguredPluginWebSearchCredential = + (pluginId: string) => (config?: Record) => + getConfiguredPluginWebSearchConfig(pluginId)(config)?.apiKey; const mockWebSearchProviders = [ { @@ -58,6 +66,15 @@ const mockWebSearchProviders = [ getCredentialValue: getScopedWebSearchCredential("perplexity"), getConfiguredCredentialValue: getConfiguredPluginWebSearchCredential("perplexity"), }, + { + id: "searxng", + envVars: ["SEARXNG_BASE_URL"], + credentialPath: "plugins.entries.searxng.config.webSearch.baseUrl", + getCredentialValue: (search?: Record) => + (search?.searxng as { baseUrl?: unknown } | undefined)?.baseUrl, + getConfiguredCredentialValue: (config?: Record) => + getConfiguredPluginWebSearchConfig("searxng")(config)?.baseUrl, + }, { id: "tavily", envVars: ["TAVILY_API_KEY"], @@ -179,6 +196,24 @@ describe("web search provider config", () => { expect(res.ok).toBe(true); }); + it("accepts searxng provider config on the plugin-owned path", () => { + const res = validateConfigObjectWithPlugins( + buildWebSearchProviderConfig({ + enabled: true, + provider: "searxng", + providerConfig: { + baseUrl: { + source: "env", + provider: "default", + id: "SEARXNG_BASE_URL", + }, + }, + }), + ); + + expect(res.ok).toBe(true); + }); + it("rejects legacy scoped Tavily config", () => { const res = validateConfigObjectWithPlugins({ tools: { @@ -261,6 +296,7 @@ describe("web search provider auto-detection", () => { delete process.env.MOONSHOT_API_KEY; delete process.env.PERPLEXITY_API_KEY; delete process.env.OPENROUTER_API_KEY; + delete process.env.SEARXNG_BASE_URL; delete process.env.TAVILY_API_KEY; delete process.env.XAI_API_KEY; delete process.env.KIMI_API_KEY; @@ -296,6 +332,11 @@ describe("web search provider auto-detection", () => { expect(resolveSearchProvider({})).toBe("firecrawl"); }); + it("auto-detects searxng when only SEARXNG_BASE_URL is set", () => { + process.env.SEARXNG_BASE_URL = "http://localhost:8080"; + expect(resolveSearchProvider({})).toBe("searxng"); + }); + it("auto-detects kimi when only KIMI_API_KEY is set", () => { process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("kimi"); diff --git a/src/plugins/web-search-providers.test.ts b/src/plugins/web-search-providers.test.ts index 39bafd65e2b..b3e6fe07b9e 100644 --- a/src/plugins/web-search-providers.test.ts +++ b/src/plugins/web-search-providers.test.ts @@ -11,6 +11,7 @@ const EXPECTED_BUNDLED_WEB_SEARCH_PROVIDER_KEYS = [ "xai:grok", "moonshot:kimi", "perplexity:perplexity", + "searxng:searxng", "tavily:tavily", ] as const; const EXPECTED_BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS = [ @@ -22,6 +23,7 @@ const EXPECTED_BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS = [ "xai", "moonshot", "perplexity", + "searxng", "tavily", ] as const; const EXPECTED_BUNDLED_WEB_SEARCH_CREDENTIAL_PATHS = [ @@ -33,6 +35,7 @@ const EXPECTED_BUNDLED_WEB_SEARCH_CREDENTIAL_PATHS = [ "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;