From 94c72acd6b002ae3b224f6d765ab20b0d91c0cb3 Mon Sep 17 00:00:00 2001 From: Dave Morin Date: Tue, 16 Jun 2026 15:02:07 +0200 Subject: [PATCH] Keep key-free web search providers opt-in (#93616) Merged via squash. Prepared head SHA: 5de02da03824ca7588bcca3dd3e7cb0997a9bcac Co-authored-by: davemorin <78139+davemorin@users.noreply.github.com> Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com> Reviewed-by: @vincentkoc (cherry picked from commit c48b36a2556e6b6c7634a54c139b67e031bc7c6a) # Conflicts: # docs/tools/web.md --- docs/reference/api-usage-costs.md | 2 +- docs/tools/duckduckgo-search.md | 12 +- docs/tools/ollama-search.md | 5 +- docs/tools/parallel-search.md | 10 +- docs/tools/searxng-search.md | 8 +- docs/tools/web.md | 25 +- extensions/parallel/index.ts | 4 +- ...arallel-free-web-search-provider.shared.ts | 9 +- .../parallel-free-web-search-provider.test.ts | 4 +- .../src/parallel-mcp-search.runtime.ts | 4 +- src/commands/onboard-search.providers.test.ts | 5 +- src/config/config.web-search-provider.test.ts | 4 +- src/flows/search-setup.ts | 29 +- src/secrets/runtime-web-tools.shared.ts | 21 +- src/secrets/runtime-web-tools.test.ts | 23 +- src/secrets/runtime-web-tools.ts | 2 + src/web-search/runtime.test.ts | 320 ++++++++++++++---- src/web-search/runtime.ts | 129 +++++-- 18 files changed, 463 insertions(+), 153 deletions(-) diff --git a/docs/reference/api-usage-costs.md b/docs/reference/api-usage-costs.md index 2282e459a735..cb2f45c4a4ec 100644 --- a/docs/reference/api-usage-costs.md +++ b/docs/reference/api-usage-costs.md @@ -140,7 +140,7 @@ See [Memory](/concepts/memory). - **Ollama Web Search**: key-free for a reachable signed-in local Ollama host; direct `https://ollama.com` search uses `OLLAMA_API_KEY`, and auth-protected hosts can reuse normal Ollama provider bearer auth - **Perplexity Search API**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `plugins.entries.perplexity.config.webSearch.apiKey` - **Tavily**: `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey` -- **DuckDuckGo**: key-free fallback (no API billing, but unofficial and HTML-based) +- **DuckDuckGo**: key-free provider when explicitly selected (no API billing, but unofficial and HTML-based) - **SearXNG**: `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (key-free/self-hosted; no hosted API billing) Legacy `tools.web.search.*` provider paths still load through the temporary compatibility shim, but they are no longer the recommended config surface. diff --git a/docs/tools/duckduckgo-search.md b/docs/tools/duckduckgo-search.md index 6de86dea99db..46bb33fce133 100644 --- a/docs/tools/duckduckgo-search.md +++ b/docs/tools/duckduckgo-search.md @@ -1,9 +1,9 @@ --- -summary: "DuckDuckGo web search -- key-free fallback provider (experimental, HTML-based)" +summary: "DuckDuckGo web search -- key-free provider (experimental, HTML-based)" read_when: - You want a web search provider that requires no API key - You want to use DuckDuckGo for web_search - - You need a zero-config search fallback + - You want an explicitly selected key-free search provider title: "DuckDuckGo search" --- @@ -85,16 +85,16 @@ parameters override config values per-query. ## Notes -- **No API key** - works out of the box, zero configuration +- **No API key** - works after you select DuckDuckGo as your `web_search` + provider - **Experimental** - gathers results from DuckDuckGo's non-JavaScript HTML search pages, not an official API or SDK - **Bot-challenge risk** - DuckDuckGo may serve CAPTCHAs or block requests under heavy or automated use - **HTML parsing** - results depend on page structure, which can change without notice -- **Auto-detection order** - DuckDuckGo is the first key-free fallback - (order 100) in auto-detection. API-backed providers with configured keys run - first, then Ollama Web Search (order 110), then SearXNG (order 200) +- **Explicit selection** - OpenClaw does not choose DuckDuckGo automatically + when no API-backed provider is configured - **SafeSearch defaults to moderate** when not configured diff --git a/docs/tools/ollama-search.md b/docs/tools/ollama-search.md index 3086863ce148..00f3c2afb042 100644 --- a/docs/tools/ollama-search.md +++ b/docs/tools/ollama-search.md @@ -140,8 +140,9 @@ Direct hosted Ollama Web Search: that env key to the local host. - OpenClaw warns during setup if Ollama is unreachable or not signed in, but it does not block selection. -- Runtime auto-detect can fall back to Ollama Web Search when no higher-priority - credentialed provider is configured. +- OpenClaw does not auto-select Ollama Web Search when no higher-priority + credentialed provider is configured; choose it explicitly with + `tools.web.search.provider: "ollama"`. - Local Ollama daemon hosts use the local proxy endpoint `/api/experimental/web_search`, which signs and forwards to Ollama Cloud. - `https://ollama.com` hosts use the public hosted endpoint diff --git a/docs/tools/parallel-search.md b/docs/tools/parallel-search.md index 7df62a7c276c..c114bdc5723a 100644 --- a/docs/tools/parallel-search.md +++ b/docs/tools/parallel-search.md @@ -11,8 +11,8 @@ OpenClaw bundles two [Parallel](https://parallel.ai/) `web_search` providers: - **Parallel Search (Free)** (`parallel-free`) -- Parallel's free [Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp). Requires no - account or API key. OpenClaw selects it automatically when no other web search - provider is configured, so `web_search` works without setup. + account or API key. Select it explicitly when you want Parallel's hosted + key-free search path. - **Parallel Search** (`parallel`) -- Parallel's paid Search API. Requires a `PARALLEL_API_KEY` and offers higher rate limits and objective tuning. @@ -29,8 +29,8 @@ explicitly. ## API key (paid provider) -`parallel-free` requires no setup. The paid `parallel` provider needs an API -key: +`parallel-free` requires no API key, but it still must be selected as the +managed provider. The paid `parallel` provider needs an API key: @@ -66,6 +66,8 @@ key: tools: { web: { search: { + // Use "parallel-free" for the free Search MCP, or "parallel" for + // the paid API-backed provider shown here. provider: "parallel", }, }, diff --git a/docs/tools/searxng-search.md b/docs/tools/searxng-search.md index 9241d1e47409..eb364007f64f 100644 --- a/docs/tools/searxng-search.md +++ b/docs/tools/searxng-search.md @@ -120,9 +120,9 @@ key wins first). - **Network guard** -- private/internal SearXNG endpoints opt in to private-network access; public `https://` SearXNG endpoints keep strict SSRF protection -- **Auto-detection order** -- SearXNG is checked last (order 200) in - auto-detection. API-backed providers with configured keys run first, then - DuckDuckGo (order 100), then Ollama Web Search (order 110) +- **Auto-detection order** -- SearXNG is checked after API-backed providers + with configured keys (order 200). Key-free providers such as DuckDuckGo or + Ollama Web Search are not auto-selected without an explicit provider choice - **Self-hosted** -- you control the instance, queries, and upstream search engines - **Categories** default to `general` when not configured - **Category fallback** -- if a non-`general` category request succeeds but @@ -137,5 +137,5 @@ key wins first). ## Related - [Web Search overview](/tools/web) -- all providers and auto-detection -- [DuckDuckGo Search](/tools/duckduckgo-search) -- another key-free fallback +- [DuckDuckGo Search](/tools/duckduckgo-search) -- another key-free provider - [Brave Search](/tools/brave-search) -- structured results with free tier diff --git a/docs/tools/web.md b/docs/tools/web.md index d384c657563a..b47765ea7340 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -6,7 +6,7 @@ read_when: - You want to enable or configure web_search - You want to enable or configure x_search - You need to choose a search provider - - You want to understand auto-detection and provider fallback + - You want to understand auto-detection and provider selection --- The `web_search` tool searches the web using your configured provider and @@ -60,8 +60,11 @@ local while `web_search` and `x_search` can use xAI Responses under the hood. Structured results with snippets. Supports `llm-context` mode, country/language filters. Free tier available. + + AI-synthesized grounded answers through your Codex app-server account. + - Key-free fallback. No API key needed. Unofficial HTML-based integration. + Key-free provider. No API key needed. Unofficial HTML-based integration. Neural + keyword search with content extraction (highlights, text, summaries). @@ -88,7 +91,7 @@ local while `web_search` and `x_search` can use xAI Responses under the hood. Paid Parallel Search API (`PARALLEL_API_KEY`); higher rate limits and objective tuning. - Zero-config default. Parallel's free Search MCP, with LLM-optimized dense excerpts and no API key. + Key-free opt-in. Parallel's free Search MCP, with LLM-optimized dense excerpts and no API key. Structured results with content extraction controls and domain filtering. @@ -106,6 +109,7 @@ local while `web_search` and `x_search` can use xAI Responses under the hood. | Provider | Result style | Filters | API key | | ------------------------------------------------ | -------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------- | | [Brave](/tools/brave-search) | Structured snippets | Country, language, time, `llm-context` mode | `BRAVE_API_KEY` | +| [Codex Hosted Search](/plugins/codex-harness) | AI-synthesized + source URLs | Domains, context size, user location | None; uses Codex/OpenAI sign-in | | [DuckDuckGo](/tools/duckduckgo-search) | Structured snippets | -- | None (key-free) | | [Exa](/tools/exa-search) | Structured + extracted | Neural/keyword mode, date, content extraction | `EXA_API_KEY` | | [Firecrawl](/tools/firecrawl) | Structured snippets | Via `firecrawl_search` tool | `FIRECRAWL_API_KEY` | @@ -194,15 +198,16 @@ API-backed providers first: 9. **Tavily** -- `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey` (order 70) 10. **Parallel** -- paid Parallel Search API via `PARALLEL_API_KEY` or `plugins.entries.parallel.config.webSearch.apiKey`; optional `plugins.entries.parallel.config.webSearch.baseUrl` overrides the endpoint (order 75) -Key-free fallbacks after that: +Configured endpoint providers after that: -11. **Parallel Search (Free)** -- the zero-config default: works with no account or API key via Parallel's free hosted [Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp) (order 76) -12. **DuckDuckGo** -- key-free HTML fallback with no account or API key (order 100) -13. **Ollama Web Search** -- key-free fallback via your configured local Ollama host when it is reachable and signed in with `ollama signin`; can reuse Ollama provider bearer auth when the host needs it, and can call direct `https://ollama.com` search when configured with `OLLAMA_API_KEY` (order 110) -14. **SearXNG** -- `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (order 200) +11. **SearXNG** -- `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (order 200) -When no API-backed provider is configured, OpenClaw defaults to **Parallel -Search (Free)**, so `web_search` works without an API key. +Key-free providers such as **Parallel Search (Free)**, **DuckDuckGo**, +**Ollama Web Search**, and **Codex Hosted Search** are available only when you +select them explicitly with `tools.web.search.provider` or through +`openclaw configure --section web`. OpenClaw does not send managed +`web_search` queries to a key-free provider just because no API-backed provider +is configured. OpenAI Responses models are an exception: while `tools.web.search.provider` is unset, they use OpenAI's native web search instead of the managed providers diff --git a/extensions/parallel/index.ts b/extensions/parallel/index.ts index c81b035fb3e2..9d57bfe06ded 100644 --- a/extensions/parallel/index.ts +++ b/extensions/parallel/index.ts @@ -7,8 +7,8 @@ export default definePluginEntry({ name: "Parallel Plugin", description: "Bundled Parallel web search plugin", register(api) { - // Free hosted Search MCP (keyless, zero-config default) and the paid v1 REST - // API (requires PARALLEL_API_KEY) are registered as two distinct providers. + // Free hosted Search MCP (keyless, opt-in) and the paid v1 REST API + // (requires PARALLEL_API_KEY) are registered as two distinct providers. api.registerWebSearchProvider(createParallelFreeWebSearchProvider()); api.registerWebSearchProvider(createParallelWebSearchProvider()); }, diff --git a/extensions/parallel/src/parallel-free-web-search-provider.shared.ts b/extensions/parallel/src/parallel-free-web-search-provider.shared.ts index c3973a07a8da..a952edecc6a1 100644 --- a/extensions/parallel/src/parallel-free-web-search-provider.shared.ts +++ b/extensions/parallel/src/parallel-free-web-search-provider.shared.ts @@ -8,16 +8,15 @@ export function createParallelFreeWebSearchProviderBase() { label: "Parallel Search (Free)", hint: "Free web search via Parallel's hosted Search MCP — no API key required", onboardingScopes: [...PARALLEL_FREE_ONBOARDING_SCOPES], - // Keyless: always uses Parallel's free hosted Search MCP. This is the - // zero-config default web_search provider (autoDetectOrder 76 keeps it ahead - // of the other key-free fallbacks: duckduckgo 100, ollama 110). The paid - // `parallel` provider (v1 REST, requires a key) is a separate entry. + // Keyless: always uses Parallel's free hosted Search MCP. Keep it + // selectable, but never auto-detected; users must opt in before search + // traffic is sent to Parallel's hosted free tier. The paid `parallel` + // provider (v1 REST, requires a key) is a separate entry. requiresCredential: false, envVars: [], placeholder: "(no key needed)", signupUrl: "https://parallel.ai", docsUrl: "https://docs.openclaw.ai/tools/parallel-search", - autoDetectOrder: 76, credentialPath: "", ...createWebSearchProviderContractFields({ credentialPath: "", diff --git a/extensions/parallel/src/parallel-free-web-search-provider.test.ts b/extensions/parallel/src/parallel-free-web-search-provider.test.ts index 5150ad7dfb84..a1a24604ec0c 100644 --- a/extensions/parallel/src/parallel-free-web-search-provider.test.ts +++ b/extensions/parallel/src/parallel-free-web-search-provider.test.ts @@ -62,13 +62,13 @@ describe("parallel-free web search provider", () => { endpointMockState.responses = []; }); - it("exposes keyless metadata as the zero-config default", () => { + it("exposes keyless metadata without claiming auto-detect fallback", () => { const provider = createParallelFreeWebSearchProvider(); expect(provider.id).toBe("parallel-free"); expect(provider.label).toBe("Parallel Search (Free)"); expect(provider.requiresCredential).toBe(false); expect(provider.envVars).toEqual([]); - expect(provider.autoDetectOrder).toBe(76); + expect(provider.autoDetectOrder).toBeUndefined(); }); it("advertises the free MCP's tighter 100-char session_id cap in its tool schema", () => { diff --git a/extensions/parallel/src/parallel-mcp-search.runtime.ts b/extensions/parallel/src/parallel-mcp-search.runtime.ts index 83b7a10ba018..989ee7f24120 100644 --- a/extensions/parallel/src/parallel-mcp-search.runtime.ts +++ b/extensions/parallel/src/parallel-mcp-search.runtime.ts @@ -3,8 +3,8 @@ import { createRequire } from "node:module"; import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared"; import { withTrustedWebSearchEndpoint } from "openclaw/plugin-sdk/provider-web-search"; -// Free hosted Search MCP — anonymous-friendly, used when no PARALLEL_API_KEY is -// configured. This is the zero-config default web_search transport. Docs: +// Free hosted Search MCP. This keyless transport is used only after the user +// explicitly selects the `parallel-free` web_search provider. Docs: // https://docs.parallel.ai/integrations/mcp/search-mcp export const PARALLEL_MCP_SEARCH_URL = "https://search.parallel.ai/mcp"; // Initial protocol version we advertise on `initialize`; we then echo whatever diff --git a/src/commands/onboard-search.providers.test.ts b/src/commands/onboard-search.providers.test.ts index d27fa0fcfbb0..3100ffbdaae6 100644 --- a/src/commands/onboard-search.providers.test.ts +++ b/src/commands/onboard-search.providers.test.ts @@ -224,7 +224,7 @@ describe("onboard-search provider resolution", () => { expect(mod.applySearchProviderSelection(cfg, "firecrawl")).toBe(cfg); }); - it("defaults to a keyless provider when no search credentials exist", async () => { + it("supports explicit keyless provider selection without defaulting to it", async () => { const duckduckgoEntry = createBundledDuckDuckGoEntry(); mocks.resolvePluginWebSearchProviders.mockImplementation((params) => params?.config ? [duckduckgoEntry] : [duckduckgoEntry], @@ -248,6 +248,9 @@ describe("onboard-search provider resolution", () => { const result = await mod.setupSearch({} as OpenClawConfig, {} as never, prompter as never); + expect(prompter.select).toHaveBeenCalledWith( + expect.objectContaining({ initialValue: "__skip__" }), + ); expect(result.tools?.web?.search?.provider).toBe("duckduckgo"); expect(result.plugins?.entries?.duckduckgo?.enabled).toBe(true); expect(notes.join("\n")).toContain("works without an API key"); diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index ac9f9e20c1a1..35fa96780994 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -595,8 +595,8 @@ describe("web search provider auto-detection", () => { vi.restoreAllMocks(); }); - it("falls back to brave when no keys available", () => { - expect(resolveSearchProvider({})).toBe("brave"); + it("returns no provider when no credentials are available", () => { + expect(resolveSearchProvider({})).toBe(""); }); it("auto-detects brave when only BRAVE_API_KEY is set", () => { diff --git a/src/flows/search-setup.ts b/src/flows/search-setup.ts index 8eb8270ae0e1..6dd03af58d91 100644 --- a/src/flows/search-setup.ts +++ b/src/flows/search-setup.ts @@ -432,22 +432,21 @@ export async function runSearchSetupFlow( const existingProvider = config.tools?.web?.search?.provider; - const defaultProvider: SearchProvider = (() => { + const defaultChoice: SearchProvider = (() => { if (existingProvider && providerOptions.some((entry) => entry.id === existingProvider)) { return existingProvider; } - // Mirror the runtime auto-detect selection (honors autoDetectOrder and - // configured credentials, incl. keyless defaults) so accepting the setup - // default writes the same provider the gateway would pick — e.g. Parallel - // Search (Free), not whichever ready provider happens to sort first - // alphabetically. Resolve over the providers actually shown in setup - // (`providerOptions`) so the default can't pick an option that isn't listed - // or force-load runtime code for an install-catalog-only provider. + // Mirror runtime auto-detect only when it has a concrete configured signal. + // Keyless providers are selectable, but never preselected; pressing through + // setup should not opt the user into a third-party search destination. + // Resolve over the providers actually shown in setup (`providerOptions`) so + // the default can't pick an option that isn't listed or force-load runtime + // code for an install-catalog-only provider. // Clear any existing provider id before auto-detecting: the valid-existing // case already returned above, so a leftover value here is stale/invalid/ - // disabled and would otherwise make the resolver short-circuit to - // providers[0] (e.g. Brave) instead of the keyless default. Keep the rest of - // the search config so configured credentials are still detected. + // disabled and would otherwise make the resolver short-circuit to that + // invalid selection. Keep the rest of the search config so configured + // credentials are still detected. const searchForAutoDetect = { ...config.tools?.web?.search, provider: undefined, @@ -461,11 +460,13 @@ export async function runSearchSetupFlow( if (autoDetected) { return autoDetected.id; } - const detected = providerOptions.find((entry) => providerIsReady(config, entry)); + const detected = providerOptions.find( + (entry) => providerNeedsCredential(entry) && providerIsReady(config, entry), + ); if (detected) { return detected.id; } - return providerOptions[0].id; + return "__skip__"; })(); const options = providerOptions.map((entry) => { @@ -488,7 +489,7 @@ export async function runSearchSetupFlow( hint: t("wizard.search.configureLaterHint"), }, ], - initialValue: defaultProvider, + initialValue: defaultChoice, searchable: true, }); diff --git a/src/secrets/runtime-web-tools.shared.ts b/src/secrets/runtime-web-tools.shared.ts index bc2c49aa4552..05d551f1a6ab 100644 --- a/src/secrets/runtime-web-tools.shared.ts +++ b/src/secrets/runtime-web-tools.shared.ts @@ -66,6 +66,8 @@ export type RuntimeWebProviderSelectionParams< resolvedConfig: OpenClawConfig; context: ResolverContext; defaults: SecretDefaults | undefined; + /** Allow keyless providers to be selected when no provider is explicitly configured. */ + allowKeylessAutoSelect: boolean; /** Defer keyless providers until credential-bearing auto-detect candidates are exhausted. */ deferKeylessFallback: boolean; fallbackUsedCode: RuntimeWebWarningCode; @@ -339,11 +341,17 @@ export async function resolveRuntimeWebProviderSurface< params.rawProvider, params.normalizeConfiguredProviderAgainstActiveProviders ? providers : allProviders, ); + const invalidConfiguredProvider = + params.normalizeConfiguredProviderAgainstActiveProviders === true && + Boolean(params.rawProvider) && + !configuredProvider; if (params.rawProvider && !configuredProvider) { const diagnostic: RuntimeWebDiagnostic = { code: params.invalidAutoDetectCode, - message: `${params.providerPath} is "${params.rawProvider}". Falling back to auto-detect precedence.`, + message: invalidConfiguredProvider + ? `${params.providerPath} is "${params.rawProvider}". No provider will be selected.` + : `${params.providerPath} is "${params.rawProvider}". Falling back to auto-detect precedence.`, path: params.providerPath, }; params.diagnostics.push(diagnostic); @@ -359,7 +367,9 @@ export async function resolveRuntimeWebProviderSurface< providers, configuredProvider, enabled: - hasConfiguredSurface && (!isRecord(params.toolConfig) || params.toolConfig.enabled !== false), + hasConfiguredSurface && + !invalidConfiguredProvider && + (!isRecord(params.toolConfig) || params.toolConfig.enabled !== false), hasConfiguredSurface, }; } @@ -395,6 +405,9 @@ export async function resolveRuntimeWebProviderSelection< for (const provider of candidates) { if (provider.requiresCredential === false) { + if (!params.configuredProvider && !params.allowKeylessAutoSelect) { + continue; + } if (params.deferKeylessFallback && !params.configuredProvider) { keylessFallbackProvider ||= provider; continue; @@ -529,7 +542,7 @@ export async function resolveRuntimeWebProviderSelection< } } - if (!selectedProvider && keylessFallbackProvider) { + if (!selectedProvider && keylessFallbackProvider && params.allowKeylessAutoSelect) { selectedProvider = keylessFallbackProvider.id; selectedResolution = { source: "missing" as TSource, @@ -570,7 +583,7 @@ export async function resolveRuntimeWebProviderSelection< ); const selectedDetails = selectedProviderEntry?.requiresCredential === false - ? `${params.scopePath} auto-detected keyless provider "${selectedProvider}" as the default fallback.` + ? `${params.scopePath} auto-detected keyless provider "${selectedProvider}".` : `${params.scopePath} auto-detected provider "${selectedProvider}" from available credentials.`; const diagnostic: RuntimeWebDiagnostic = { code: params.autoDetectSelectedCode, diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts index 90f9a9e1ead0..795b47ef25df 100644 --- a/src/secrets/runtime-web-tools.test.ts +++ b/src/secrets/runtime-web-tools.test.ts @@ -582,7 +582,7 @@ describe("runtime web tools resolution", () => { }); }); - it("auto-selects a keyless provider when no credentials are configured", async () => { + it("does not auto-select a keyless provider when no credentials are configured", async () => { const { metadata } = await runRuntimeWebTools({ config: asConfig({ tools: { @@ -595,12 +595,9 @@ describe("runtime web tools resolution", () => { }), }); - expect(metadata.search.selectedProvider).toBe("duckduckgo"); - expect(metadata.search.providerSource).toBe("auto-detect"); - expectDiagnostic(metadata.search.diagnostics, { - code: "WEB_SEARCH_AUTODETECT_SELECTED", - messageIncludes: 'keyless provider "duckduckgo"', - }); + expect(metadata.search.selectedProvider).toBeUndefined(); + expect(metadata.search.providerSource).toBe("none"); + expect(metadata.search.diagnostics).toEqual([]); }); it.each([ @@ -958,7 +955,7 @@ describe("runtime web tools resolution", () => { expect(resolvedConfig.models?.providers?.google?.apiKey).toBe("google-provider-ref-key"); }); - it("warns when provider is invalid and falls back to auto-detect", async () => { + it("keeps an invalid provider unselected without resolving another provider", async () => { const { metadata, resolvedConfig, context } = await runRuntimeWebTools({ config: asConfig({ tools: { @@ -987,9 +984,13 @@ describe("runtime web tools resolution", () => { }); expect(metadata.search.providerConfigured).toBeUndefined(); - expect(metadata.search.providerSource).toBe("auto-detect"); - expect(metadata.search.selectedProvider).toBe("gemini"); - expect(readProviderKey(resolvedConfig, "gemini")).toBe("gemini-runtime-key"); + expect(metadata.search.providerSource).toBe("none"); + expect(metadata.search.selectedProvider).toBeUndefined(); + expect(readProviderKey(resolvedConfig, "gemini")).toEqual({ + source: "env", + provider: "default", + id: "GEMINI_API_KEY_REF", + }); expectDiagnostic(metadata.search.diagnostics, { code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT", path: "tools.web.search.provider", diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index c1478a67868b..b65c750ddf6c 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -681,6 +681,7 @@ export async function resolveRuntimeWebTools(params: { resolvedConfig: params.resolvedConfig, context: params.context, defaults, + allowKeylessAutoSelect: false, deferKeylessFallback: true, fallbackUsedCode: "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED", noFallbackCode: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", @@ -793,6 +794,7 @@ export async function resolveRuntimeWebTools(params: { resolvedConfig: params.resolvedConfig, context: params.context, defaults, + allowKeylessAutoSelect: true, deferKeylessFallback: false, fallbackUsedCode: "WEB_FETCH_PROVIDER_KEY_UNRESOLVED_FALLBACK_USED", noFallbackCode: "WEB_FETCH_PROVIDER_KEY_UNRESOLVED_NO_FALLBACK", diff --git a/src/web-search/runtime.test.ts b/src/web-search/runtime.test.ts index cf749c2f725f..25991b7d7342 100644 --- a/src/web-search/runtime.test.ts +++ b/src/web-search/runtime.test.ts @@ -1,8 +1,13 @@ // Web search runtime tests cover provider resolution and search execution. -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearRuntimeAuthProfileStoreSnapshots, + replaceRuntimeAuthProfileStoreSnapshots, +} from "../agents/auth-profiles/store.js"; +import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import type { OpenClawConfig } from "../config/config.js"; import type { PluginWebSearchProviderEntry } from "../plugins/web-provider-types.js"; import { @@ -108,11 +113,32 @@ function createGoogleSearchProvider( id: "google", credentialPath: "tools.web.search.google.apiKey", autoDetectOrder: 1, + getConfiguredCredentialValue: () => "configured", getCredentialValue: () => "configured", ...overrides, }); } +function createOAuthAuthProfileStore(params: { + provider: string; + profileId: string; + access: string; + refresh: string; +}): AuthProfileStore { + return { + version: 1, + profiles: { + [params.profileId]: { + type: "oauth", + provider: params.provider, + access: params.access, + refresh: params.refresh, + expires: Date.now() + 3_600_000, + }, + }, + }; +} + function requireRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("Expected a non-array record"); @@ -139,13 +165,14 @@ function createDuckDuckGoSearchProvider( describe("web search runtime", () => { let runWebSearch: typeof import("./runtime.js").runWebSearch; + let resolveWebSearchDefinition: typeof import("./runtime.js").resolveWebSearchDefinition; let activateSecretsRuntimeSnapshot: typeof import("../secrets/runtime.js").activateSecretsRuntimeSnapshot; let clearSecretsRuntimeSnapshot: typeof import("../secrets/runtime.js").clearSecretsRuntimeSnapshot; let setRuntimeConfigSnapshot: typeof import("../config/config.js").setRuntimeConfigSnapshot; const tempDirs: string[] = []; beforeAll(async () => { - ({ runWebSearch } = await import("./runtime.js")); + ({ resolveWebSearchDefinition, runWebSearch } = await import("./runtime.js")); ({ activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot } = await import("../secrets/runtime.js")); ({ setRuntimeConfigSnapshot } = await import("../config/config.js")); @@ -162,6 +189,7 @@ describe("web search runtime", () => { afterEach(() => { clearSecretsRuntimeSnapshot(); + clearRuntimeAuthProfileStoreSnapshots(); for (const tempDir of tempDirs.splice(0)) { rmSync(tempDir, { recursive: true, force: true }); } @@ -177,7 +205,15 @@ describe("web search runtime", () => { await expect( runWebSearch({ - config: {}, + config: { + tools: { + web: { + search: { + provider: "custom", + }, + }, + }, + }, args: { query: "hello" }, }), ).resolves.toEqual({ @@ -209,7 +245,15 @@ describe("web search runtime", () => { await expect( runWebSearch({ - config: {}, + config: { + tools: { + web: { + search: { + provider: "custom", + }, + }, + }, + }, args: { query: "abort plumbing" }, signal: controller.signal, }), @@ -286,22 +330,17 @@ describe("web search runtime", () => { it("auto-detects a provider from a model-provider auth profile", async () => { const agentDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-web-search-auth-")); tempDirs.push(agentDir); - mkdirSync(agentDir, { recursive: true }); - writeFileSync( - path.join(agentDir, "auth-profiles.json"), - JSON.stringify({ - version: 1, - profiles: { - "xai:default": { - type: "oauth", - provider: "xai", - access: "xai-oauth-access-token", - refresh: "xai-oauth-refresh-token", - expires: Date.now() + 3_600_000, - }, - }, - }), - ); + replaceRuntimeAuthProfileStoreSnapshots([ + { + agentDir, + store: createOAuthAuthProfileStore({ + provider: "xai", + profileId: "xai:default", + access: "xai-oauth-access-token", + refresh: "xai-oauth-refresh-token", + }), + }, + ]); const provider = createCustomSearchProvider({ pluginId: "xai", @@ -334,23 +373,17 @@ describe("web search runtime", () => { const defaultAgentDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-web-search-default-")); const activeAgentDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-web-search-active-")); tempDirs.push(defaultAgentDir, activeAgentDir); - mkdirSync(defaultAgentDir, { recursive: true }); - mkdirSync(activeAgentDir, { recursive: true }); - writeFileSync( - path.join(activeAgentDir, "auth-profiles.json"), - JSON.stringify({ - version: 1, - profiles: { - "xai:active": { - type: "oauth", - provider: "xai", - access: "xai-active-oauth-token", - refresh: "xai-active-refresh-token", - expires: Date.now() + 3_600_000, - }, - }, - }), - ); + replaceRuntimeAuthProfileStoreSnapshots([ + { + agentDir: activeAgentDir, + store: createOAuthAuthProfileStore({ + provider: "xai", + profileId: "xai:active", + access: "xai-active-oauth-token", + refresh: "xai-active-refresh-token", + }), + }, + ]); const provider = createCustomSearchProvider({ pluginId: "xai", @@ -404,7 +437,15 @@ describe("web search runtime", () => { await expect( runWebSearch({ agentDir: activeAgentDir, - config: {}, + config: { + tools: { + web: { + search: { + provider: "custom", + }, + }, + }, + }, args: { query: "active-agent tool context" }, }), ).resolves.toEqual({ @@ -528,11 +569,18 @@ describe("web search runtime", () => { }); }); - it("falls back to a keyless provider when no credentials are available", async () => { + it("does not auto-select keyless providers when no provider is configured", async () => { resolveRuntimeWebSearchProvidersMock.mockReturnValue([ createDuckDuckGoSearchProvider({ getCredentialValue: () => "duckduckgo-no-key-needed", }), + createWebSearchTestProvider({ + pluginId: "parallel", + id: "parallel-free", + credentialPath: "", + autoDetectOrder: 76, + requiresCredential: false, + }), ]); await expect( @@ -540,9 +588,38 @@ describe("web search runtime", () => { config: {}, args: { query: "fallback" }, }), + ).rejects.toThrow("web_search is disabled or no provider is available."); + }); + + it("does not resolve a keyless provider definition when no provider is configured", () => { + resolvePluginWebSearchProvidersMock.mockReturnValue([createDuckDuckGoSearchProvider()]); + + const resolved = resolveWebSearchDefinition({ + config: {}, + }); + + expect(resolved).toBeNull(); + }); + + it("uses a keyless provider when the user explicitly selects it", async () => { + resolveRuntimeWebSearchProvidersMock.mockReturnValue([createDuckDuckGoSearchProvider()]); + + await expect( + runWebSearch({ + config: { + tools: { + web: { + search: { + provider: "duckduckgo", + }, + }, + }, + }, + args: { query: "explicit-keyless" }, + }), ).resolves.toEqual({ provider: "duckduckgo", - result: { query: "fallback", provider: "duckduckgo" }, + result: { query: "explicit-keyless", provider: "duckduckgo" }, }); }); @@ -553,6 +630,7 @@ describe("web search runtime", () => { id: "alpha", credentialPath: "tools.web.search.alpha.apiKey", autoDetectOrder: 1, + getConfiguredCredentialValue: () => "alpha-configured", getCredentialValue: () => "alpha-configured", createTool: ({ runtimeMetadata }) => ({ description: "alpha", @@ -569,6 +647,7 @@ describe("web search runtime", () => { id: "beta", credentialPath: "tools.web.search.beta.apiKey", autoDetectOrder: 2, + getConfiguredCredentialValue: () => "beta-configured", getCredentialValue: () => "beta-configured", createTool: ({ runtimeMetadata }) => ({ description: "beta", @@ -612,10 +691,115 @@ describe("web search runtime", () => { }); }); + it("ignores auto-detected keyless runtime metadata when no provider is configured", async () => { + resolveRuntimeWebSearchProvidersMock.mockReturnValue([ + createWebSearchTestProvider({ + pluginId: "parallel", + id: "parallel-free", + credentialPath: "", + autoDetectOrder: 76, + requiresCredential: false, + }), + ]); + + activateSecretsRuntimeSnapshot({ + sourceConfig: {}, + config: {}, + authStores: [], + warnings: [], + webTools: { + search: { + providerSource: "auto-detect", + selectedProvider: "parallel-free", + diagnostics: [], + }, + fetch: { + providerSource: "none", + diagnostics: [], + }, + diagnostics: [], + }, + }); + + await expect( + runWebSearch({ + config: {}, + args: { query: "stale-keyless-runtime" }, + }), + ).rejects.toThrow("web_search is disabled or no provider is available."); + }); + + it("ignores auto-detected runtime metadata after config names an unknown provider", async () => { + const createTool = vi.fn(() => createCustomSearchTool()); + resolveRuntimeWebSearchProvidersMock.mockReturnValue([ + createGoogleSearchProvider({ + createTool, + }), + ]); + const config = { + tools: { + web: { + search: { + provider: "missing-id", + }, + }, + }, + }; + + activateSecretsRuntimeSnapshot({ + sourceConfig: config, + config: structuredClone(config), + authStores: [], + warnings: [], + webTools: { + search: { + providerSource: "auto-detect", + selectedProvider: "google", + diagnostics: [], + }, + fetch: { + providerSource: "none", + diagnostics: [], + }, + diagnostics: [], + }, + }); + + await expect( + runWebSearch({ + config: structuredClone(config), + args: { query: "runtime-config-typo" }, + }), + ).rejects.toThrow("web_search is disabled or no provider is available."); + expect(createTool).not.toHaveBeenCalled(); + }); + + it("ignores auto-detected keyless runtime metadata when resolving a provider definition", () => { + resolvePluginWebSearchProvidersMock.mockReturnValue([ + createWebSearchTestProvider({ + pluginId: "parallel", + id: "parallel-free", + credentialPath: "", + autoDetectOrder: 76, + requiresCredential: false, + }), + ]); + + const resolved = resolveWebSearchDefinition({ + config: {}, + runtimeWebSearch: { + providerSource: "auto-detect", + selectedProvider: "parallel-free", + diagnostics: [], + }, + }); + + expect(resolved).toBeNull(); + }); + it("falls back to another provider when auto-selected search execution fails", async () => { resolveRuntimeWebSearchProvidersMock.mockReturnValue([ createGoogleSearchProvider({ - requiresCredential: false, createTool: () => ({ description: "google", parameters: {}, @@ -624,7 +808,14 @@ describe("web search runtime", () => { }, }), }), - createDuckDuckGoSearchProvider(), + createWebSearchTestProvider({ + pluginId: "backup-search", + id: "backup", + credentialPath: "tools.web.search.backup.apiKey", + autoDetectOrder: 2, + getConfiguredCredentialValue: () => "backup-configured", + getCredentialValue: () => "backup-configured", + }), ]); await expect( @@ -633,15 +824,14 @@ describe("web search runtime", () => { args: { query: "fallback" }, }), ).resolves.toEqual({ - provider: "duckduckgo", - result: { query: "fallback", provider: "duckduckgo" }, + provider: "backup", + result: { query: "fallback", provider: "backup" }, }); }); it("falls back when an auto-selected provider returns a structured error payload", async () => { resolveRuntimeWebSearchProvidersMock.mockReturnValue([ createGoogleSearchProvider({ - requiresCredential: false, createTool: () => ({ description: "google", parameters: {}, @@ -651,7 +841,14 @@ describe("web search runtime", () => { }), }), }), - createDuckDuckGoSearchProvider(), + createWebSearchTestProvider({ + pluginId: "backup-search", + id: "backup", + credentialPath: "tools.web.search.backup.apiKey", + autoDetectOrder: 2, + getConfiguredCredentialValue: () => "backup-configured", + getCredentialValue: () => "backup-configured", + }), ]); await expect( @@ -660,15 +857,14 @@ describe("web search runtime", () => { args: { query: "fallback-structured-error" }, }), ).resolves.toEqual({ - provider: "duckduckgo", - result: { query: "fallback-structured-error", provider: "duckduckgo" }, + provider: "backup", + result: { query: "fallback-structured-error", provider: "backup" }, }); }); it("does not fall back when an auto-selected provider returns a validation error payload", async () => { resolveRuntimeWebSearchProvidersMock.mockReturnValue([ createGoogleSearchProvider({ - requiresCredential: false, createTool: () => ({ description: "google", parameters: {}, @@ -920,7 +1116,7 @@ describe("web search runtime", () => { ).rejects.toThrow('Unknown web_search provider "missing-id".'); }); - it("still falls back when config names an unknown provider id", async () => { + it("does not fall back when config names an unknown provider id", async () => { resolveRuntimeWebSearchProvidersMock.mockReturnValue([ createGoogleSearchProvider({ createTool: () => { @@ -930,22 +1126,20 @@ describe("web search runtime", () => { createDuckDuckGoSearchProvider(), ]); - const result = await runWebSearch({ - config: { - tools: { - web: { - search: { - provider: "missing-id", + await expect( + runWebSearch({ + config: { + tools: { + web: { + search: { + provider: "missing-id", + }, }, }, }, - }, - args: { query: "config-typo" }, - }); - expect(result.provider).toBe("duckduckgo"); - const searchResult = requireRecord(result.result); - expect(searchResult.provider).toBe("duckduckgo"); - expect(searchResult.query).toBe("config-typo"); + args: { query: "config-typo" }, + }), + ).rejects.toThrow("web_search is disabled or no provider is available."); }); it("honors preferRuntimeProviders during execution", async () => { diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts index 6cef7935143c..0395a2feb151 100644 --- a/src/web-search/runtime.ts +++ b/src/web-search/runtime.ts @@ -118,6 +118,28 @@ function hasEntryCredential( }); } +function hasImplicitProviderSelectionSignal( + provider: Pick< + PluginWebSearchProviderEntry, + | "credentialPath" + | "id" + | "authProviderId" + | "envVars" + | "getConfiguredCredentialValue" + | "getConfiguredCredentialFallback" + | "getCredentialValue" + | "requiresCredential" + >, + config: OpenClawConfig | undefined, + search: WebSearchConfig | undefined, + agentDir?: string, +): boolean { + if (!providerRequiresCredential(provider)) { + return false; + } + return hasEntryCredential(provider, config, search, agentDir); +} + /** Reports whether a web_search provider has usable configured credentials. */ export function isWebSearchProviderConfigured(params: { provider: Pick< @@ -183,13 +205,8 @@ export function resolveWebSearchProviderId(params: { } if (!raw) { - let keylessFallbackProviderId = ""; for (const provider of providers) { - if (!providerRequiresCredential(provider)) { - keylessFallbackProviderId ||= provider.id; - continue; - } - if (!hasEntryCredential(provider, config, search, params.agentDir)) { + if (!hasImplicitProviderSelectionSignal(provider, config, search, params.agentDir)) { continue; } logVerbose( @@ -197,17 +214,63 @@ export function resolveWebSearchProviderId(params: { ); return provider.id; } - if (keylessFallbackProviderId) { - // Keyless providers are only used after credential-backed providers fail - // auto-detection, so configured API keys win when present. - logVerbose( - `web_search: no provider configured and no credentials found, falling back to keyless provider "${keylessFallbackProviderId}"`, - ); - return keylessFallbackProviderId; - } + return ""; } - return providers[0]?.id ?? ""; + return ""; +} + +function resolveRuntimePreferredWebSearchProviderId(params: { + config?: OpenClawConfig; + search?: WebSearchConfig; + runtimeWebSearch?: RuntimeWebSearchMetadata; + providers?: PluginWebSearchProviderEntry[]; + agentDir?: string; +}): string | undefined { + const runtimeProviderId = normalizeOptionalLowercaseString( + params.runtimeWebSearch?.selectedProvider ?? params.runtimeWebSearch?.providerConfigured, + ); + if (!runtimeProviderId) { + return undefined; + } + const configuredProviderId = + params.search && "provider" in params.search + ? normalizeOptionalLowercaseString(params.search.provider) + : undefined; + if (configuredProviderId) { + const configuredProvider = params.providers?.find((entry) => entry.id === configuredProviderId); + return configuredProvider?.id === runtimeProviderId ? runtimeProviderId : undefined; + } + if (params.runtimeWebSearch?.providerSource === "configured") { + return runtimeProviderId; + } + const provider = params.providers?.find((entry) => entry.id === runtimeProviderId); + return provider && + hasImplicitProviderSelectionSignal(provider, params.config, params.search, params.agentDir) + ? provider.id + : undefined; +} + +function resolveTrustedRuntimeWebSearchMetadata(params: { + config?: OpenClawConfig; + search?: WebSearchConfig; + runtimeWebSearch?: RuntimeWebSearchMetadata; + providers?: PluginWebSearchProviderEntry[]; + agentDir?: string; +}): RuntimeWebSearchMetadata | undefined { + const runtimeWebSearch = params.runtimeWebSearch; + if (!runtimeWebSearch) { + return undefined; + } + const trustedProviderId = resolveRuntimePreferredWebSearchProviderId(params); + const runtimeProviderId = normalizeOptionalLowercaseString( + runtimeWebSearch.selectedProvider ?? runtimeWebSearch.providerConfigured, + ); + if (trustedProviderId && trustedProviderId === runtimeProviderId) { + return runtimeWebSearch; + } + const { selectedProvider: _selectedProvider, ...metadataWithoutSelection } = runtimeWebSearch; + return metadataWithoutSelection; } function resolveExplicitWebSearchProviderId(params: { @@ -331,10 +394,17 @@ export function resolveWebSearchDefinition( providerId: options?.providerId, preferRuntimeProviders: options?.preferRuntimeProviders, }); + const trustedRuntimeWebSearch = resolveTrustedRuntimeWebSearchMetadata({ + config, + search, + runtimeWebSearch, + providers, + agentDir: options?.agentDir, + }); return resolveWebProviderDefinition({ config, toolConfig: search as Record | undefined, - runtimeMetadata: runtimeWebSearch, + runtimeMetadata: trustedRuntimeWebSearch, sandboxed: options?.sandboxed, providerId: options?.providerId, providers, @@ -356,7 +426,7 @@ export function resolveWebSearchDefinition( agentDir: options?.agentDir, search: toolConfig as WebSearchConfig | undefined, providers: providersLocal, - }) || providersLocal[0]?.id, + }), createTool: ({ provider, config: configLocal, toolConfig, runtimeMetadata }) => provider.createTool({ config: configLocal, @@ -389,8 +459,13 @@ function resolveWebSearchCandidates( const preferredIds = uniqueStrings( [ options?.providerId, - runtimeWebSearch?.selectedProvider, - runtimeWebSearch?.providerConfigured, + resolveRuntimePreferredWebSearchProviderId({ + config, + search, + runtimeWebSearch, + providers, + agentDir: options?.agentDir, + }), resolveWebSearchProviderId({ config, agentDir: options?.agentDir, search, providers }), ].filter((value): value is string => Boolean(value)), ); @@ -399,12 +474,26 @@ function resolveWebSearchCandidates( if (explicitProviderId && !providers.some((entry) => entry.id === explicitProviderId)) { throw new Error(`Unknown web_search provider "${explicitProviderId}".`); } + const explicitSelection = hasExplicitWebSearchSelection({ + search, + runtimeWebSearch, + providerId: options?.providerId, + providers, + }); + if (preferredIds.length === 0 && !explicitSelection) { + return []; + } + const fallbackProviders = explicitSelection + ? providers + : providers.filter((provider) => + hasImplicitProviderSelectionSignal(provider, config, search, options?.agentDir), + ); const orderedProviders = [ ...preferredIds .map((id) => providers.find((entry) => entry.id === id)) .filter((entry): entry is PluginWebSearchProviderEntry => Boolean(entry)), - ...providers.filter((entry) => !preferredIds.includes(entry.id)), + ...fallbackProviders.filter((entry) => !preferredIds.includes(entry.id)), ]; return orderedProviders; }