diff --git a/extensions/searxng/src/searxng-search-provider.ts b/extensions/searxng/src/searxng-search-provider.ts index 5a95a93a07d..708e55ad93f 100644 --- a/extensions/searxng/src/searxng-search-provider.ts +++ b/extensions/searxng/src/searxng-search-provider.ts @@ -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; 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"), - }), + }); + }, }), }; } diff --git a/test/helpers/plugins/bundled-web-search-fast-path-contract.ts b/test/helpers/plugins/bundled-web-search-fast-path-contract.ts index 3b0b8d5f3bc..ba197c71141 100644 --- a/test/helpers/plugins/bundled-web-search-fast-path-contract.ts +++ b/test/helpers/plugins/bundled-web-search-fast-path-contract.ts @@ -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); }