Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
# SearXNG OpenClaw plugin
Official OpenClaw plugin for SearXNG.
## Install
```sh
openclaw plugins install @openclaw/searxng-plugin
```
## Docs
See `docs/tools/searxng-search.md` in the OpenClaw repository, or the published docs at `https://docs.openclaw.ai/tools/searxng-search`.

View File

@@ -0,0 +1,12 @@
// Searxng plugin entrypoint registers its OpenClaw integration.
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());
},
});

12
extensions/searxng/npm-shrinkwrap.json generated Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "@openclaw/searxng-plugin",
"version": "2026.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/searxng-plugin",
"version": "2026.6.11"
}
}
}

View File

@@ -0,0 +1,45 @@
{
"id": "searxng",
"icon": "https://cdn.simpleicons.org/searxng",
"activation": {
"onStartup": false
},
"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"
}
}
}
}
}
}

View File

@@ -0,0 +1,36 @@
{
"name": "@openclaw/searxng-plugin",
"version": "2026.6.11",
"description": "OpenClaw SearXNG plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"install": {
"clawhubSpec": "clawhub:@openclaw/searxng-plugin",
"npmSpec": "@openclaw/searxng-plugin",
"defaultChoice": "npm",
"minHostVersion": ">=2026.6.9",
"allowInvalidConfigRecovery": true
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"build": {
"openclawVersion": "2026.6.11",
"bundledDist": false
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
},
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
}
}

View File

@@ -0,0 +1,86 @@
// Searxng helper module supports config behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
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;
}
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);
}

View File

@@ -0,0 +1,270 @@
// Searxng tests cover searxng client plugin behavior.
import type { LookupFn } from "openclaw/plugin-sdk/ssrf-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
const endpointMockState = vi.hoisted(() => ({
calls: [] as Array<{ url: string; timeoutSeconds: number; init: RequestInit }>,
responses: [] as Response[],
}));
vi.mock("openclaw/plugin-sdk/provider-web-search", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-web-search")>();
const runEndpoint = async (
params: { url: string; timeoutSeconds: number; init: RequestInit },
run: (response: Response) => Promise<unknown>,
) => {
endpointMockState.calls.push(params);
const response = endpointMockState.responses.shift();
if (!response) {
throw new Error("Missing mocked SearXNG response.");
}
return await run(response);
};
return {
...actual,
withSelfHostedWebSearchEndpoint: vi.fn(runEndpoint),
withTrustedWebSearchEndpoint: vi.fn(runEndpoint),
};
});
import { testing, runSearxngSearch } 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", () => {
beforeEach(() => {
endpointMockState.calls = [];
endpointMockState.responses = [];
testing.SEARXNG_SEARCH_CACHE.clear();
});
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("retries an empty category search with general results", async () => {
endpointMockState.responses.push(
new Response(JSON.stringify({ results: [] }), { status: 200 }),
new Response(
JSON.stringify({
results: [
{
title: "Beijing hourly weather",
url: "https://example.com/weather",
content: "Hourly forecast",
},
],
}),
{ status: 200 },
),
);
const result = await runSearxngSearch({
baseUrl: "http://127.0.0.1:8888",
query: "beijing hourly weather",
categories: "weather",
count: 5,
});
expect(endpointMockState.calls).toHaveLength(2);
expect(new URL(endpointMockState.calls[0].url).searchParams.get("categories")).toBe("weather");
expect(new URL(endpointMockState.calls[1].url).searchParams.get("categories")).toBe("general");
expect(result.provider).toBe("searxng");
expect(result.query).toBe("beijing hourly weather");
expect(result.count).toBe(1);
const results = result.results as Array<{
url?: string;
siteName?: string;
title?: string;
snippet?: string;
}>;
expect(results).toHaveLength(1);
expect(results[0]?.url).toBe("https://example.com/weather");
expect(results[0]?.siteName).toBe("example.com");
expect(results[0]?.title).toContain("Beijing hourly weather");
expect(results[0]?.snippet).toContain("Hourly forecast");
expect(result.externalContent).toEqual({
provider: "searxng",
source: "web_search",
untrusted: true,
wrapped: true,
});
});
it("does not retry empty general category searches", async () => {
endpointMockState.responses.push(
new Response(JSON.stringify({ results: [] }), { status: 200 }),
);
const result = await runSearxngSearch({
baseUrl: "http://127.0.0.1:8888",
query: "openclaw",
categories: "general",
count: 5,
});
expect(endpointMockState.calls).toHaveLength(1);
const { tookMs, ...stableResult } = result;
expect(typeof tookMs).toBe("number");
expect(stableResult).toEqual({
query: "openclaw",
provider: "searxng",
count: 0,
externalContent: {
provider: "searxng",
source: "web_search",
untrusted: true,
wrapped: true,
},
results: [],
});
});
it("detects category searches that should retry with general", () => {
expect(testing.shouldRetryEmptyCategorySearchWithGeneral("weather")).toBe(true);
expect(testing.shouldRetryEmptyCategorySearchWithGeneral("weather,news")).toBe(true);
expect(testing.shouldRetryEmptyCategorySearchWithGeneral("general")).toBe(false);
expect(testing.shouldRetryEmptyCategorySearchWithGeneral("general,news")).toBe(false);
expect(testing.shouldRetryEmptyCategorySearchWithGeneral(undefined)).toBe(false);
});
it("preserves img_src from image search results", () => {
expect(
testing.parseSearxngResponseText(
JSON.stringify({
results: [
{
title: "Kitten",
url: "https://example.com/kitten",
content: "A cute kitten",
img_src: "https://cdn.example.com/kitten.jpg",
},
{
title: "No Image",
url: "https://example.com/text",
content: "Text only",
},
{
title: "Bad Image",
url: "https://example.com/bad",
img_src: { url: "https://cdn.example.com/bad.jpg" },
},
],
}),
10,
),
).toEqual([
{
title: "Kitten",
url: "https://example.com/kitten",
content: "A cute kitten",
img_src: "https://cdn.example.com/kitten.jpg",
},
{
title: "No Image",
url: "https://example.com/text",
content: "Text only",
img_src: undefined,
},
{
title: "Bad Image",
url: "https://example.com/bad",
content: undefined,
img_src: undefined,
},
]);
});
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",
createLookupFn([{ address: "93.184.216.34", family: 4 }]),
),
).resolves.toBe("strict");
});
it("allows cleartext private-network hosts", async () => {
await expect(
testing.validateSearxngBaseUrl(
"http://matrix-synapse:8080",
createLookupFn([{ address: "10.0.0.5", family: 4 }]),
),
).resolves.toBe("selfHosted");
});
it("routes https private-network hosts through the self-hosted guard", async () => {
await expect(
testing.validateSearxngBaseUrl(
"https://search.internal/searxng",
createLookupFn([{ address: "10.0.0.5", family: 4 }]),
),
).resolves.toBe("selfHosted");
});
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.",
);
});
});

