diff --git a/.github/labeler.yml b/.github/labeler.yml index 67a74985465..c2b3da01b9d 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -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: diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index 4853ba0060b..bb66dad71e6 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -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 }, { diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index 2bf2c245c2a..9fff7b31ce7 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -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} diff --git a/extensions/duckduckgo/index.test.ts b/extensions/duckduckgo/index.test.ts new file mode 100644 index 00000000000..0b1459e8540 --- /dev/null +++ b/extensions/duckduckgo/index.test.ts @@ -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; + expect(provider.id).toBe("duckduckgo"); + expect(provider.requiresCredential).toBe(false); + expect(provider.envVars).toEqual([]); + }); +}); diff --git a/extensions/duckduckgo/index.ts b/extensions/duckduckgo/index.ts new file mode 100644 index 00000000000..c5f0c45a57f --- /dev/null +++ b/extensions/duckduckgo/index.ts @@ -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()); + }, +}); diff --git a/extensions/duckduckgo/openclaw.plugin.json b/extensions/duckduckgo/openclaw.plugin.json new file mode 100644 index 00000000000..e6c4d620275 --- /dev/null +++ b/extensions/duckduckgo/openclaw.plugin.json @@ -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"] + } + } + } + } + } +} diff --git a/extensions/duckduckgo/package.json b/extensions/duckduckgo/package.json new file mode 100644 index 00000000000..da44a630511 --- /dev/null +++ b/extensions/duckduckgo/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/duckduckgo-plugin", + "version": "2026.3.22", + "private": true, + "description": "OpenClaw DuckDuckGo plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/duckduckgo/src/config.test.ts b/extensions/duckduckgo/src/config.test.ts new file mode 100644 index 00000000000..d8511ca18e6 --- /dev/null +++ b/extensions/duckduckgo/src/config.test.ts @@ -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"); + }); +}); diff --git a/extensions/duckduckgo/src/config.ts b/extensions/duckduckgo/src/config.ts new file mode 100644 index 00000000000..6c67eebe7c2 --- /dev/null +++ b/extensions/duckduckgo/src/config.ts @@ -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; +} diff --git a/extensions/duckduckgo/src/ddg-client.test.ts b/extensions/duckduckgo/src/ddg-client.test.ts new file mode 100644 index 00000000000..c09597d0ba5 --- /dev/null +++ b/extensions/duckduckgo/src/ddg-client.test.ts @@ -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 & Chips … 'ok'")).toBe( + "Fish & Chips ... 'ok'", + ); + }); + + it("parses results when href appears before class", () => { + const html = ` + + Example & Co + + Fast search … with details + Direct result + Second snippet + `; + + 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 = ` + + +
+

Are you a human?

