feat(web-search): add DuckDuckGo bundled plugin (#52629)

* feat(web-search): add DuckDuckGo bundled plugin

* chore(changelog): restore main changelog

* fix(web-search): harden DuckDuckGo challenge detection
This commit is contained in:
Vincent Koc
2026-03-22 22:05:33 -07:00
committed by GitHub
parent 827c441902
commit c6ca11e5a5
27 changed files with 1222 additions and 217 deletions

4
.github/labeler.yml vendored
View File

@@ -229,6 +229,10 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/device-pair/**"
"extensions: duckduckgo":
- changed-files:
- any-glob-to-any-file:
- "extensions/duckduckgo/**"
"extensions: acpx":
- changed-files:
- any-glob-to-any-file:

View File

@@ -47319,8 +47319,8 @@
"tags": [
"advanced"
],
"label": "@openclaw/deepgram-provider",
"help": "OpenClaw Deepgram media-understanding provider (plugin: deepgram)",
"label": "@openclaw/deepgram-media-understanding",
"help": "OpenClaw Deepgram media-understanding plugin (plugin: deepgram)",
"hasChildren": true
},
{
@@ -47333,7 +47333,7 @@
"tags": [
"advanced"
],
"label": "@openclaw/deepgram-provider Config",
"label": "@openclaw/deepgram-media-understanding Config",
"help": "Plugin-defined config payload for deepgram.",
"hasChildren": false
},
@@ -47347,7 +47347,7 @@
"tags": [
"advanced"
],
"label": "Enable @openclaw/deepgram-provider",
"label": "Enable @openclaw/deepgram-media-understanding",
"hasChildren": false
},
{
@@ -48386,155 +48386,6 @@
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
"hasChildren": false
},
{
"path": "plugins.entries.exa",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "@openclaw/exa-plugin",
"help": "OpenClaw Exa plugin (plugin: exa)",
"hasChildren": true
},
{
"path": "plugins.entries.exa.config",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "@openclaw/exa-plugin Config",
"help": "Plugin-defined config payload for exa.",
"hasChildren": true
},
{
"path": "plugins.entries.exa.config.webSearch",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
"path": "plugins.entries.exa.config.webSearch.apiKey",
"kind": "plugin",
"type": [
"object",
"string"
],
"required": false,
"deprecated": false,
"sensitive": true,
"tags": [
"auth",
"security"
],
"label": "Exa API Key",
"help": "Exa Search API key (fallback: EXA_API_KEY env var).",
"hasChildren": false
},
{
"path": "plugins.entries.exa.enabled",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Enable @openclaw/exa-plugin",
"hasChildren": false
},
{
"path": "plugins.entries.exa.hooks",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Plugin Hook Policy",
"help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.",
"hasChildren": true
},
{
"path": "plugins.entries.exa.hooks.allowPromptInjection",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Allow Prompt Injection Hooks",
"help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.",
"hasChildren": false
},
{
"path": "plugins.entries.exa.subagent",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Plugin Subagent Policy",
"help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.",
"hasChildren": true
},
{
"path": "plugins.entries.exa.subagent.allowedModels",
"kind": "plugin",
"type": "array",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Plugin Subagent Allowed Models",
"help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.",
"hasChildren": true
},
{
"path": "plugins.entries.exa.subagent.allowedModels.*",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "plugins.entries.exa.subagent.allowModelOverride",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Allow Plugin Subagent Model Override",
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
"hasChildren": false
},
{
"path": "plugins.entries.fal",
"kind": "plugin",
@@ -48777,6 +48628,166 @@
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
"hasChildren": false
},
{
"path": "plugins.entries.duckduckgo",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "@openclaw/duckduckgo-plugin",
"help": "OpenClaw DuckDuckGo plugin (plugin: duckduckgo)",
"hasChildren": true
},
{
"path": "plugins.entries.duckduckgo.config",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "@openclaw/duckduckgo-plugin Config",
"help": "Plugin-defined config payload for duckduckgo.",
"hasChildren": true
},
{
"path": "plugins.entries.duckduckgo.config.webSearch",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": true
},
{
"path": "plugins.entries.duckduckgo.config.webSearch.region",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"label": "DuckDuckGo Region",
"help": "Optional DuckDuckGo region code such as us-en, uk-en, or de-de.",
"hasChildren": false
},
{
"path": "plugins.entries.duckduckgo.config.webSearch.safeSearch",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"enumValues": [
"strict",
"moderate",
"off"
],
"label": "DuckDuckGo SafeSearch",
"help": "SafeSearch level for DuckDuckGo results.",
"hasChildren": false
},
{
"path": "plugins.entries.duckduckgo.enabled",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Enable @openclaw/duckduckgo-plugin",
"hasChildren": false
},
{
"path": "plugins.entries.duckduckgo.hooks",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Plugin Hook Policy",
"help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.",
"hasChildren": true
},
{
"path": "plugins.entries.duckduckgo.hooks.allowPromptInjection",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Allow Prompt Injection Hooks",
"help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.",
"hasChildren": false
},
{
"path": "plugins.entries.duckduckgo.subagent",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Plugin Subagent Policy",
"help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.",
"hasChildren": true
},
{
"path": "plugins.entries.duckduckgo.subagent.allowedModels",
"kind": "plugin",
"type": "array",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Plugin Subagent Allowed Models",
"help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.",
"hasChildren": true
},
{
"path": "plugins.entries.duckduckgo.subagent.allowedModels.*",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "plugins.entries.duckduckgo.subagent.allowModelOverride",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Allow Plugin Subagent Model Override",
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
"hasChildren": false
},
{
"path": "plugins.entries.firecrawl",
"kind": "plugin",
@@ -49355,8 +49366,8 @@
"tags": [
"advanced"
],
"label": "@openclaw/groq-provider",
"help": "OpenClaw Groq media-understanding provider (plugin: groq)",
"label": "@openclaw/groq-media-understanding",
"help": "OpenClaw Groq media-understanding plugin (plugin: groq)",
"hasChildren": true
},
{
@@ -49369,7 +49380,7 @@
"tags": [
"advanced"
],
"label": "@openclaw/groq-provider Config",
"label": "@openclaw/groq-media-understanding Config",
"help": "Plugin-defined config payload for groq.",
"hasChildren": false
},
@@ -49383,7 +49394,7 @@
"tags": [
"advanced"
],
"label": "Enable @openclaw/groq-provider",
"label": "Enable @openclaw/groq-media-understanding",
"hasChildren": false
},
{

View File

@@ -1,4 +1,4 @@
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5605}
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5594}
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -4177,9 +4177,9 @@
{"recordType":"path","path":"plugins.entries.copilot-proxy.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.copilot-proxy.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.copilot-proxy.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.deepgram","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/deepgram-provider","help":"OpenClaw Deepgram media-understanding provider (plugin: deepgram)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.deepgram.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/deepgram-provider Config","help":"Plugin-defined config payload for deepgram.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.deepgram.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/deepgram-provider","hasChildren":false}
{"recordType":"path","path":"plugins.entries.deepgram","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/deepgram-media-understanding","help":"OpenClaw Deepgram media-understanding plugin (plugin: deepgram)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.deepgram.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/deepgram-media-understanding Config","help":"Plugin-defined config payload for deepgram.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.deepgram.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/deepgram-media-understanding","hasChildren":false}
{"recordType":"path","path":"plugins.entries.deepgram.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.deepgram.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.deepgram.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true}
@@ -4254,17 +4254,6 @@
{"recordType":"path","path":"plugins.entries.elevenlabs.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.elevenlabs.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.elevenlabs.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.exa","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/exa-plugin","help":"OpenClaw Exa plugin (plugin: exa)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.exa.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/exa-plugin Config","help":"Plugin-defined config payload for exa.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.exa.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"plugins.entries.exa.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Exa API Key","help":"Exa Search API key (fallback: EXA_API_KEY env var).","hasChildren":false}
{"recordType":"path","path":"plugins.entries.exa.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/exa-plugin","hasChildren":false}
{"recordType":"path","path":"plugins.entries.exa.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.exa.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.exa.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.exa.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.exa.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.exa.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.fal","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/fal-provider","help":"OpenClaw fal provider plugin (plugin: fal)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.fal.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/fal-provider Config","help":"Plugin-defined config payload for fal.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.fal.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/fal-provider","hasChildren":false}
@@ -4283,6 +4272,18 @@
{"recordType":"path","path":"plugins.entries.feishu.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.feishu.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.feishu.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.duckduckgo","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/duckduckgo-plugin","help":"OpenClaw DuckDuckGo plugin (plugin: duckduckgo)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.duckduckgo.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/duckduckgo-plugin Config","help":"Plugin-defined config payload for duckduckgo.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.duckduckgo.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"plugins.entries.duckduckgo.config.webSearch.region","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"label":"DuckDuckGo Region","help":"Optional DuckDuckGo region code such as us-en, uk-en, or de-de.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.duckduckgo.config.webSearch.safeSearch","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"enumValues":["strict","moderate","off"],"label":"DuckDuckGo SafeSearch","help":"SafeSearch level for DuckDuckGo results.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.duckduckgo.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/duckduckgo-plugin","hasChildren":false}
{"recordType":"path","path":"plugins.entries.duckduckgo.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.duckduckgo.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.duckduckgo.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.duckduckgo.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.duckduckgo.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.duckduckgo.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.firecrawl","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/firecrawl-plugin","help":"OpenClaw Firecrawl plugin (plugin: firecrawl)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.firecrawl.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/firecrawl-plugin Config","help":"Plugin-defined config payload for firecrawl.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.firecrawl.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
@@ -4325,9 +4326,9 @@
{"recordType":"path","path":"plugins.entries.googlechat.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.googlechat.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.googlechat.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.groq","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/groq-provider","help":"OpenClaw Groq media-understanding provider (plugin: groq)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.groq.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/groq-provider Config","help":"Plugin-defined config payload for groq.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.groq.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/groq-provider","hasChildren":false}
{"recordType":"path","path":"plugins.entries.groq","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/groq-media-understanding","help":"OpenClaw Groq media-understanding plugin (plugin: groq)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.groq.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/groq-media-understanding Config","help":"Plugin-defined config payload for groq.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.groq.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/groq-media-understanding","hasChildren":false}
{"recordType":"path","path":"plugins.entries.groq.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.groq.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.groq.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true}

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
describe("duckduckgo plugin", () => {
it("registers a keyless web search provider", () => {
const webSearchProviders: unknown[] = [];
plugin.register({
registerWebSearchProvider(provider: unknown) {
webSearchProviders.push(provider);
},
} as never);
expect(plugin.id).toBe("duckduckgo");
expect(webSearchProviders).toHaveLength(1);
const provider = webSearchProviders[0] as Record<string, unknown>;
expect(provider.id).toBe("duckduckgo");
expect(provider.requiresCredential).toBe(false);
expect(provider.envVars).toEqual([]);
});
});

View File

@@ -0,0 +1,11 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createDuckDuckGoWebSearchProvider } from "./src/ddg-search-provider.js";
export default definePluginEntry({
id: "duckduckgo",
name: "DuckDuckGo Plugin",
description: "Bundled DuckDuckGo web search plugin",
register(api) {
api.registerWebSearchProvider(createDuckDuckGoWebSearchProvider());
},
});

View File

@@ -0,0 +1,32 @@
{
"id": "duckduckgo",
"uiHints": {
"webSearch.region": {
"label": "DuckDuckGo Region",
"help": "Optional DuckDuckGo region code such as us-en, uk-en, or de-de."
},
"webSearch.safeSearch": {
"label": "DuckDuckGo SafeSearch",
"help": "SafeSearch level for DuckDuckGo results."
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"region": {
"type": "string"
},
"safeSearch": {
"type": "string",
"enum": ["strict", "moderate", "off"]
}
}
}
}
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "@openclaw/duckduckgo-plugin",
"version": "2026.3.22",
"private": true,
"description": "OpenClaw DuckDuckGo plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_DDG_SAFE_SEARCH, resolveDdgRegion, resolveDdgSafeSearch } from "./config.js";
describe("duckduckgo config", () => {
it("reads region from plugin config", () => {
expect(
resolveDdgRegion({
plugins: {
entries: {
duckduckgo: {
config: {
webSearch: {
region: "de-de",
},
},
},
},
},
} as never),
).toBe("de-de");
});
it("normalizes empty region to undefined", () => {
expect(
resolveDdgRegion({
plugins: {
entries: {
duckduckgo: {
config: {
webSearch: {
region: " ",
},
},
},
},
},
} as never),
).toBeUndefined();
});
it("defaults safeSearch to moderate", () => {
expect(resolveDdgSafeSearch(undefined)).toBe(DEFAULT_DDG_SAFE_SEARCH);
});
it("accepts strict and off safeSearch values", () => {
expect(
resolveDdgSafeSearch({
plugins: {
entries: {
duckduckgo: {
config: {
webSearch: {
safeSearch: "strict",
},
},
},
},
},
} as never),
).toBe("strict");
expect(
resolveDdgSafeSearch({
plugins: {
entries: {
duckduckgo: {
config: {
webSearch: {
safeSearch: "off",
},
},
},
},
},
} as never),
).toBe("off");
});
});

View File

@@ -0,0 +1,41 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
export const DEFAULT_DDG_SAFE_SEARCH = "moderate";
export type DdgSafeSearch = "strict" | "moderate" | "off";
type DdgPluginConfig = {
webSearch?: {
region?: string;
safeSearch?: string;
};
};
export function resolveDdgWebSearchConfig(
config?: OpenClawConfig,
): DdgPluginConfig["webSearch"] | undefined {
const pluginConfig = config?.plugins?.entries?.duckduckgo?.config as DdgPluginConfig | undefined;
const webSearch = pluginConfig?.webSearch;
if (webSearch && typeof webSearch === "object" && !Array.isArray(webSearch)) {
return webSearch;
}
return undefined;
}
export function resolveDdgRegion(config?: OpenClawConfig): string | undefined {
const region = resolveDdgWebSearchConfig(config)?.region;
if (typeof region !== "string") {
return undefined;
}
const trimmed = region.trim();
return trimmed || undefined;
}
export function resolveDdgSafeSearch(config?: OpenClawConfig): DdgSafeSearch {
const safeSearch = resolveDdgWebSearchConfig(config)?.safeSearch;
const normalized = typeof safeSearch === "string" ? safeSearch.trim().toLowerCase() : "";
if (normalized === "strict" || normalized === "off") {
return normalized;
}
return DEFAULT_DDG_SAFE_SEARCH;
}

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { __testing } from "./ddg-client.js";
describe("duckduckgo html parsing", () => {
it("decodes direct and redirect urls", () => {
expect(
__testing.decodeDuckDuckGoUrl(
"https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fsearch%3Fq%3Dclaw",
),
).toBe("https://example.com/search?q=claw");
expect(__testing.decodeDuckDuckGoUrl("https://example.com")).toBe("https://example.com");
});
it("decodes common html entities", () => {
expect(__testing.decodeHtmlEntities("Fish &amp; Chips&nbsp;&hellip; &#39;ok&#39;")).toBe(
"Fish & Chips ... 'ok'",
);
});
it("parses results when href appears before class", () => {
const html = `
<a href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com" class="result__a">
Example &amp; Co
</a>
<a class="result__snippet">Fast&nbsp;search &hellip; with details</a>
<a class="result__a" href="https://example.org/direct">Direct result</a>
<a class="result__snippet">Second snippet</a>
`;
expect(__testing.parseDuckDuckGoHtml(html)).toEqual([
{
title: "Example & Co",
url: "https://example.com",
snippet: "Fast search ... with details",
},
{
title: "Direct result",
url: "https://example.org/direct",
snippet: "Second snippet",
},
]);
});
it("returns no results for bot challenge pages", () => {
const html = `
<html>
<body>
<form>
<h1>Are you a human?</h1>
<div class="g-recaptcha">captcha</div>
</form>
</body>
</html>
`;
expect(__testing.isBotChallenge(html)).toBe(true);
expect(__testing.parseDuckDuckGoHtml(html)).toEqual([]);
});
it("does not treat ordinary result snippets mentioning challenge as bot pages", () => {
const html = `
<a class="result__a" href="https://example.com/challenge">Coding Challenge</a>
<a class="result__snippet">A fun coding challenge for interview prep.</a>
`;
expect(__testing.isBotChallenge(html)).toBe(false);
expect(__testing.parseDuckDuckGoHtml(html)).toEqual([
{
title: "Coding Challenge",
url: "https://example.com/challenge",
snippet: "A fun coding challenge for interview prep.",
},
]);
});
});

View File

@@ -0,0 +1,212 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
DEFAULT_CACHE_TTL_MINUTES,
DEFAULT_SEARCH_COUNT,
normalizeCacheKey,
readCache,
readResponseText,
resolveCacheTtlMs,
resolveSearchCount,
resolveSiteName,
resolveTimeoutSeconds,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCache,
} from "openclaw/plugin-sdk/provider-web-search";
import { resolveDdgRegion, resolveDdgSafeSearch, type DdgSafeSearch } from "./config.js";
const DDG_HTML_ENDPOINT = "https://html.duckduckgo.com/html";
const DEFAULT_TIMEOUT_SECONDS = 20;
const DDG_SAFE_SEARCH_PARAM: Record<DdgSafeSearch, string> = {
strict: "1",
moderate: "-1",
off: "-2",
};
const DDG_SEARCH_CACHE = new Map<
string,
{ value: Record<string, unknown>; insertedAt: number; expiresAt: number }
>();
type DuckDuckGoResult = {
title: string;
url: string;
snippet: string;
};
function decodeHtmlEntities(text: string): string {
return text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, "/")
.replace(/&nbsp;/g, " ")
.replace(/&ndash;/g, "-")
.replace(/&mdash;/g, "--")
.replace(/&hellip;/g, "...")
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)));
}
function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function decodeDuckDuckGoUrl(rawUrl: string): string {
try {
const normalized = rawUrl.startsWith("//") ? `https:${rawUrl}` : rawUrl;
const parsed = new URL(normalized);
const uddg = parsed.searchParams.get("uddg");
if (uddg) {
return uddg;
}
} catch {
// Keep the original value when DuckDuckGo already returns a direct link.
}
return rawUrl;
}
function readHrefAttribute(tagAttributes: string): string {
return /\bhref="([^"]*)"/i.exec(tagAttributes)?.[1] ?? "";
}
function isBotChallenge(html: string): boolean {
if (/class="[^"]*\bresult__a\b[^"]*"/i.test(html)) {
return false;
}
return /g-recaptcha|are you a human|id="challenge-form"|name="challenge"/i.test(html);
}
function parseDuckDuckGoHtml(html: string): DuckDuckGoResult[] {
const results: DuckDuckGoResult[] = [];
const resultRegex = /<a\b(?=[^>]*\bclass="[^"]*\bresult__a\b[^"]*")([^>]*)>([\s\S]*?)<\/a>/gi;
const nextResultRegex = /<a\b(?=[^>]*\bclass="[^"]*\bresult__a\b[^"]*")[^>]*>/i;
const snippetRegex = /<a\b(?=[^>]*\bclass="[^"]*\bresult__snippet\b[^"]*")[^>]*>([\s\S]*?)<\/a>/i;
for (const match of html.matchAll(resultRegex)) {
const rawAttributes = match[1] ?? "";
const rawTitle = match[2] ?? "";
const rawUrl = readHrefAttribute(rawAttributes);
const matchEnd = (match.index ?? 0) + match[0].length;
const trailingHtml = html.slice(matchEnd);
const nextResultIndex = trailingHtml.search(nextResultRegex);
const scopedTrailingHtml =
nextResultIndex >= 0 ? trailingHtml.slice(0, nextResultIndex) : trailingHtml;
const rawSnippet = snippetRegex.exec(scopedTrailingHtml)?.[1] ?? "";
const title = decodeHtmlEntities(stripHtml(rawTitle));
const url = decodeDuckDuckGoUrl(decodeHtmlEntities(rawUrl));
const snippet = decodeHtmlEntities(stripHtml(rawSnippet));
if (title && url) {
results.push({ title, url, snippet });
}
}
return results;
}
export async function runDuckDuckGoSearch(params: {
config?: OpenClawConfig;
query: string;
count?: number;
region?: string;
safeSearch?: DdgSafeSearch;
timeoutSeconds?: number;
cacheTtlMinutes?: number;
}): Promise<Record<string, unknown>> {
const count = resolveSearchCount(params.count, DEFAULT_SEARCH_COUNT);
const region = params.region ?? resolveDdgRegion(params.config);
const safeSearch =
params.safeSearch === "strict" ||
params.safeSearch === "moderate" ||
params.safeSearch === "off"
? params.safeSearch
: resolveDdgSafeSearch(params.config);
const timeoutSeconds = resolveTimeoutSeconds(params.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS);
const cacheTtlMs = resolveCacheTtlMs(params.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES);
const cacheKey = normalizeCacheKey(
JSON.stringify({
provider: "duckduckgo",
query: params.query,
count,
region: region ?? "",
safeSearch,
}),
);
const cached = readCache(DDG_SEARCH_CACHE, cacheKey);
if (cached) {
return { ...cached.value, cached: true };
}
const url = new URL(DDG_HTML_ENDPOINT);
url.searchParams.set("q", params.query);
if (region) {
url.searchParams.set("kl", region);
}
url.searchParams.set("kp", DDG_SAFE_SEARCH_PARAM[safeSearch]);
const startedAt = Date.now();
const results = await withTrustedWebSearchEndpoint(
{
url: url.toString(),
timeoutSeconds,
init: {
method: "GET",
headers: {
"User-Agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
},
},
},
async (response) => {
if (!response.ok) {
const detail = (await readResponseText(response, { maxBytes: 64_000 })).text;
throw new Error(
`DuckDuckGo search error (${response.status}): ${detail || response.statusText}`,
);
}
const html = await response.text();
if (isBotChallenge(html)) {
throw new Error("DuckDuckGo returned a bot-detection challenge.");
}
return parseDuckDuckGoHtml(html).slice(0, count);
},
);
const payload = {
query: params.query,
provider: "duckduckgo",
count: results.length,
tookMs: Date.now() - startedAt,
externalContent: {
untrusted: true,
source: "web_search",
provider: "duckduckgo",
wrapped: true,
},
results: results.map((result) => ({
title: wrapWebContent(result.title, "web_search"),
url: result.url,
snippet: result.snippet ? wrapWebContent(result.snippet, "web_search") : "",
siteName: resolveSiteName(result.url) || undefined,
})),
} satisfies Record<string, unknown>;
writeCache(DDG_SEARCH_CACHE, cacheKey, payload, cacheTtlMs);
return payload;
}
export const __testing = {
decodeDuckDuckGoUrl,
decodeHtmlEntities,
isBotChallenge,
parseDuckDuckGoHtml,
};

View File

@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from "vitest";
const runDuckDuckGoSearch = vi.fn(async (params: Record<string, unknown>) => params);
vi.mock("./ddg-client.js", () => ({
runDuckDuckGoSearch,
}));
describe("duckduckgo web search provider", () => {
it("exposes keyless metadata and enables the plugin in config", async () => {
const { createDuckDuckGoWebSearchProvider } = await import("./ddg-search-provider.js");
const provider = createDuckDuckGoWebSearchProvider();
if (!provider.applySelectionConfig) {
throw new Error("Expected applySelectionConfig to be defined");
}
const applied = provider.applySelectionConfig({});
expect(provider.id).toBe("duckduckgo");
expect(provider.requiresCredential).toBe(false);
expect(provider.credentialPath).toBe("");
expect(applied.plugins?.entries?.duckduckgo?.enabled).toBe(true);
});
it("maps generic tool arguments into DuckDuckGo search params", async () => {
const { createDuckDuckGoWebSearchProvider } = await import("./ddg-search-provider.js");
const provider = createDuckDuckGoWebSearchProvider();
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,
region: "us-en",
safeSearch: "off",
});
expect(runDuckDuckGoSearch).toHaveBeenCalledWith({
config: { test: true },
query: "openclaw docs",
count: 4,
region: "us-en",
safeSearch: "off",
});
expect(result).toEqual({
config: { test: true },
query: "openclaw docs",
count: 4,
region: "us-en",
safeSearch: "off",
});
});
});

View File

@@ -0,0 +1,71 @@
import { Type } from "@sinclair/typebox";
import {
enablePluginInConfig,
getScopedCredentialValue,
readNumberParam,
readStringParam,
setScopedCredentialValue,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search";
import { runDuckDuckGoSearch } from "./ddg-client.js";
const DuckDuckGoSearchSchema = 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,
}),
),
region: Type.Optional(
Type.String({
description: "Optional DuckDuckGo region code such as us-en, uk-en, or de-de.",
}),
),
safeSearch: Type.Optional(
Type.String({
description: "SafeSearch level: strict, moderate, or off.",
}),
),
},
{ additionalProperties: false },
);
export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin {
return {
id: "duckduckgo",
label: "DuckDuckGo Search",
hint: "Free web search fallback with no API key required",
requiresCredential: false,
envVars: [],
placeholder: "(no key needed)",
signupUrl: "https://duckduckgo.com/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 100,
credentialPath: "",
inactiveSecretPaths: [],
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "duckduckgo"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "duckduckgo", value),
applySelectionConfig: (config) => enablePluginInConfig(config, "duckduckgo").config,
createTool: (ctx) => ({
description:
"Search the web using DuckDuckGo. Returns titles, URLs, and snippets with no API key required.",
parameters: DuckDuckGoSearchSchema,
execute: async (args) =>
await runDuckDuckGoSearch({
config: ctx.config,
query: readStringParam(args, "query", { required: true }),
count: readNumberParam(args, "count", { integer: true }),
region: readStringParam(args, "region"),
safeSearch: readStringParam(args, "safeSearch") as
| "strict"
| "moderate"
| "off"
| undefined,
}),
}),
};
}

2
pnpm-lock.yaml generated
View File

@@ -341,6 +341,8 @@ importers:
specifier: workspace:*
version: link:../..
extensions/duckduckgo: {}
extensions/elevenlabs: {}
extensions/exa: {}

View File

@@ -1,4 +1,5 @@
import bravePlugin from "../extensions/brave/index.js";
import duckduckgoPlugin from "../extensions/duckduckgo/index.js";
import exaPlugin from "../extensions/exa/index.js";
import firecrawlPlugin from "../extensions/firecrawl/index.js";
import googlePlugin from "../extensions/google/index.js";
@@ -29,6 +30,12 @@ export const bundledWebSearchPluginRegistrations: ReadonlyArray<{
},
credentialValue: "exa-test",
},
{
get plugin() {
return duckduckgoPlugin;
},
credentialValue: "duckduckgo-no-key-needed",
},
{
get plugin() {
return firecrawlPlugin;

View File

@@ -491,4 +491,83 @@ describe("runConfigureWizard", () => {
}),
);
});
it("skips the API key prompt for keyless web search providers", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: false,
valid: true,
config: {},
issues: [],
});
mocks.resolveGatewayPort.mockReturnValue(18789);
mocks.probeGatewayReachable.mockResolvedValue({ ok: false });
mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" });
mocks.summarizeExistingConfig.mockReturnValue("");
mocks.createClackPrompter.mockReturnValue({});
mocks.resolveSearchProviderOptions.mockReturnValue([
{
id: "duckduckgo",
label: "DuckDuckGo Search",
hint: "Free fallback",
requiresCredential: false,
envVars: [],
placeholder: "(no key needed)",
signupUrl: "https://duckduckgo.com/",
docsUrl: "https://docs.openclaw.ai/tools/web",
credentialPath: "",
},
]);
mocks.applySearchProviderSelection.mockImplementation(
(cfg: OpenClawConfig, provider: string) => ({
...cfg,
tools: {
...cfg.tools,
web: {
...cfg.tools?.web,
search: {
provider,
enabled: true,
},
},
},
plugins: {
...cfg.plugins,
entries: {
...cfg.plugins?.entries,
duckduckgo: {
enabled: true,
},
},
},
}),
);
const selectQueue = ["local", "duckduckgo"];
const confirmQueue = [true, false];
mocks.clackSelect.mockImplementation(async () => selectQueue.shift());
mocks.clackConfirm.mockImplementation(async () => confirmQueue.shift());
mocks.clackIntro.mockResolvedValue(undefined);
mocks.clackOutro.mockResolvedValue(undefined);
await runConfigureWizard(
{ command: "configure", sections: ["web"] },
{
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
);
expect(mocks.clackText).not.toHaveBeenCalled();
expect(mocks.applySearchProviderSelection).toHaveBeenCalledWith(
expect.objectContaining({
gateway: expect.objectContaining({ mode: "local" }),
}),
"duckduckgo",
);
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining("works without an API key"),
"Web search",
);
});
});

View File

@@ -181,6 +181,9 @@ async function promptWebToolsConfig(
if (!entry) {
return false;
}
if (entry.requiresCredential === false) {
return true;
}
return hasExistingKey(nextConfig, provider) || hasKeyInEnv(entry);
};
@@ -195,7 +198,7 @@ async function promptWebToolsConfig(
note(
[
"Web search lets your agent look things up online using the `web_search` tool.",
"Choose a provider and paste your API key.",
"Choose a provider. Some providers need an API key, and some work key-free.",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
@@ -236,7 +239,12 @@ async function promptWebToolsConfig(
return {
value: entry.id,
label: entry.label,
hint: configured ? `${entry.hint} · configured` : entry.hint,
hint:
entry.requiresCredential === false
? `${entry.hint} · key-free`
: configured
? `${entry.hint} · configured`
: entry.hint,
};
});
@@ -257,39 +265,53 @@ async function promptWebToolsConfig(
const keyConfigured = hasExistingKey(nextConfig, providerChoice);
const envAvailable = entry.envVars.some((k) => Boolean(process.env[k]?.trim()));
const envVarNames = entry.envVars.join(" / ");
const needsCredential = entry.requiresCredential !== false;
const keyInput = guardCancel(
await text({
message: keyConfigured
? envAvailable
? `${credentialLabel} (leave blank to keep current or use ${envVarNames})`
: `${credentialLabel} (leave blank to keep current)`
: envAvailable
? `${credentialLabel} (paste it here; leave blank to use ${envVarNames})`
: credentialLabel,
placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder,
}),
runtime,
);
const key = String(keyInput ?? "").trim();
if (key || existingKey) {
workingConfig = applySearchKey(workingConfig, providerChoice, (key || existingKey)!);
nextSearch = { ...workingConfig.tools?.web?.search };
} else if (keyConfigured || envAvailable) {
if (!needsCredential) {
workingConfig = applySearchProviderSelection(workingConfig, providerChoice);
nextSearch = { ...workingConfig.tools?.web?.search };
} else {
nextSearch = { ...nextSearch, provider: providerChoice };
note(
[
"No key stored yet — web_search won't work until a key is available.",
`Store your ${credentialLabel} here or set ${envVarNames} in the Gateway environment.`,
`Get your API key at: ${entry.signupUrl}`,
"Docs: https://docs.openclaw.ai/tools/web",
`${entry.label} works without an API key.`,
"OpenClaw enabled the plugin and selected it as your web_search provider.",
`Docs: ${entry.docsUrl ?? "https://docs.openclaw.ai/tools/web"}`,
].join("\n"),
"Web search",
);
} else {
const keyInput = guardCancel(
await text({
message: keyConfigured
? envAvailable
? `${credentialLabel} (leave blank to keep current or use ${envVarNames})`
: `${credentialLabel} (leave blank to keep current)`
: envAvailable
? `${credentialLabel} (paste it here; leave blank to use ${envVarNames})`
: credentialLabel,
placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder,
}),
runtime,
);
const key = String(keyInput ?? "").trim();
if (key || existingKey) {
workingConfig = applySearchKey(workingConfig, providerChoice, (key || existingKey)!);
nextSearch = { ...workingConfig.tools?.web?.search };
} else if (keyConfigured || envAvailable) {
workingConfig = applySearchProviderSelection(workingConfig, providerChoice);
nextSearch = { ...workingConfig.tools?.web?.search };
} else {
nextSearch = { ...nextSearch, provider: providerChoice };
note(
[
"No key stored yet — web_search won't work until a key is available.",
`Store your ${credentialLabel} here or set ${envVarNames} in the Gateway environment.`,
`Get your API key at: ${entry.signupUrl}`,
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
}
}
}
}

View File

@@ -76,6 +76,23 @@ function createBundledFirecrawlEntry(): PluginWebSearchProviderEntry {
};
}
function createBundledDuckDuckGoEntry(): PluginWebSearchProviderEntry {
return {
id: "duckduckgo",
pluginId: "duckduckgo",
label: "DuckDuckGo Search",
hint: "Free fallback",
requiresCredential: false,
envVars: [],
placeholder: "(no key needed)",
signupUrl: "https://duckduckgo.com/",
credentialPath: "",
getCredentialValue: () => "duckduckgo-no-key-needed",
setCredentialValue: () => {},
createTool: () => null,
};
}
describe("onboard-search provider resolution", () => {
afterEach(() => {
vi.resetModules();
@@ -207,4 +224,34 @@ describe("onboard-search provider resolution", () => {
expect(mod.resolveExistingKey(cfg, "firecrawl")).toBeUndefined();
expect(mod.applySearchProviderSelection(cfg, "firecrawl")).toBe(cfg);
});
it("defaults to a keyless provider when no search credentials exist", async () => {
const duckduckgoEntry = createBundledDuckDuckGoEntry();
mocks.resolvePluginWebSearchProviders.mockImplementation((params) =>
params?.config ? [duckduckgoEntry] : [duckduckgoEntry],
);
const mod = await import("./onboard-search.js");
const notes: string[] = [];
const prompter = {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async (message: string) => {
notes.push(message);
}),
select: vi.fn(async () => "duckduckgo"),
multiselect: vi.fn(async () => []),
text: vi.fn(async () => {
throw new Error("text prompt should not run for keyless providers");
}),
confirm: vi.fn(async () => true),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
};
const result = await mod.setupSearch({} as OpenClawConfig, {} as never, prompter as never);
expect(result.tools?.web?.search?.provider).toBe("duckduckgo");
expect(result.plugins?.entries?.duckduckgo?.enabled).toBe(true);
expect(notes.some((message) => message.includes("works without an API key"))).toBe(true);
});
});

View File

@@ -10,6 +10,7 @@ import {
listBundledWebSearchProviders,
resolveBundledWebSearchPluginId,
} from "../plugins/bundled-web-search.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -23,8 +24,11 @@ type SearchConfig = NonNullable<NonNullable<NonNullable<OpenClawConfig["tools"]>
type MutableSearchConfig = SearchConfig & Record<string, unknown>;
function resolveSearchProviderCredentialLabel(
entry: Pick<PluginWebSearchProviderEntry, "label" | "credentialLabel">,
entry: Pick<PluginWebSearchProviderEntry, "label" | "credentialLabel" | "requiresCredential">,
): string {
if (entry.requiresCredential === false) {
return `${entry.label} setup`;
}
return entry.credentialLabel?.trim() || `${entry.label} API key`;
}
@@ -96,6 +100,22 @@ export function hasKeyInEnv(entry: Pick<PluginWebSearchProviderEntry, "envVars">
return entry.envVars.some((k) => Boolean(process.env[k]?.trim()));
}
function providerNeedsCredential(
entry: Pick<PluginWebSearchProviderEntry, "requiresCredential">,
): boolean {
return entry.requiresCredential !== false;
}
function providerIsReady(
config: OpenClawConfig,
entry: Pick<PluginWebSearchProviderEntry, "id" | "envVars" | "requiresCredential">,
): boolean {
if (!providerNeedsCredential(entry)) {
return true;
}
return hasExistingKey(config, entry.id) || hasKeyInEnv(entry);
}
function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown {
const search = config.tools?.web?.search;
const entry = resolveSearchProviderEntry(config, provider);
@@ -167,11 +187,24 @@ export function applySearchKey(
web: { ...config.tools?.web, search },
},
};
const next = providerEntry.applySelectionConfig?.(nextBase) ?? nextBase;
const next = applySearchProviderSelectionConfig(nextBase, providerEntry);
providerEntry.setConfiguredCredentialValue?.(next, key);
return next;
}
function applySearchProviderSelectionConfig(
config: OpenClawConfig,
providerEntry: Pick<PluginWebSearchProviderEntry, "pluginId" | "applySelectionConfig">,
): OpenClawConfig {
if (providerEntry.applySelectionConfig) {
return providerEntry.applySelectionConfig(config);
}
if (providerEntry.pluginId) {
return enablePluginInConfig(config, providerEntry.pluginId).config;
}
return config;
}
export function applySearchProviderSelection(
config: OpenClawConfig,
provider: SearchProvider,
@@ -195,7 +228,7 @@ export function applySearchProviderSelection(
},
},
};
return providerEntry.applySelectionConfig?.(nextBase) ?? nextBase;
return applySearchProviderSelectionConfig(nextBase, providerEntry);
}
function preserveDisabledState(original: OpenClawConfig, result: OpenClawConfig): OpenClawConfig {
@@ -283,7 +316,7 @@ export async function setupSearch(
await prompter.note(
[
"Web search lets your agent look things up online.",
"Choose a provider and paste your API key.",
"Choose a provider. Some providers need an API key, and some work key-free.",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
@@ -292,8 +325,12 @@ export async function setupSearch(
const existingProvider = config.tools?.web?.search?.provider;
const options = providerOptions.map((entry) => {
const configured = hasExistingKey(config, entry.id) || hasKeyInEnv(entry);
const hint = configured ? `${entry.hint} · configured` : entry.hint;
const hint =
entry.requiresCredential === false
? `${entry.hint} · key-free`
: providerIsReady(config, entry)
? `${entry.hint} · configured`
: entry.hint;
return { value: entry.id, label: entry.label, hint };
});
@@ -301,7 +338,7 @@ export async function setupSearch(
if (existingProvider && providerOptions.some((entry) => entry.id === existingProvider)) {
return existingProvider;
}
const detected = providerOptions.find((e) => hasExistingKey(config, e.id) || hasKeyInEnv(e));
const detected = providerOptions.find((entry) => providerIsReady(config, entry));
if (detected) {
return detected.id;
}
@@ -334,6 +371,7 @@ export async function setupSearch(
const existingKey = resolveExistingKey(config, choice);
const keyConfigured = hasExistingKey(config, choice);
const envAvailable = hasKeyInEnv(entry);
const needsCredential = providerNeedsCredential(entry);
if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) {
const result = existingKey
@@ -342,6 +380,18 @@ export async function setupSearch(
return preserveDisabledState(config, result);
}
if (!needsCredential) {
await prompter.note(
[
`${entry.label} works without an API key.`,
"OpenClaw will enable the plugin and use it as your web_search provider.",
`Docs: ${entry.docsUrl ?? "https://docs.openclaw.ai/tools/web"}`,
].join("\n"),
"Web search",
);
return preserveDisabledState(config, applySearchProviderSelection(config, choice));
}
const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret
if (useSecretRefMode) {
if (keyConfigured) {

View File

@@ -752,6 +752,52 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [
channels: ["discord"],
},
},
{
dirName: "duckduckgo",
idHint: "duckduckgo-plugin",
source: {
source: "./index.ts",
built: "index.js",
},
packageName: "@openclaw/duckduckgo-plugin",
packageVersion: "2026.3.22",
packageDescription: "OpenClaw DuckDuckGo plugin",
packageManifest: {
extensions: ["./index.ts"],
},
manifest: {
id: "duckduckgo",
configSchema: {
type: "object",
additionalProperties: false,
properties: {
webSearch: {
type: "object",
additionalProperties: false,
properties: {
region: {
type: "string",
},
safeSearch: {
type: "string",
enum: ["strict", "moderate", "off"],
},
},
},
},
},
uiHints: {
"webSearch.region": {
label: "DuckDuckGo Region",
help: "Optional DuckDuckGo region code such as us-en, uk-en, or de-de.",
},
"webSearch.safeSearch": {
label: "DuckDuckGo SafeSearch",
help: "SafeSearch level for DuckDuckGo results.",
},
},
},
},
{
dirName: "elevenlabs",
idHint: "elevenlabs",

View File

@@ -1,5 +1,6 @@
export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = [
"brave",
"duckduckgo",
"exa",
"firecrawl",
"google",

View File

@@ -20,6 +20,7 @@ describe("bundled web search metadata", () => {
signupUrl: string;
docsUrl?: string;
autoDetectOrder?: number;
requiresCredential?: boolean;
credentialPath: string;
inactiveSecretPaths?: string[];
getConfiguredCredentialValue?: unknown;
@@ -38,6 +39,7 @@ describe("bundled web search metadata", () => {
signupUrl: params.provider.signupUrl,
docsUrl: params.provider.docsUrl,
autoDetectOrder: params.provider.autoDetectOrder,
requiresCredential: params.provider.requiresCredential,
credentialPath: params.provider.credentialPath,
inactiveSecretPaths: params.provider.inactiveSecretPaths,
hasConfiguredCredentialAccessors:
@@ -69,6 +71,7 @@ describe("bundled web search metadata", () => {
it("keeps bundled web search compat ids aligned with bundled manifests", () => {
expect(resolveBundledWebSearchPluginIds({})).toEqual([
"brave",
"duckduckgo",
"exa",
"firecrawl",
"google",

View File

@@ -905,6 +905,7 @@ export type WebSearchProviderPlugin = {
id: WebSearchProviderId;
label: string;
hint: string;
requiresCredential?: boolean;
credentialLabel?: string;
envVars: string[];
placeholder: string;

View File

@@ -7,7 +7,7 @@ import * as secretResolve from "./resolve.js";
import { createResolverContext } from "./runtime-shared.js";
import { resolveRuntimeWebTools } from "./runtime-web-tools.js";
type ProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity";
type ProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity" | "duckduckgo";
const { resolvePluginWebSearchProvidersMock } = vi.hoisted(() => ({
resolvePluginWebSearchProvidersMock: vi.fn(() => buildTestWebSearchProviders()),
@@ -36,6 +36,8 @@ function asConfig(value: unknown): OpenClawConfig {
function providerPluginId(provider: ProviderUnderTest): string {
switch (provider) {
case "duckduckgo":
return "duckduckgo";
case "gemini":
return "google";
case "grok":
@@ -81,13 +83,15 @@ function createTestProvider(params: {
id: params.provider,
label: params.provider,
hint: `${params.provider} test provider`,
envVars: [`${params.provider.toUpperCase()}_API_KEY`],
placeholder: `${params.provider}-...`,
requiresCredential: params.provider === "duckduckgo" ? false : undefined,
envVars: params.provider === "duckduckgo" ? [] : [`${params.provider.toUpperCase()}_API_KEY`],
placeholder: params.provider === "duckduckgo" ? "(no key needed)" : `${params.provider}-...`,
signupUrl: `https://example.com/${params.provider}`,
autoDetectOrder: params.order,
credentialPath,
inactiveSecretPaths: [credentialPath],
getCredentialValue: (searchConfig) => searchConfig?.apiKey,
credentialPath: params.provider === "duckduckgo" ? "" : credentialPath,
inactiveSecretPaths: params.provider === "duckduckgo" ? [] : [credentialPath],
getCredentialValue: (searchConfig) =>
params.provider === "duckduckgo" ? "duckduckgo-no-key-needed" : searchConfig?.apiKey,
setCredentialValue: (searchConfigTarget, value) => {
searchConfigTarget.apiKey = value;
},
@@ -117,6 +121,7 @@ function buildTestWebSearchProviders(): PluginWebSearchProviderEntry[] {
createTestProvider({ provider: "grok", pluginId: "xai", order: 30 }),
createTestProvider({ provider: "kimi", pluginId: "moonshot", order: 40 }),
createTestProvider({ provider: "perplexity", pluginId: "perplexity", order: 50 }),
createTestProvider({ provider: "duckduckgo", pluginId: "duckduckgo", order: 100 }),
];
}
@@ -231,6 +236,31 @@ describe("runtime web tools resolution", () => {
expect(metadata.fetch.firecrawl.apiKeySource).toBe("env");
});
it("auto-selects a keyless provider when no credentials are configured", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
enabled: true,
},
},
},
}),
});
expect(metadata.search.selectedProvider).toBe("duckduckgo");
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "WEB_SEARCH_AUTODETECT_SELECTED",
message: expect.stringContaining('keyless provider "duckduckgo"'),
}),
]),
);
});
it.each([
{
provider: "brave" as const,

View File

@@ -268,6 +268,9 @@ function keyPathForProvider(provider: PluginWebSearchProviderEntry): string {
}
function inactivePathsForProvider(provider: PluginWebSearchProviderEntry): string[] {
if (provider.requiresCredential === false) {
return [];
}
return provider.inactiveSecretPaths?.length
? provider.inactiveSecretPaths
: [provider.credentialPath];
@@ -357,8 +360,19 @@ export async function resolveRuntimeWebTools(params: {
let selectedProvider: WebSearchProvider | undefined;
let selectedResolution: SecretResolutionResult | undefined;
let keylessFallbackProvider: PluginWebSearchProviderEntry | undefined;
for (const provider of candidates) {
if (provider.requiresCredential === false) {
if (!keylessFallbackProvider) {
keylessFallbackProvider = provider;
}
if (configuredProvider) {
selectedProvider = provider.id;
break;
}
continue;
}
const path = keyPathForProvider(provider);
const value =
provider.getConfiguredCredentialValue?.(params.sourceConfig) ??
@@ -422,6 +436,15 @@ export async function resolveRuntimeWebTools(params: {
}
}
if (!selectedProvider && keylessFallbackProvider) {
selectedProvider = keylessFallbackProvider.id;
selectedResolution = {
source: "missing",
secretRefConfigured: false,
fallbackUsedAfterRefFailure: false,
};
}
const failUnresolvedSearchNoFallback = (unresolved: { path: string; reason: string }) => {
const diagnostic: RuntimeWebDiagnostic = {
code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
@@ -449,9 +472,14 @@ export async function resolveRuntimeWebTools(params: {
}
if (selectedProvider) {
const selectedProviderEntry = providers.find((entry) => entry.id === selectedProvider);
const selectedDetails =
selectedProviderEntry?.requiresCredential === false
? `tools.web.search auto-detected keyless provider "${selectedProvider}" as the default fallback.`
: `tools.web.search auto-detected provider "${selectedProvider}" from available credentials.`;
const diagnostic: RuntimeWebDiagnostic = {
code: "WEB_SEARCH_AUTODETECT_SELECTED",
message: `tools.web.search auto-detected provider "${selectedProvider}" from available credentials.`,
message: selectedDetails,
path: "tools.web.search.provider",
};
diagnostics.push(diagnostic);

View File

@@ -121,4 +121,42 @@ describe("web search runtime", () => {
result: { query: "hello", ok: true },
});
});
it("falls back to a keyless provider when no credentials are available", async () => {
const registry = createEmptyPluginRegistry();
registry.webSearchProviders.push({
pluginId: "duckduckgo",
pluginName: "DuckDuckGo",
provider: {
id: "duckduckgo",
label: "DuckDuckGo Search",
hint: "Keyless fallback",
requiresCredential: false,
envVars: [],
placeholder: "(no key needed)",
signupUrl: "https://duckduckgo.com/",
credentialPath: "",
autoDetectOrder: 100,
getCredentialValue: () => "duckduckgo-no-key-needed",
setCredentialValue: () => {},
createTool: () => ({
description: "duckduckgo",
parameters: {},
execute: async (args) => ({ ...args, provider: "duckduckgo" }),
}),
},
source: "test",
});
setActivePluginRegistry(registry);
await expect(
runWebSearch({
config: {},
args: { query: "fallback" },
}),
).resolves.toEqual({
provider: "duckduckgo",
result: { query: "fallback", provider: "duckduckgo" },
});
});
});

View File

@@ -60,14 +60,27 @@ function readProviderEnvValue(envVars: string[]): string | undefined {
return undefined;
}
function providerRequiresCredential(
provider: Pick<PluginWebSearchProviderEntry, "requiresCredential">,
): boolean {
return provider.requiresCredential !== false;
}
function hasEntryCredential(
provider: Pick<
PluginWebSearchProviderEntry,
"credentialPath" | "envVars" | "getConfiguredCredentialValue" | "getCredentialValue"
| "credentialPath"
| "envVars"
| "getConfiguredCredentialValue"
| "getCredentialValue"
| "requiresCredential"
>,
config: OpenClawConfig | undefined,
search: WebSearchConfig | undefined,
): boolean {
if (!providerRequiresCredential(provider)) {
return true;
}
const rawValue =
provider.getConfiguredCredentialValue?.(config) ??
provider.getCredentialValue(search as Record<string, unknown> | undefined);
@@ -122,7 +135,12 @@ export function resolveWebSearchProviderId(params: {
}
if (!raw) {
let keylessFallbackProviderId = "";
for (const provider of providers) {
if (!providerRequiresCredential(provider)) {
keylessFallbackProviderId ||= provider.id;
continue;
}
if (!hasEntryCredential(provider, params.config, params.search)) {
continue;
}
@@ -131,6 +149,12 @@ export function resolveWebSearchProviderId(params: {
);
return provider.id;
}
if (keylessFallbackProviderId) {
logVerbose(
`web_search: no provider configured and no credentials found, falling back to keyless provider "${keylessFallbackProviderId}"`,
);
return keylessFallbackProviderId;
}
}
return providers[0]?.id ?? "";