View File

@@ -0,0 +1,325 @@
// Searxng plugin module implements searxng client behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
DEFAULT_CACHE_TTL_MINUTES,
DEFAULT_SEARCH_COUNT,
normalizeCacheKey,
readCache,
readResponseText,
resolveCacheTtlMs,
resolveSearchCount,
resolveSiteName,
resolveTimeoutSeconds,
withSelfHostedWebSearchEndpoint,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCache,
} from "openclaw/plugin-sdk/provider-web-search";
import {
assertHttpUrlTargetsPrivateNetwork,
isBlockedHostnameOrIp,
isPrivateIpAddress,
resolvePinnedHostnameWithPolicy,
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;
type SearxngEndpointMode = "selfHosted" | "strict";
const SEARXNG_SEARCH_CACHE = new Map<
string,
{ value: Record<string, unknown>; insertedAt: number; expiresAt: number }
>();
type SearxngResult = {
url: string;
title: string;
content?: string;
img_src?: 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;
img_src?: 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,
img_src: typeof candidate.img_src === "string" ? candidate.img_src : 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();
}
function shouldRetryEmptyCategorySearchWithGeneral(categories: string | undefined): boolean {
if (!categories) {
return false;
}
const normalized = categories
.split(",")
.map((category) => category.trim().toLowerCase())
.filter((category) => category.length > 0);
return normalized.length > 0 && !normalized.includes("general");
}
async function searxngEndpointTargetsPrivateNetwork(
url: URL,
lookupFn?: LookupFn,
): Promise<boolean> {
if (isBlockedHostnameOrIp(url.hostname)) {
return true;
}
try {
const pinned = await resolvePinnedHostnameWithPolicy(url.hostname, {
lookupFn,
policy: {
allowPrivateNetwork: true,
allowRfc2544BenchmarkRange: true,
},
});
return pinned.addresses.every((address) => isPrivateIpAddress(address));
} catch {
return false;
}
}
async function validateSearxngBaseUrl(
baseUrl: string,
lookupFn?: LookupFn,
): Promise<SearxngEndpointMode> {
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(), {
dangerouslyAllowPrivateNetwork: true,
lookupFn,
errorMessage:
"SearXNG HTTP base URL must target a trusted private or loopback host. Use https:// for public hosts.",
});
return "selfHosted";
}
return (await searxngEndpointTargetsPrivateNetwork(parsed, lookupFn)) ? "selfHosted" : "strict";
}
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;
}
async function fetchSearxngResults(params: {
baseUrl: string;
query: string;
categories?: string;
language?: string;
timeoutSeconds: number;
count: number;
endpointMode: SearxngEndpointMode;
}): Promise<SearxngResult[]> {
const url = buildSearxngSearchUrl({
baseUrl: params.baseUrl,
query: params.query,
categories: params.categories,
language: params.language,
});
const withEndpoint =
params.endpointMode === "selfHosted"
? withSelfHostedWebSearchEndpoint
: withTrustedWebSearchEndpoint;
return await withEndpoint(
{
url,
timeoutSeconds: params.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, params.count);
},
);
}
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.",
);
}
const endpointMode = 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 startedAt = Date.now();
let results = await fetchSearxngResults({
baseUrl,
query: params.query,
categories,
language,
timeoutSeconds,
count,
endpointMode,
});
if (results.length === 0 && shouldRetryEmptyCategorySearchWithGeneral(categories)) {
results = await fetchSearxngResults({
baseUrl,
query: params.query,
categories: "general",
language,
timeoutSeconds,
count,
endpointMode,
});
}
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,
img_src: result.img_src || undefined,
})),
} satisfies Record<string, unknown>;
writeCache(SEARXNG_SEARCH_CACHE, cacheKey, payload, cacheTtlMs);
return payload;
}
export const testing = {
buildSearxngSearchUrl,
normalizeSearxngResult,
parseSearxngResponseText,
shouldRetryEmptyCategorySearchWithGeneral,
validateSearxngBaseUrl,
SEARXNG_SEARCH_CACHE,
};
export { testing as __testing };