+
captcha
+
+ + + `; + + 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 = ` + Coding Challenge + A fun coding challenge for interview prep. + `; + + 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.", + }, + ]); + }); +}); diff --git a/extensions/duckduckgo/src/ddg-client.ts b/extensions/duckduckgo/src/ddg-client.ts new file mode 100644 index 00000000000..a2771dcc3e7 --- /dev/null +++ b/extensions/duckduckgo/src/ddg-client.ts @@ -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 = { + strict: "1", + moderate: "-1", + off: "-2", +}; + +const DDG_SEARCH_CACHE = new Map< + string, + { value: Record; insertedAt: number; expiresAt: number } +>(); + +type DuckDuckGoResult = { + title: string; + url: string; + snippet: string; +}; + +function decodeHtmlEntities(text: string): string { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(///g, "/") + .replace(/ /g, " ") + .replace(/–/g, "-") + .replace(/—/g, "--") + .replace(/…/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 = /]*\bclass="[^"]*\bresult__a\b[^"]*")([^>]*)>([\s\S]*?)<\/a>/gi; + const nextResultRegex = /]*\bclass="[^"]*\bresult__a\b[^"]*")[^>]*>/i; + const snippetRegex = /]*\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> { + 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; + + writeCache(DDG_SEARCH_CACHE, cacheKey, payload, cacheTtlMs); + return payload; +} + +export const __testing = { + decodeDuckDuckGoUrl, + decodeHtmlEntities, + isBotChallenge, + parseDuckDuckGoHtml, +}; diff --git a/extensions/duckduckgo/src/ddg-search-provider.test.ts b/extensions/duckduckgo/src/ddg-search-provider.test.ts new file mode 100644 index 00000000000..49a1c2d3ca7 --- /dev/null +++ b/extensions/duckduckgo/src/ddg-search-provider.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; + +const runDuckDuckGoSearch = vi.fn(async (params: Record) => 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", + }); + }); +}); diff --git a/extensions/duckduckgo/src/ddg-search-provider.ts b/extensions/duckduckgo/src/ddg-search-provider.ts new file mode 100644 index 00000000000..c683a0854d4 --- /dev/null +++ b/extensions/duckduckgo/src/ddg-search-provider.ts @@ -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, + }), + }), + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e1467571c2..f5b4de07798 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -341,6 +341,8 @@ importers: specifier: workspace:* version: link:../.. + extensions/duckduckgo: {} + extensions/elevenlabs: {} extensions/exa: {} diff --git a/src/bundled-web-search-registry.ts b/src/bundled-web-search-registry.ts index 8ea8d7414e4..2797bdab97f 100644 --- a/src/bundled-web-search-registry.ts +++ b/src/bundled-web-search-registry.ts @@ -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; diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index 290f6f62f87..6937586ed57 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -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", + ); + }); }); diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index c13f7b9a412..24dbe9a11d2 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -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", + ); + } } } } diff --git a/src/commands/onboard-search.providers.test.ts b/src/commands/onboard-search.providers.test.ts index db57239951b..b8c9e7f00b2 100644 --- a/src/commands/onboard-search.providers.test.ts +++ b/src/commands/onboard-search.providers.test.ts @@ -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); + }); }); diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index 4c2b7187daa..c54ebb9db93 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -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 type MutableSearchConfig = SearchConfig & Record; function resolveSearchProviderCredentialLabel( - entry: Pick, + entry: Pick, ): 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 return entry.envVars.some((k) => Boolean(process.env[k]?.trim())); } +function providerNeedsCredential( + entry: Pick, +): boolean { + return entry.requiresCredential !== false; +} + +function providerIsReady( + config: OpenClawConfig, + entry: Pick, +): 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, +): 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) { diff --git a/src/plugins/bundled-plugin-metadata.generated.ts b/src/plugins/bundled-plugin-metadata.generated.ts index f604b10050a..dfd68ef76f8 100644 --- a/src/plugins/bundled-plugin-metadata.generated.ts +++ b/src/plugins/bundled-plugin-metadata.generated.ts @@ -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", diff --git a/src/plugins/bundled-web-search-ids.ts b/src/plugins/bundled-web-search-ids.ts index ab986c42482..e73f543d637 100644 --- a/src/plugins/bundled-web-search-ids.ts +++ b/src/plugins/bundled-web-search-ids.ts @@ -1,5 +1,6 @@ export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = [ "brave", + "duckduckgo", "exa", "firecrawl", "google", diff --git a/src/plugins/bundled-web-search.test.ts b/src/plugins/bundled-web-search.test.ts index 1b7e7904091..4259ece924e 100644 --- a/src/plugins/bundled-web-search.test.ts +++ b/src/plugins/bundled-web-search.test.ts @@ -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", diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 918832d07e7..65e39e68de2 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -905,6 +905,7 @@ export type WebSearchProviderPlugin = { id: WebSearchProviderId; label: string; hint: string; + requiresCredential?: boolean; credentialLabel?: string; envVars: string[]; placeholder: string; diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts index a0640e17ea3..d4e13194620 100644 --- a/src/secrets/runtime-web-tools.test.ts +++ b/src/secrets/runtime-web-tools.test.ts @@ -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, diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index 45f94f235dd..c7341a10d31 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -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); diff --git a/src/web-search/runtime.test.ts b/src/web-search/runtime.test.ts index ab5a59ca993..b823a5f7c85 100644 --- a/src/web-search/runtime.test.ts +++ b/src/web-search/runtime.test.ts @@ -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" }, + }); + }); }); diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts index 273bfd8c8db..4c12081ae12 100644 --- a/src/web-search/runtime.ts +++ b/src/web-search/runtime.ts @@ -60,14 +60,27 @@ function readProviderEnvValue(envVars: string[]): string | undefined { return undefined; } +function providerRequiresCredential( + provider: Pick, +): 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 | 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 ?? "";