mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-24 07:01:49 +00:00
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:
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
|
||||
22
extensions/duckduckgo/index.test.ts
Normal file
22
extensions/duckduckgo/index.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
11
extensions/duckduckgo/index.ts
Normal file
11
extensions/duckduckgo/index.ts
Normal 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());
|
||||
},
|
||||
});
|
||||
32
extensions/duckduckgo/openclaw.plugin.json
Normal file
32
extensions/duckduckgo/openclaw.plugin.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
extensions/duckduckgo/package.json
Normal file
12
extensions/duckduckgo/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
78
extensions/duckduckgo/src/config.test.ts
Normal file
78
extensions/duckduckgo/src/config.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
41
extensions/duckduckgo/src/config.ts
Normal file
41
extensions/duckduckgo/src/config.ts
Normal 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;
|
||||
}
|
||||
75
extensions/duckduckgo/src/ddg-client.test.ts
Normal file
75
extensions/duckduckgo/src/ddg-client.test.ts
Normal 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 & Chips … 'ok'")).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 & Co
|
||||
</a>
|
||||
<a class="result__snippet">Fast search … 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.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
212
extensions/duckduckgo/src/ddg-client.ts
Normal file
212
extensions/duckduckgo/src/ddg-client.ts
Normal 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(/&/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 = /<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,
|
||||
};
|
||||
57
extensions/duckduckgo/src/ddg-search-provider.test.ts
Normal file
57
extensions/duckduckgo/src/ddg-search-provider.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
71
extensions/duckduckgo/src/ddg-search-provider.ts
Normal file
71
extensions/duckduckgo/src/ddg-search-provider.ts
Normal 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
2
pnpm-lock.yaml
generated
@@ -341,6 +341,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
extensions/duckduckgo: {}
|
||||
|
||||
extensions/elevenlabs: {}
|
||||
|
||||
extensions/exa: {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = [
|
||||
"brave",
|
||||
"duckduckgo",
|
||||
"exa",
|
||||
"firecrawl",
|
||||
"google",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -905,6 +905,7 @@ export type WebSearchProviderPlugin = {
|
||||
id: WebSearchProviderId;
|
||||
label: string;
|
||||
hint: string;
|
||||
requiresCredential?: boolean;
|
||||
credentialLabel?: string;
|
||||
envVars: string[];
|
||||
placeholder: string;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
Reference in New Issue
Block a user