test: keep searxng web search contract light

Lazy-load the SearXNG web-search client from provider execution and reuse
the shared contract helper for credential and selection wiring. Keep the
shared fast-path contract focused on the single bundled manifest it checks.
This commit is contained in:
Gustavo Madeira Santana
2026-04-17 18:15:59 -04:00
parent 41ee813a45
commit c54464a887
2 changed files with 102 additions and 52 deletions

View File

@@ -1,40 +1,32 @@
import { Type } from "@sinclair/typebox";
import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/param-readers";
import {
enablePluginInConfig,
getScopedCredentialValue,
readNumberParam,
readStringParam,
resolveProviderWebSearchPluginConfig,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search";
import { runSearxngSearch } from "./searxng-client.js";
} from "openclaw/plugin-sdk/provider-web-search-contract";
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.",
}),
),
const SEARXNG_CREDENTIAL_PATH = "plugins.entries.searxng.config.webSearch.baseUrl";
const SearxngSearchSchema = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "number",
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 },
);
additionalProperties: false,
} satisfies Record<string, unknown>;
export function createSearxngWebSearchProvider(): WebSearchProviderPlugin {
return {
@@ -48,29 +40,27 @@ export function createSearxngWebSearchProvider(): WebSearchProviderPlugin {
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,
credentialPath: SEARXNG_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: SEARXNG_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "searxng" },
configuredCredential: { pluginId: "searxng", field: "baseUrl" },
selectionPluginId: "searxng",
}),
createTool: (ctx) => ({
description:
"Search the web using a self-hosted SearXNG instance. Returns titles, URLs, and snippets.",
parameters: SearxngSearchSchema,
execute: async (args) =>
await runSearxngSearch({
execute: async (args) => {
const { runSearxngSearch } = await import("./searxng-client.js");
return 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"),
}),
});
},
}),
};
}

View File

@@ -1,10 +1,13 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { resolveManifestContractOwnerPluginId } from "../../../src/plugins/manifest-registry.js";
import { resolveBundledPluginsDir } from "../../../src/plugins/bundled-dir.js";
import {
resolveBundledExplicitRuntimeWebSearchProvidersFromPublicArtifacts,
resolveBundledExplicitWebSearchProvidersFromPublicArtifacts,
} from "../../../src/plugins/web-provider-public-artifacts.explicit.js";
import { normalizeOptionalLowercaseString } from "../../../src/shared/string-coerce.js";
type ComparableProvider = {
pluginId: string;
@@ -24,6 +27,64 @@ type ComparableProvider = {
hasResolveRuntimeMetadata: boolean;
};
type MinimalBundledPluginManifest = {
id?: unknown;
contracts?: {
webSearchProviders?: unknown;
};
};
const bundledWebSearchManifestContracts = new Map<
string,
{ pluginId: string; webSearchProviderIds: string[] } | null
>();
function readBundledWebSearchManifestContract(pluginId: string) {
if (bundledWebSearchManifestContracts.has(pluginId)) {
return bundledWebSearchManifestContracts.get(pluginId) ?? null;
}
const bundledPluginsDir = resolveBundledPluginsDir();
if (!bundledPluginsDir) {
bundledWebSearchManifestContracts.set(pluginId, null);
return null;
}
const manifestPath = path.join(bundledPluginsDir, pluginId, "openclaw.plugin.json");
const manifest = JSON.parse(
fs.readFileSync(manifestPath, "utf8"),
) as MinimalBundledPluginManifest;
const manifestPluginId = typeof manifest.id === "string" ? manifest.id : "";
const webSearchProviderIds = Array.isArray(manifest.contracts?.webSearchProviders)
? manifest.contracts.webSearchProviders.filter(
(providerId): providerId is string => typeof providerId === "string",
)
: [];
const contract = { pluginId: manifestPluginId, webSearchProviderIds };
bundledWebSearchManifestContracts.set(pluginId, contract);
return contract;
}
function resolveBundledManifestWebSearchOwnerPluginId(params: {
pluginId: string;
providerId: string;
}): string | undefined {
const normalizedProviderId = normalizeOptionalLowercaseString(params.providerId);
if (!normalizedProviderId) {
return undefined;
}
const contract = readBundledWebSearchManifestContract(params.pluginId);
if (
!contract?.webSearchProviderIds.some(
(candidate) => normalizeOptionalLowercaseString(candidate) === normalizedProviderId,
)
) {
return undefined;
}
return contract.pluginId || undefined;
}
function toComparableEntry(params: {
pluginId: string;
provider: {
@@ -87,10 +148,9 @@ export function describeBundledWebSearchFastPathContract(pluginId: string) {
expect(providers.length).toBeGreaterThan(0);
for (const provider of providers) {
expect(
resolveManifestContractOwnerPluginId({
contract: "webSearchProviders",
value: provider.id,
origin: "bundled",
resolveBundledManifestWebSearchOwnerPluginId({
pluginId,
providerId: provider.id,
}),
).toBe(pluginId);
}