View File

@@ -0,0 +1,192 @@
// Searxng tests cover searxng search provider plugin behavior.
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 () => {
({ 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("rejects fractional and out-of-range counts before searching", async () => {
const provider = createSearxngWebSearchProvider();
const tool = provider.createTool({
config: { test: true },
} as never);
if (!tool) {
throw new Error("Expected tool definition");
}
await expect(tool.execute({ query: "openclaw docs", count: 4.5 })).rejects.toThrow(
"count must be an integer from 1 to 10.",
);
await expect(tool.execute({ query: "openclaw docs", count: 11 })).rejects.toThrow(
"count must be an integer from 1 to 10.",
);
expect(runSearxngSearch).not.toHaveBeenCalled();
});
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("exposes a credentialNote with JSON format guidance", () => {
const provider = createSearxngWebSearchProvider();
expect(provider.credentialNote).toContain("json format enabled");
expect(provider.credentialNote).toContain("search.formats");
});
it("persists base URL to plugin config via setConfiguredCredentialValue", () => {
const provider = createSearxngWebSearchProvider();
const config = {} as Record<string, unknown>;
const setConfiguredCredentialValue = provider.setConfiguredCredentialValue;
if (!setConfiguredCredentialValue) {
throw new Error("Expected SearXNG provider setConfiguredCredentialValue");
}
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");
});
});

View File

@@ -0,0 +1,77 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
// Searxng provider module implements model/runtime integration.
import { readPositiveIntegerParam, readStringParam } from "openclaw/plugin-sdk/param-readers";
import {
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-contract";
const SEARXNG_CREDENTIAL_PATH = "plugins.entries.searxng.config.webSearch.baseUrl";
const loadSearxngClientModule = createLazyRuntimeModule(() => import("./searxng-client.js"));
const SearxngSearchSchema = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "integer",
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
},
categories: {
type: "string",
description: "Optional comma-separated search categories such as general, news, or science.",
},
language: {
type: "string",
description: "Optional language code for results such as en, de, or fr.",
},
},
additionalProperties: false,
} satisfies Record<string, unknown>;
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: SEARXNG_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: SEARXNG_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "searxng" },
configuredCredential: { pluginId: "searxng", field: "baseUrl" },
selectionPluginId: "searxng",
}),
credentialNote: [
"For the SearXNG JSON API to work, make sure your SearXNG instance",
"has the json format enabled in its settings.yml under search.formats.",
].join("\n"),
createTool: (ctx) => ({
description:
"Search the web using a self-hosted SearXNG instance. Returns titles, URLs, and snippets.",
parameters: SearxngSearchSchema,
execute: async (args) => {
const { runSearxngSearch } = await loadSearxngClientModule();
return await runSearxngSearch({
config: ctx.config,
query: readStringParam(args, "query", { required: true }),
count: readPositiveIntegerParam(args, "count", {
max: 10,
message: "count must be an integer from 1 to 10.",
}),
categories: readStringParam(args, "categories"),
language: readStringParam(args, "language"),
});
},
}),
};
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -0,0 +1,2 @@
// Searxng provider module implements model/runtime integration.
export { createSearxngWebSearchProvider } from "./src/searxng-search-provider.js";