feat(firecrawl): add keyless scrape support (#94551)

Merged under maintainer approval while exact-head CI was queue-bound.

Co-authored-by: Developers Digest <124798203+developersdigest@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@users.noreply.github.com>
This commit is contained in:
Vincent Koc
2026-06-19 00:23:41 +08:00
committed by GitHub
parent fa4f1abb29
commit beea31a6b5
28 changed files with 478 additions and 101 deletions
+1 -1
View File
@@ -340,7 +340,7 @@ session establishment, not on every turn; use `/new`, `/reset`, or a gateway
restart after changing native plugin config.
- `plugins.entries.firecrawl.config.webFetch`: Firecrawl web-fetch provider settings.
- `apiKey`: Firecrawl API key (accepts SecretRef). Falls back to `plugins.entries.firecrawl.config.webSearch.apiKey`, legacy `tools.web.fetch.firecrawl.apiKey`, or `FIRECRAWL_API_KEY` env var.
- `apiKey`: Optional Firecrawl API key for higher limits (accepts SecretRef). Falls back to `plugins.entries.firecrawl.config.webSearch.apiKey`, legacy `tools.web.fetch.firecrawl.apiKey`, or `FIRECRAWL_API_KEY` env var.
- `baseUrl`: Firecrawl API base URL (default: `https://api.firecrawl.dev`; self-hosted overrides must target private/internal endpoints).
- `onlyMainContent`: extract only the main content from pages (default: `true`).
- `maxAgeMs`: maximum cache age in milliseconds (default: `172800000` / 2 days).
+1 -1
View File
@@ -857,7 +857,7 @@ lives on the [First-run FAQ](/help/faq-first-run).
- If you use allowlists, add `web_search`/`web_fetch`/`x_search` or `group:web`.
- `web_fetch` is enabled by default (unless explicitly disabled).
- If `tools.web.fetch.provider` is omitted, OpenClaw auto-detects the first ready fetch fallback provider from available credentials. Today the bundled provider is Firecrawl.
- If `tools.web.fetch.provider` is omitted, OpenClaw auto-detects the first ready fetch fallback provider from configured credentials. Today the bundled provider is Firecrawl.
- Daemons read env vars from `~/.openclaw/.env` (or the service environment).
Docs: [Web tools](/tools/web).
+2 -1
View File
@@ -154,7 +154,8 @@ See [Web tools](/tools/web).
### 5) Web fetch tool (Firecrawl)
`web_fetch` can call **Firecrawl** when an API key is present:
`web_fetch` can call **Firecrawl** with keyless starter access. Add an API key
for higher limits:
- `FIRECRAWL_API_KEY` or `plugins.entries.firecrawl.config.webFetch.apiKey`
+20 -9
View File
@@ -2,7 +2,8 @@
summary: "Firecrawl search, scrape, and web_fetch fallback"
read_when:
- You want Firecrawl-backed web extraction
- You need a Firecrawl API key
- You want keyless Firecrawl web_fetch
- You need a Firecrawl API key for search or higher limits
- You want Firecrawl as a web_search provider
- You want anti-bot extraction for web_fetch
title: "Firecrawl"
@@ -17,10 +18,12 @@ OpenClaw can use **Firecrawl** in three ways:
It is a hosted extraction/search service that supports bot circumvention and caching,
which helps with JS-heavy sites or pages that block plain HTTP fetches.
## Get an API key
## Keyless web_fetch and API keys
1. Create a Firecrawl account and generate an API key.
2. Store it in config or set `FIRECRAWL_API_KEY` in the gateway environment.
The explicitly selected hosted Firecrawl `web_fetch` fallback supports starter
access without an API key. Add `FIRECRAWL_API_KEY` in the gateway environment
or configure it when you need higher limits. Firecrawl `web_search` and
`firecrawl_scrape` require an API key.
## Configure Firecrawl search
@@ -57,17 +60,23 @@ Notes:
- `baseUrl` defaults to hosted Firecrawl at `https://api.firecrawl.dev`. Self-hosted overrides are allowed only for private/internal endpoints; HTTP is accepted only for those private targets.
- `FIRECRAWL_BASE_URL` is the shared env fallback for Firecrawl search and scrape base URLs.
## Configure Firecrawl scrape + web_fetch fallback
## Configure Firecrawl web_fetch fallback
```json5
{
tools: {
web: {
fetch: {
provider: "firecrawl", // explicit selection enables keyless fallback
},
},
},
plugins: {
entries: {
firecrawl: {
enabled: true,
config: {
webFetch: {
apiKey: "FIRECRAWL_API_KEY_HERE",
baseUrl: "https://api.firecrawl.dev",
onlyMainContent: true,
maxAgeMs: 172800000,
@@ -82,13 +91,15 @@ Notes:
Notes:
- Firecrawl fallback attempts run only when an API key is available (`plugins.entries.firecrawl.config.webFetch.apiKey` or `FIRECRAWL_API_KEY`).
- The explicitly selected Firecrawl `web_fetch` fallback works without an API key. When configured, OpenClaw sends `plugins.entries.firecrawl.config.webFetch.apiKey` or `FIRECRAWL_API_KEY` for higher limits.
- Choosing Firecrawl during onboarding or `openclaw configure --section web` enables the plugin and selects Firecrawl for `web_fetch` unless another fetch provider is already configured.
- `firecrawl_scrape` requires an API key.
- `maxAgeMs` controls how old cached results can be (ms). Default is 2 days.
- Legacy `tools.web.fetch.firecrawl.*` config is auto-migrated by `openclaw doctor --fix`.
- Firecrawl scrape/base URL overrides follow the same hosted/private rule as search: public hosted traffic uses `https://api.firecrawl.dev`; self-hosted overrides must resolve to private/internal endpoints.
- `firecrawl_scrape` rejects obvious private, loopback, metadata, and non-HTTP(S) target URLs before forwarding them to Firecrawl, matching the `web_fetch` target-safety contract for explicit Firecrawl scrape calls.
`firecrawl_scrape` reuses the same `plugins.entries.firecrawl.config.webFetch.*` settings and env vars.
`firecrawl_scrape` reuses the same `plugins.entries.firecrawl.config.webFetch.*` settings and env vars, including its required API key.
### Self-hosted Firecrawl
@@ -141,7 +152,7 @@ than basic-only scraping.
`web_fetch` extraction order:
1. Readability (local)
2. Firecrawl (if selected or auto-detected as the active web-fetch fallback)
2. Firecrawl (when selected, or auto-detected from configured credentials)
3. Basic HTML cleanup (last fallback)
The selection knob is `tools.web.fetch.provider`. If you omit it, OpenClaw
+5 -5
View File
@@ -48,7 +48,7 @@ Truncate output to this many characters.
Runs Readability (main-content extraction) on the HTML response.
</Step>
<Step title="Fallback (optional)">
If Readability fails and Firecrawl is configured, retries through the
If Readability fails and Firecrawl is selected, retries through the
Firecrawl API with bot-circumvention mode.
</Step>
<Step title="Cache">
@@ -120,7 +120,7 @@ If Readability extraction fails, `web_fetch` can fall back to
enabled: true,
config: {
webFetch: {
apiKey: "fc-...", // optional if FIRECRAWL_API_KEY is set
// apiKey: "fc-...", // optional; omit for keyless starter access
baseUrl: "https://api.firecrawl.dev",
onlyMainContent: true,
maxAgeMs: 86400000, // cache duration (1 day)
@@ -133,11 +133,11 @@ If Readability extraction fails, `web_fetch` can fall back to
}
```
`plugins.entries.firecrawl.config.webFetch.apiKey` supports SecretRef objects.
`plugins.entries.firecrawl.config.webFetch.apiKey` is optional and supports SecretRef objects.
Legacy `tools.web.fetch.firecrawl.*` config is auto-migrated by `openclaw doctor --fix`.
<Note>
If Firecrawl is enabled and its SecretRef is unresolved with no
If you configure a Firecrawl API-key SecretRef and it is unresolved with no
`FIRECRAWL_API_KEY` env fallback, gateway startup fails fast.
</Note>
@@ -151,7 +151,7 @@ Current runtime behavior:
- `tools.web.fetch.provider` selects the fetch fallback provider explicitly.
- If `provider` is omitted, OpenClaw auto-detects the first ready web-fetch
provider from available credentials. Non-sandboxed `web_fetch` can use
provider from configured credentials. Non-sandboxed `web_fetch` can use
installed plugins that declare `contracts.webFetchProviders` and register a
matching provider at runtime. Today the bundled provider is Firecrawl.
- Sandboxed `web_fetch` calls stay limited to bundled providers.
+1 -1
View File
@@ -307,7 +307,7 @@ plugin or run `openclaw doctor --fix` to clean up the stale config.
- choose it with `tools.web.fetch.provider`
- or omit that field and let OpenClaw auto-detect the first ready web-fetch
provider from available credentials
provider from configured credentials
- non-sandboxed `web_fetch` can use installed plugin providers that declare
`contracts.webFetchProviders`; sandboxed fetches stay bundled-only
- today the bundled web-fetch provider is Firecrawl, configured under
+1 -1
View File
@@ -14,7 +14,7 @@
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"undici": "8.3.0"
"undici": "8.5.0"
},
"openclaw": {
"extensions": [
+1 -1
View File
@@ -12,7 +12,7 @@
"discord-api-types": "0.38.48",
"libopus-wasm": "0.2.0",
"typebox": "1.1.39",
"undici": "8.3.0",
"undici": "8.5.0",
"ws": "8.21.0"
},
"devDependencies": {
+1 -1
View File
@@ -24,7 +24,7 @@
},
"webFetch.apiKey": {
"label": "Firecrawl Fetch API Key",
"help": "Firecrawl API key for web fetch fallback (fallback: FIRECRAWL_API_KEY env var).",
"help": "Optional for hosted keyless scraping; add a key for higher limits (fallback: FIRECRAWL_API_KEY env var).",
"sensitive": true,
"placeholder": "fc-..."
},
+8 -3
View File
@@ -87,6 +87,7 @@ export type FirecrawlScrapeParams = {
cfg?: OpenClawConfig;
url: string;
extractMode: "markdown" | "text";
access?: "credential" | "keyless";
maxChars?: number;
onlyMainContent?: boolean;
maxAgeMs?: number;
@@ -184,7 +185,7 @@ async function postFirecrawlJson<T>(
url: string;
mode?: FirecrawlEndpointMode;
timeoutSeconds: number;
apiKey: string;
apiKey?: string;
body: Record<string, unknown>;
errorLabel: string;
},
@@ -201,8 +202,10 @@ async function postFirecrawlJson<T>(
init: {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
// Hosted Firecrawl accepts starter scrape requests without a token.
// Send one only when configured so higher-limit accounts still apply.
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify(params.body),
},
@@ -522,7 +525,9 @@ export async function runFirecrawlScrape(
assertFirecrawlScrapeTargetAllowed(params.url);
const apiKey = resolveFirecrawlApiKey(params.cfg);
if (!apiKey) {
// Hosted v2/scrape accepts starter requests without a bearer token.
// Only the selected web_fetch provider opts into that access mode.
if (!apiKey && params.access !== "keyless") {
throw new Error(
"firecrawl_scrape needs a Firecrawl API key. Set FIRECRAWL_API_KEY in the Gateway environment, or configure plugins.entries.firecrawl.config.webFetch.apiKey.",
);
@@ -14,7 +14,9 @@ function ensureRecord(target: Record<string, unknown>, key: string): Record<stri
export const FIRECRAWL_WEB_FETCH_PROVIDER_SHARED = {
id: "firecrawl",
label: "Firecrawl",
hint: "Fetch pages with Firecrawl for JS-heavy or bot-protected sites.",
hint: "Fetch pages with keyless starter access; add a key for higher limits.",
requiresCredential: false,
credentialLabel: "Firecrawl API key (optional)",
envVars: ["FIRECRAWL_API_KEY"],
placeholder: "fc-...",
signupUrl: "https://www.firecrawl.dev/",
@@ -25,6 +25,7 @@ export function createFirecrawlWebFetchProvider(): WebFetchProviderPlugin {
cfg: config,
url,
extractMode,
access: "keyless",
maxChars,
...(proxy ? { proxy } : {}),
...(storeInCache !== undefined ? { storeInCache } : {}),
@@ -110,6 +110,18 @@ describe("firecrawl tools", () => {
throw new Error("expected Firecrawl plugin entry");
}
expect(pluginEntry.enabled).toBe(true);
expect(applied.tools?.web?.fetch?.provider).toBe("firecrawl");
const preservedFetchProvider = provider.applySelectionConfig({
tools: {
web: {
fetch: {
provider: "other",
},
},
},
} as OpenClawConfig);
expect(preservedFetchProvider.tools?.web?.fetch?.provider).toBe("other");
});
it("parses scrape payloads into wrapped external-content results", () => {
@@ -241,6 +253,72 @@ describe("firecrawl tools", () => {
expect(authHeader).toBe("Bearer firecrawl-test-key");
});
it("omits Firecrawl authorization for keyless scrape requests", async () => {
let capturedInit: RequestInit | undefined;
global.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
capturedInit = init;
return new Response(
JSON.stringify({
success: true,
data: {
markdown: "# Keyless",
metadata: {
sourceURL: "https://example.com/keyless-firecrawl",
statusCode: 200,
},
},
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
);
}) as typeof fetch;
await runActualFirecrawlScrape({
cfg: {
plugins: {
entries: {
firecrawl: {
config: {
webFetch: {
baseUrl: "https://api.firecrawl.dev",
},
},
},
},
},
} as OpenClawConfig,
url: "https://example.com/keyless-firecrawl",
extractMode: "markdown",
access: "keyless",
});
expect(new Headers(capturedInit?.headers).has("Authorization")).toBe(false);
});
it("requires credentials for direct scrape requests", async () => {
await expect(
runActualFirecrawlScrape({
cfg: {
plugins: {
entries: {
firecrawl: {
config: {
webFetch: {
baseUrl: "https://api.firecrawl.dev",
},
},
},
},
},
} as OpenClawConfig,
url: "https://example.com/direct-scrape",
extractMode: "markdown",
}),
).rejects.toThrow("firecrawl_scrape needs a Firecrawl API key");
});
it("blocks private and non-http scrape targets before Firecrawl requests", () => {
expect(
firecrawlClientTesting.assertFirecrawlScrapeTargetAllowed("https://example.com/page"),
@@ -402,6 +480,7 @@ describe("firecrawl tools", () => {
expect(provider.id).toBe("firecrawl");
expect(provider.credentialPath).toBe("plugins.entries.firecrawl.config.webFetch.apiKey");
expect(provider.requiresCredential).toBe(false);
const pluginEntry = applied.plugins?.entries?.firecrawl;
if (!pluginEntry) {
throw new Error("expected Firecrawl fetch plugin entry");
@@ -430,6 +509,7 @@ describe("firecrawl tools", () => {
cfg: { test: true },
url: "https://docs.openclaw.ai",
extractMode: "markdown",
access: "keyless",
maxChars: 1500,
proxy: "stealth",
storeInCache: false,
@@ -454,6 +534,7 @@ describe("firecrawl tools", () => {
cfg: { test: true },
url: "https://docs.openclaw.ai",
extractMode: "markdown",
access: "keyless",
maxChars: 1500,
});
await expect(
+27 -6
View File
@@ -1,6 +1,7 @@
// Firecrawl plugin module implements web search shared behavior.
import {
createWebSearchProviderContractFields,
enablePluginInConfig,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-contract";
@@ -22,6 +23,12 @@ export function getConfiguredFirecrawlFetchCredentialFallback(config?: {
}
export function buildFirecrawlWebSearchProviderBase(): Omit<WebSearchProviderPlugin, "createTool"> {
const contractFields = createWebSearchProviderContractFields({
credentialPath: FIRECRAWL_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "firecrawl" },
configuredCredential: { pluginId: "firecrawl" },
});
return {
id: "firecrawl",
label: "Firecrawl Search",
@@ -34,12 +41,26 @@ export function buildFirecrawlWebSearchProviderBase(): Omit<WebSearchProviderPlu
docsUrl: "https://docs.openclaw.ai/tools/firecrawl",
autoDetectOrder: 60,
credentialPath: FIRECRAWL_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: FIRECRAWL_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "firecrawl" },
configuredCredential: { pluginId: "firecrawl" },
selectionPluginId: "firecrawl",
}),
...contractFields,
applySelectionConfig: (config) => {
const enabled = enablePluginInConfig(config, "firecrawl");
if (!enabled.enabled || enabled.config.tools?.web?.fetch?.provider) {
return enabled.config;
}
return {
...enabled.config,
tools: {
...enabled.config.tools,
web: {
...enabled.config.tools?.web,
fetch: {
...enabled.config.tools?.web?.fetch,
provider: "firecrawl",
},
},
},
};
},
getConfiguredCredentialFallback: getConfiguredFirecrawlFetchCredentialFallback,
};
}
+1 -1
View File
@@ -5,7 +5,7 @@
"description": "OpenClaw Matrix QA runner plugin",
"type": "module",
"dependencies": {
"undici": "8.3.0"
"undici": "8.5.0"
},
"devDependencies": {
"@openclaw/matrix": "workspace:*",
+1 -1
View File
@@ -9,7 +9,7 @@
"@grammyjs/transformer-throttler": "1.2.1",
"grammy": "1.43.0",
"typebox": "1.1.39",
"undici": "8.3.0"
"undici": "8.5.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
+1 -1
View File
@@ -1949,7 +1949,7 @@
"tslog": "4.10.2",
"typebox": "1.1.39",
"typescript": "6.0.3",
"undici": "8.3.0",
"undici": "8.5.0",
"web-push": "3.6.7",
"web-tree-sitter": "0.26.9",
"ws": "8.21.0",
+22 -22
View File
@@ -87,7 +87,7 @@ importers:
version: 0.3.0
'@openclaw/proxyline':
specifier: 0.3.3
version: 0.3.3(undici@8.3.0)
version: 0.3.3(undici@8.5.0)
chalk:
specifier: 5.6.2
version: 5.6.2
@@ -188,8 +188,8 @@ importers:
specifier: 6.0.3
version: 6.0.3
undici:
specifier: 8.3.0
version: 8.3.0
specifier: 8.5.0
version: 8.5.0
web-push:
specifier: 3.6.7
version: 3.6.7
@@ -442,8 +442,8 @@ importers:
specifier: workspace:*
version: link:../../packages/plugin-sdk
undici:
specifier: 8.3.0
version: 8.3.0
specifier: 8.5.0
version: 8.5.0
extensions/byteplus:
devDependencies:
@@ -689,8 +689,8 @@ importers:
specifier: 1.1.39
version: 1.1.39
undici:
specifier: 8.3.0
version: 8.3.0
specifier: 8.5.0
version: 8.5.0
ws:
specifier: 8.21.0
version: 8.21.0
@@ -1364,8 +1364,8 @@ importers:
extensions/qa-matrix:
dependencies:
undici:
specifier: 8.3.0
version: 8.3.0
specifier: 8.5.0
version: 8.5.0
devDependencies:
'@openclaw/matrix':
specifier: workspace:*
@@ -1533,8 +1533,8 @@ importers:
specifier: 1.1.39
version: 1.1.39
undici:
specifier: 8.3.0
version: 8.3.0
specifier: 8.5.0
version: 8.5.0
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
@@ -7375,12 +7375,12 @@ packages:
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
undici@7.27.2:
resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==}
undici@7.28.0:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
engines: {node: '>=20.18.1'}
undici@8.3.0:
resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==}
undici@8.5.0:
resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==}
engines: {node: '>=22.19.0'}
unhomoglyph@1.0.6:
@@ -9223,9 +9223,9 @@ snapshots:
jszip: 3.10.1
tar: 7.5.16
'@openclaw/proxyline@0.3.3(undici@8.3.0)':
'@openclaw/proxyline@0.3.3(undici@8.5.0)':
dependencies:
undici: 8.3.0
undici: 8.5.0
'@opentelemetry/api-logs@0.219.0':
dependencies:
@@ -11895,7 +11895,7 @@ snapshots:
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 4.1.3
undici: 7.27.2
undici: 7.28.0
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
@@ -12763,7 +12763,7 @@ snapshots:
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
'@mozilla/readability': 0.6.0
'@openclaw/fs-safe': 0.3.0
'@openclaw/proxyline': 0.3.3(undici@8.3.0)
'@openclaw/proxyline': 0.3.3(undici@8.5.0)
'@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2
chokidar: 5.0.0
@@ -12801,7 +12801,7 @@ snapshots:
tslog: 4.10.2
typebox: 1.1.39
typescript: 6.0.3
undici: 8.3.0
undici: 8.5.0
web-push: 3.6.7
web-tree-sitter: 0.26.9
ws: 8.21.0
@@ -13873,9 +13873,9 @@ snapshots:
undici-types@7.24.6: {}
undici@7.27.2: {}
undici@7.28.0: {}
undici@8.3.0: {}
undici@8.5.0: {}
unhomoglyph@1.0.6: {}
+60 -5
View File
@@ -420,15 +420,25 @@ describe("runConfigureWizard", () => {
it("persists provider-owned web search config changes returned by setupSearch", async () => {
setupBaseWizardState();
mocks.setupSearch.mockImplementation(async (cfg: OpenClawConfig) =>
createEnabledWebSearchConfig("firecrawl", {
mocks.setupSearch.mockImplementation(async (cfg: OpenClawConfig) => {
const configured = createEnabledWebSearchConfig("firecrawl", {
enabled: true,
config: { webSearch: { apiKey: "fc-entered-key" } },
})(cfg),
);
})(cfg);
return {
...configured,
tools: {
...configured.tools,
web: {
...configured.tools?.web,
fetch: { provider: "firecrawl" },
},
},
};
});
queueWizardPrompts({
select: [],
confirm: [true, false],
confirm: [true, true],
});
await runWebConfigureWizard();
@@ -442,6 +452,12 @@ describe("runConfigureWizard", () => {
const search = getWebSearch(written);
expect(search.provider).toBe("firecrawl");
expect(search.enabled).toBe(true);
const tools = requireRecord(written.tools, "tools config");
const web = requireRecord(tools.web, "web config");
expect(requireRecord(web.fetch, "web fetch config")).toEqual({
enabled: true,
provider: "firecrawl",
});
const firecrawl = getPluginEntry(written, "firecrawl");
expect(firecrawl.enabled).toBe(true);
const firecrawlConfig = requireRecord(firecrawl.config, "firecrawl config");
@@ -449,6 +465,45 @@ describe("runConfigureWizard", () => {
"fc-entered-key",
);
expect(mocks.setupSearch).toHaveBeenCalledOnce();
expect(mocks.setupSearch).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
{ preserveDisabledSearchState: false },
);
});
it("keeps web_search disabled when provider setup has no credential", async () => {
setupBaseWizardState();
mocks.setupSearch.mockImplementation(async (cfg: OpenClawConfig) => ({
...cfg,
tools: {
...cfg.tools,
web: {
...cfg.tools?.web,
fetch: { provider: "firecrawl" },
search: { enabled: false, provider: "firecrawl" },
},
},
}));
queueWizardPrompts({
select: [],
confirm: [true, true],
});
await runWebConfigureWizard();
const written = requireWriteConfig();
expect(getWebSearch(written)).toMatchObject({
enabled: false,
provider: "firecrawl",
});
const tools = requireRecord(written.tools, "tools config");
const web = requireRecord(tools.web, "web config");
expect(requireRecord(web.fetch, "web fetch config")).toEqual({
enabled: true,
provider: "firecrawl",
});
});
it("notes unavailable web search providers under plugin policy", async () => {
+8 -4
View File
@@ -337,10 +337,14 @@ async function promptWebToolsConfig(
};
}
} else {
workingConfig = await setupSearch(workingConfig, runtime, prompter);
workingConfig = await setupSearch(workingConfig, runtime, prompter, {
preserveDisabledSearchState: false,
});
const selectedSearch = workingConfig.tools?.web?.search;
nextSearch = {
...workingConfig.tools?.web?.search,
enabled: workingConfig.tools?.web?.search?.provider ? true : existingSearch?.enabled,
...selectedSearch,
enabled:
selectedSearch?.enabled ?? (selectedSearch?.provider ? true : existingSearch?.enabled),
openaiCodex: {
...existingSearch?.openaiCodex,
...(nextSearch.openaiCodex as Record<string, unknown> | undefined),
@@ -359,7 +363,7 @@ async function promptWebToolsConfig(
);
const nextFetch = {
...existingFetch,
...workingConfig.tools?.web?.fetch,
enabled: enableFetch,
};
+39 -3
View File
@@ -96,7 +96,19 @@ function createSearchProviderEntry(id: string): PluginWebSearchProviderEntry {
>;
entries[metadata.pluginId] = { ...entries[metadata.pluginId], enabled: true };
next.plugins = { ...next.plugins, entries };
return next;
if (id !== "firecrawl" || next.tools?.web?.fetch?.provider) {
return next;
}
return {
...next,
tools: {
...next.tools,
web: {
...next.tools?.web,
fetch: { provider: "firecrawl" },
},
},
};
},
};
if (id === "kimi") {
@@ -391,7 +403,7 @@ describe("setupSearch", () => {
});
const result = await setupSearch(cfg, runtime, prompter);
expect(result.tools?.web?.search?.provider).toBe("brave");
expect(result.tools?.web?.search?.enabled).toBeUndefined();
expect(result.tools?.web?.search?.enabled).toBe(false);
const missingNote = notes.find((n) => n.message.includes("No Brave Search API key stored"));
expect(missingNote?.message).toContain("No Brave Search API key stored");
} finally {
@@ -403,6 +415,30 @@ describe("setupSearch", () => {
}
});
it("keeps keyless Firecrawl fetch configured when search setup has no key", async () => {
const original = process.env.FIRECRAWL_API_KEY;
delete process.env.FIRECRAWL_API_KEY;
try {
const { prompter } = createPrompter({
selectValue: "firecrawl",
textValue: "",
});
const result = await setupSearch({}, runtime, prompter);
expect(result.tools?.web?.search?.provider).toBe("firecrawl");
expect(result.tools?.web?.search?.enabled).toBe(false);
expect(result.tools?.web?.fetch?.provider).toBe("firecrawl");
expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true);
} finally {
if (original === undefined) {
delete process.env.FIRECRAWL_API_KEY;
} else {
process.env.FIRECRAWL_API_KEY = original;
}
}
});
it("keeps existing key when user leaves input blank", async () => {
const result = await runBlankPerplexityKeyEntry(
"existing-key", // pragma: allowlist secret
@@ -481,7 +517,7 @@ describe("setupSearch", () => {
});
expect(prompter.text).toHaveBeenCalled();
expect(result.tools?.web?.search?.provider).toBe("grok");
expect(result.tools?.web?.search?.enabled).toBeUndefined();
expect(result.tools?.web?.search?.enabled).toBe(false);
} finally {
if (original === undefined) {
delete process.env.XAI_API_KEY;
+40
View File
@@ -396,6 +396,46 @@ describe("runSearchSetupFlow", () => {
expect(xaiConfig?.xSearch?.model).toBe("grok-4-1-fast");
});
it("allows an explicit setup flow to reenable credential-ready web_search", async () => {
const select = vi.fn().mockResolvedValueOnce("grok").mockResolvedValueOnce("no");
const prompter = createWizardPrompter({
select: select as never,
});
const next = await runSearchSetupFlow(
{
plugins: {
allow: ["xai"],
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-test-key",
},
},
},
},
},
tools: {
web: {
search: {
enabled: false,
provider: "grok",
},
},
},
},
createNonExitingRuntime(),
prompter,
{ preserveDisabledSearchState: false },
);
expect(next.tools?.web?.search).toMatchObject({
enabled: true,
provider: "grok",
});
});
it("installs an external catalog search provider before enabling it", async () => {
const select = vi.fn().mockResolvedValueOnce("brave");
const text = vi.fn().mockResolvedValue("brave-test-key");
+19 -10
View File
@@ -353,6 +353,7 @@ function preserveDisabledState(original: OpenClawConfig, result: OpenClawConfig)
type SetupSearchOptions = {
quickstartDefaults?: boolean;
preserveDisabledSearchState?: boolean;
secretInputMode?: SecretInputMode;
};
@@ -388,7 +389,9 @@ async function finalizeSearchProviderSetup(params: {
}
next = installed.cfg;
}
next = preserveDisabledState(params.originalConfig, next);
if (params.opts?.preserveDisabledSearchState !== false) {
next = preserveDisabledState(params.originalConfig, next);
}
if (!params.entry.runSetup) {
return next;
}
@@ -399,7 +402,9 @@ async function finalizeSearchProviderSetup(params: {
quickstartDefaults: params.opts?.quickstartDefaults,
secretInputMode: params.opts?.secretInputMode,
});
return preserveDisabledState(params.originalConfig, next);
return params.opts?.preserveDisabledSearchState === false
? next
: preserveDisabledState(params.originalConfig, next);
}
export async function runSearchSetupFlow(
@@ -686,16 +691,20 @@ export async function runSearchSetupFlow(
const search: SearchConfig = {
...config.tools?.web?.search,
enabled: false,
provider: choice,
};
return {
...config,
tools: {
...config.tools,
web: {
...config.tools?.web,
search,
return applySearchProviderSelectionConfig(
{
...config,
tools: {
...config.tools,
web: {
...config.tools?.web,
search,
},
},
},
};
entry,
);
}
+32 -8
View File
@@ -404,7 +404,8 @@ export async function resolveRuntimeWebProviderSelection<
let keylessFallbackProvider: TProvider | undefined;
for (const provider of candidates) {
if (provider.requiresCredential === false) {
const isKeyless = provider.requiresCredential === false;
if (isKeyless) {
if (!params.configuredProvider && !params.allowKeylessAutoSelect) {
continue;
}
@@ -412,13 +413,6 @@ export async function resolveRuntimeWebProviderSelection<
keylessFallbackProvider ||= provider;
continue;
}
selectedProvider = provider.id;
selectedResolution = {
source: "missing" as TSource,
secretRefConfigured: false,
fallbackUsedAfterRefFailure: false,
};
break;
}
const path = params.inactivePathsForProvider(provider)[0] ?? "";
@@ -507,6 +501,18 @@ export async function resolveRuntimeWebProviderSelection<
});
}
if (
isKeyless &&
selectedCandidateResolution.secretRefConfigured &&
!selectedCandidateResolution.value
) {
continue;
}
if (isKeyless && !params.configuredProvider && !selectedCandidateResolution.value) {
continue;
}
if (params.configuredProvider) {
selectedProvider = provider.id;
selectedResolution = selectedCandidateResolution;
@@ -525,6 +531,24 @@ export async function resolveRuntimeWebProviderSelection<
break;
}
if (isKeyless) {
selectedProvider = provider.id;
selectedResolution = selectedCandidateResolution;
if (selectedCandidateResolution.value) {
setResolvedCredentialPath({
resolvedConfig: params.resolvedConfig,
path: selectedCandidatePath,
value: selectedCandidateResolution.value,
});
params.setResolvedCredential({
resolvedConfig: params.resolvedConfig,
provider,
value: selectedCandidateResolution.value,
});
}
break;
}
if (selectedCandidateResolution.value) {
selectedProvider = provider.id;
selectedResolution = selectedCandidateResolution;
+36
View File
@@ -256,6 +256,7 @@ function buildTestWebFetchProviders(): PluginWebFetchProviderEntry[] {
id: "firecrawl",
label: "firecrawl",
hint: "firecrawl test provider",
requiresCredential: false,
envVars: ["FIRECRAWL_API_KEY"],
placeholder: "fc-...",
signupUrl: "https://example.com/firecrawl",
@@ -582,6 +583,41 @@ describe("runtime web tools resolution", () => {
});
});
it("selects the configured keyless Firecrawl fetch provider without an API key", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
fetch: {
provider: "firecrawl",
},
},
},
}),
});
expect(metadata.fetch.providerSource).toBe("configured");
expect(metadata.fetch.selectedProvider).toBe("firecrawl");
expect(metadata.fetch.selectedProviderKeySource).toBe("missing");
});
it("does not auto-select keyless Firecrawl fetch without a credential", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
fetch: {
enabled: true,
},
},
},
}),
});
expect(metadata.fetch.providerSource).toBe("none");
expect(metadata.fetch.selectedProvider).toBeUndefined();
});
it("does not auto-select a keyless provider when no credentials are configured", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
+3 -4
View File
@@ -512,12 +512,11 @@ function readConfiguredFetchProviderCredentialFallback(params: {
}
function inactivePathsForFetchProvider(provider: PluginWebFetchProviderEntry): string[] {
if (provider.requiresCredential === false) {
return [];
}
return provider.inactiveSecretPaths?.length
? provider.inactiveSecretPaths
: [provider.credentialPath];
: provider.credentialPath
? [provider.credentialPath]
: [];
}
/**
+30
View File
@@ -179,6 +179,36 @@ describe("web fetch runtime", () => {
});
});
it("uses an explicitly configured keyless provider without an API key", () => {
const provider = createFirecrawlProvider({
requiresCredential: false,
});
resolvePluginWebFetchProvidersMock.mockReturnValue([provider]);
const resolved = resolveWebFetchDefinition({
config: {
tools: {
web: {
fetch: {
provider: "firecrawl",
},
},
},
} as OpenClawConfig,
});
expect(requireResolvedWebFetch(resolved).provider.id).toBe("firecrawl");
});
it("does not auto-detect a keyless provider without a credential", () => {
const provider = createFirecrawlProvider({
requiresCredential: false,
});
resolvePluginWebFetchProvidersMock.mockReturnValue([provider]);
expect(resolveWebFetchDefinition({ config: {} })).toBeNull();
});
it("auto-detects providers from configured fallback credentials", () => {
const provider = createFirecrawlProvider({
getConfiguredCredentialFallback: (config) => {
+33 -11
View File
@@ -1,5 +1,12 @@
/** Runtime provider selection and tool construction for the `web_fetch` tool. */
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import {
hasWebProviderEntryCredential,
providerRequiresCredential,
readWebProviderEnvValue,
resolveWebProviderConfig,
resolveWebProviderDefinition,
} from "../../packages/web-content-core/src/provider-runtime-shared.js";
import type { OpenClawConfig } from "../config/types.js";
import { logVerbose } from "../globals.js";
import type {
@@ -13,13 +20,6 @@ import {
import { sortWebFetchProvidersForAutoDetect } from "../plugins/web-fetch-providers.shared.js";
import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime-web-tools-state.js";
import type { RuntimeWebFetchMetadata } from "../secrets/runtime-web-tools.types.js";
import {
hasWebProviderEntryCredential,
providerRequiresCredential,
readWebProviderEnvValue,
resolveWebProviderConfig,
resolveWebProviderDefinition,
} from "../../packages/web-content-core/src/provider-runtime-shared.js";
// Runtime provider selection for the web_fetch tool. It resolves config,
// credentials, runtime metadata, and sandbox-safe bundled provider scopes.
@@ -38,10 +38,7 @@ type ResolveWebFetchDefinitionParams = {
};
/** Resolves whether web_fetch is enabled for the current config/sandbox. */
function resolveWebFetchEnabled(params: {
fetch?: WebFetchConfig;
sandboxed?: boolean;
}): boolean {
function resolveWebFetchEnabled(params: { fetch?: WebFetchConfig; sandboxed?: boolean }): boolean {
if (typeof params.fetch?.enabled === "boolean") {
return params.fetch.enabled;
}
@@ -78,6 +75,28 @@ function hasEntryCredential(
});
}
function hasAutoDetectCredential(
provider: Pick<
PluginWebFetchProviderEntry,
| "envVars"
| "getConfiguredCredentialFallback"
| "getConfiguredCredentialValue"
| "getCredentialValue"
| "requiresCredential"
>,
config: OpenClawConfig | undefined,
fetch: WebFetchConfig | undefined,
): boolean {
return hasEntryCredential(
{
...provider,
requiresCredential: true,
},
config,
fetch,
);
}
/** Reports whether a web_fetch provider has usable credentials. */
export function isWebFetchProviderConfigured(params: {
provider: Pick<
@@ -128,6 +147,9 @@ function resolveWebFetchProviderId(params: {
for (const provider of providers) {
if (!providerRequiresCredential(provider)) {
if (!hasAutoDetectCredential(provider, params.config, params.fetch)) {
continue;
}
logVerbose(
`web_fetch: ${raw ? `invalid configured provider "${raw}", ` : ""}auto-detected keyless provider "${provider.id}"`,
);