From beea31a6b5daa78c9a73b3d05b7ea9ec24c1c873 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 19 Jun 2026 00:23:41 +0800 Subject: [PATCH] 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 --- docs/gateway/configuration-reference.md | 2 +- docs/help/faq.md | 2 +- docs/reference/api-usage-costs.md | 3 +- docs/tools/firecrawl.md | 29 ++++--- docs/tools/web-fetch.md | 10 +-- docs/tools/web.md | 2 +- extensions/browser/package.json | 2 +- extensions/discord/package.json | 2 +- extensions/firecrawl/openclaw.plugin.json | 2 +- extensions/firecrawl/src/firecrawl-client.ts | 11 ++- .../src/firecrawl-fetch-provider-shared.ts | 4 +- .../firecrawl/src/firecrawl-fetch-provider.ts | 1 + .../firecrawl/src/firecrawl-tools.test.ts | 81 +++++++++++++++++++ extensions/firecrawl/web-search-shared.ts | 33 ++++++-- extensions/qa-matrix/package.json | 2 +- extensions/telegram/package.json | 2 +- package.json | 2 +- pnpm-lock.yaml | 44 +++++----- src/commands/configure.wizard.test.ts | 65 +++++++++++++-- src/commands/configure.wizard.ts | 12 ++- src/commands/onboard-search.test.ts | 42 +++++++++- src/flows/search-setup.test.ts | 40 +++++++++ src/flows/search-setup.ts | 29 ++++--- src/secrets/runtime-web-tools.shared.ts | 40 +++++++-- src/secrets/runtime-web-tools.test.ts | 36 +++++++++ src/secrets/runtime-web-tools.ts | 7 +- src/web-fetch/runtime.test.ts | 30 +++++++ src/web-fetch/runtime.ts | 44 +++++++--- 28 files changed, 478 insertions(+), 101 deletions(-) diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 3f9440d1a994..adf0e55e7ef1 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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). diff --git a/docs/help/faq.md b/docs/help/faq.md index 2a8ad0e0664d..f4817a823c04 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -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). diff --git a/docs/reference/api-usage-costs.md b/docs/reference/api-usage-costs.md index 48654d22fec4..0ad2538e5e06 100644 --- a/docs/reference/api-usage-costs.md +++ b/docs/reference/api-usage-costs.md @@ -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` diff --git a/docs/tools/firecrawl.md b/docs/tools/firecrawl.md index bfba75d45994..44896881c315 100644 --- a/docs/tools/firecrawl.md +++ b/docs/tools/firecrawl.md @@ -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 diff --git a/docs/tools/web-fetch.md b/docs/tools/web-fetch.md index 39fcc032e2ab..13a7d12f206e 100644 --- a/docs/tools/web-fetch.md +++ b/docs/tools/web-fetch.md @@ -48,7 +48,7 @@ Truncate output to this many characters. Runs Readability (main-content extraction) on the HTML response. - 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. @@ -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`. - 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. @@ -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. diff --git a/docs/tools/web.md b/docs/tools/web.md index a3b99873cac5..c28bb38da709 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -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 diff --git a/extensions/browser/package.json b/extensions/browser/package.json index 5dbf4baebf76..3d98091418f5 100644 --- a/extensions/browser/package.json +++ b/extensions/browser/package.json @@ -14,7 +14,7 @@ }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", - "undici": "8.3.0" + "undici": "8.5.0" }, "openclaw": { "extensions": [ diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 43f996bfb33e..bd570cabab6a 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -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": { diff --git a/extensions/firecrawl/openclaw.plugin.json b/extensions/firecrawl/openclaw.plugin.json index ee1276da4e9b..1d7ebb077209 100644 --- a/extensions/firecrawl/openclaw.plugin.json +++ b/extensions/firecrawl/openclaw.plugin.json @@ -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-..." }, diff --git a/extensions/firecrawl/src/firecrawl-client.ts b/extensions/firecrawl/src/firecrawl-client.ts index dbbc12a4e4d9..162bf6042d27 100644 --- a/extensions/firecrawl/src/firecrawl-client.ts +++ b/extensions/firecrawl/src/firecrawl-client.ts @@ -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( url: string; mode?: FirecrawlEndpointMode; timeoutSeconds: number; - apiKey: string; + apiKey?: string; body: Record; errorLabel: string; }, @@ -201,8 +202,10 @@ async function postFirecrawlJson( 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.", ); diff --git a/extensions/firecrawl/src/firecrawl-fetch-provider-shared.ts b/extensions/firecrawl/src/firecrawl-fetch-provider-shared.ts index 7ac3cfb52182..fd9694d509c5 100644 --- a/extensions/firecrawl/src/firecrawl-fetch-provider-shared.ts +++ b/extensions/firecrawl/src/firecrawl-fetch-provider-shared.ts @@ -14,7 +14,9 @@ function ensureRecord(target: Record, key: string): Record { 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( diff --git a/extensions/firecrawl/web-search-shared.ts b/extensions/firecrawl/web-search-shared.ts index f4c23b738c25..1fa4ebd879e6 100644 --- a/extensions/firecrawl/web-search-shared.ts +++ b/extensions/firecrawl/web-search-shared.ts @@ -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 { + 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 { + 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, }; } diff --git a/extensions/qa-matrix/package.json b/extensions/qa-matrix/package.json index d808ae3f7efb..04b3c2f667fa 100644 --- a/extensions/qa-matrix/package.json +++ b/extensions/qa-matrix/package.json @@ -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:*", diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index cfbd665af5ed..a003007e5971 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -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:*" diff --git a/package.json b/package.json index a8ab41d12325..d90b90562fff 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86dc61657fb1..c754a5edec9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index 8c6df841dcdb..0536b1a58b47 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -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 () => { diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 2fdfe224b56d..bc5642160f05 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -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 | undefined), @@ -359,7 +363,7 @@ async function promptWebToolsConfig( ); const nextFetch = { - ...existingFetch, + ...workingConfig.tools?.web?.fetch, enabled: enableFetch, }; diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index 1f2186bea78a..3b1d48883004 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -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; diff --git a/src/flows/search-setup.test.ts b/src/flows/search-setup.test.ts index 503e338d2bde..3f58da30bdcb 100644 --- a/src/flows/search-setup.test.ts +++ b/src/flows/search-setup.test.ts @@ -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"); diff --git a/src/flows/search-setup.ts b/src/flows/search-setup.ts index e44ff69e765a..75107e88b4a1 100644 --- a/src/flows/search-setup.ts +++ b/src/flows/search-setup.ts @@ -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, + ); } diff --git a/src/secrets/runtime-web-tools.shared.ts b/src/secrets/runtime-web-tools.shared.ts index 05d551f1a6ab..37d1fe324060 100644 --- a/src/secrets/runtime-web-tools.shared.ts +++ b/src/secrets/runtime-web-tools.shared.ts @@ -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; diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts index 795b47ef25df..7e851fc485b1 100644 --- a/src/secrets/runtime-web-tools.test.ts +++ b/src/secrets/runtime-web-tools.test.ts @@ -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({ diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index b65c750ddf6c..c02ac2628b77 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -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] + : []; } /** diff --git a/src/web-fetch/runtime.test.ts b/src/web-fetch/runtime.test.ts index 2723db4b24f7..5f4e9259f8b0 100644 --- a/src/web-fetch/runtime.test.ts +++ b/src/web-fetch/runtime.test.ts @@ -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) => { diff --git a/src/web-fetch/runtime.ts b/src/web-fetch/runtime.ts index 76fdfc8a0485..6dd0fc84e601 100644 --- a/src/web-fetch/runtime.ts +++ b/src/web-fetch/runtime.ts @@ -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}"`, );