mirror of https://github.com/openclaw/openclaw.git
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 <vincentkoc@ieee.org>
This commit is contained in:
parent
3f67581e50
commit
32ae841098
|
|
@ -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());
|
||||
},
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "@openclaw/searxng-plugin",
|
||||
"version": "2026.4.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw SearXNG plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.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);
|
||||
}
|
||||
|
|
@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, unknown>; 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<void> {
|
||||
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<Record<string, unknown>> {
|
||||
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<string, unknown>;
|
||||
|
||||
writeCache(SEARXNG_SEARCH_CACHE, cacheKey, payload, cacheTtlMs);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
buildSearxngSearchUrl,
|
||||
normalizeSearxngResult,
|
||||
parseSearxngResponseText,
|
||||
validateSearxngBaseUrl,
|
||||
SEARXNG_SEARCH_CACHE,
|
||||
};
|
||||
|
|
@ -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<string, unknown>) => 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<string, unknown>) => 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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"),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { createSearxngWebSearchProvider } from "./src/searxng-search-provider.js";
|
||||
|
|
@ -583,6 +583,8 @@ importers:
|
|||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
extensions/searxng: {}
|
||||
|
||||
extensions/sglang: {}
|
||||
|
||||
extensions/signal: {}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,21 @@ vi.mock("../runtime.js", () => ({
|
|||
|
||||
const getScopedWebSearchCredential = (key: string) => (search?: Record<string, unknown>) =>
|
||||
(search?.[key] as { apiKey?: unknown } | undefined)?.apiKey;
|
||||
const getConfiguredPluginWebSearchCredential =
|
||||
const getConfiguredPluginWebSearchConfig =
|
||||
(pluginId: string) => (config?: Record<string, unknown>) =>
|
||||
(
|
||||
config?.plugins as
|
||||
| { entries?: Record<string, { config?: { webSearch?: { apiKey?: unknown } } }> }
|
||||
| {
|
||||
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<string, unknown>) =>
|
||||
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<string, unknown>) =>
|
||||
(search?.searxng as { baseUrl?: unknown } | undefined)?.baseUrl,
|
||||
getConfiguredCredentialValue: (config?: Record<string, unknown>) =>
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue