Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions plugins/web/firecrawl/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
from typing import Any, Dict, List, Optional, TYPE_CHECKING

from agent.web_search_provider import WebSearchProvider
from tools.url_safety import is_safe_url
from tools.website_policy import check_website_access

logger = logging.getLogger(__name__)
Expand All @@ -73,6 +74,52 @@
_FIRECRAWL_CLS_CACHE: Optional[type] = None


class _KeylessFirecrawlClient:
"""Firecrawl client that works without an API key (Keyless mode).

Uses raw HTTP requests instead of the Firecrawl SDK, which requires
an API key to be set. Calls the same cloud endpoints.
"""

def __init__(self, api_url: str = "https://api.firecrawl.dev") -> None:
self.api_url = api_url.rstrip("/")
self._httpx_client: Optional[Any] = None

def _client(self) -> Any:
if self._httpx_client is None:
import httpx
self._httpx_client = httpx.Client(timeout=30.0)
return self._httpx_client

def search(self, query: str, limit: int = 5) -> dict:
"""Search via Firecrawl Keyless API."""
resp = self._client().post(
f"{self.api_url}/v1/search",
json={"query": query, "limit": limit},
)
resp.raise_for_status()
return resp.json()

def scrape(self, url: str, formats: Optional[list[str]] = None, **kwargs: Any) -> dict:
"""Scrape a single URL via Firecrawl Keyless API.

Signature matches the Firecrawl SDK's ``scrape()`` method so the
existing extract code works without modification.
"""
payload: dict = {"url": url}
if formats:
payload["formats"] = formats
resp = self._client().post(
f"{self.api_url}/v1/scrape",
json=payload,
)
resp.raise_for_status()
return resp.json()

def __repr__(self) -> str:
return f"<_KeylessFirecrawlClient api_url={self.api_url}>"


def _load_firecrawl_cls() -> type:
"""Import and cache ``firecrawl.Firecrawl``."""
global _FIRECRAWL_CLS_CACHE
Expand Down Expand Up @@ -255,6 +302,16 @@ def _get_firecrawl_client() -> Any:
if cached is not None and cached_config == client_config:
return cached

# Keyless mode: when no api_key is provided, use raw HTTP instead of SDK
# (the Firecrawl SDK requires an api_key, but the cloud API supports
# unauthenticated requests since Firecrawl Keyless launch, June 2026).
if "api_key" not in kwargs:
_wt._firecrawl_client = _KeylessFirecrawlClient(
api_url=kwargs.get("api_url", "https://api.firecrawl.dev")
)
_wt._firecrawl_client_config = client_config
return _wt._firecrawl_client

# Construct via the re-exported Firecrawl proxy on tools.web_tools so
# unit tests patching ``tools.web_tools.Firecrawl`` see their mock.
_wt._firecrawl_client = _wt.Firecrawl(**kwargs)
Expand Down Expand Up @@ -523,6 +580,26 @@ async def extract(self, urls: List[str], **kwargs: Any) -> List[Dict[str, Any]]:
title = metadata.get("title", "")
final_url = metadata.get("sourceURL", url)

# Re-check SSRF safety after any redirect reported by Firecrawl.
if not is_safe_url(final_url):
logger.info(
"Blocked redirected web_extract for unsafe final URL: %s",
final_url,
)
results.append(
{
"url": final_url,
"title": title,
"content": "",
"raw_content": "",
"error": (
"Blocked: URL targets a private or internal "
"network address"
),
}
)
continue

# Re-check website-access policy after any redirect
final_blocked = check_website_access(final_url)
if final_blocked:
Expand Down
Loading