fix(web-fetch): stop sending .env.example placeholder keys to providers (#8206)

* fix(web-fetch): stop sending .env.example placeholder keys to providers

make config and make setup copy .env.example into .env, and the template
set nine search/fetch provider keys to example values such as
JINA_API_KEY=your-jina-api-key. The default keyless Jina Reader sent
that text as a Bearer key, so every web_fetch on a fresh install failed
with 401 while make doctor reported web fetch as configured.

- .env.example comments those nine lines out, like every other credential
  in the template already is.
- provider_credentials.resolve_api_key reads a your-...-key/-token/-secret
  value as unset, so existing .env files that still hold the placeholders
  recover without editing, for every provider that resolves keys there.

* chore: leave the changelog entry to merge time

Per CONTRIBUTING.md (#8202), maintainers add changelog entries when they
merge a pull request.

* fix(web-search): route every provider key and doctor through one placeholder rule

Review follow-up for #8206:

- provider_credentials exposes is_template_placeholder(), the single
  definition of an .env.example example value. key/token/secret must be
  a whole hyphen-separated word, so a real value such as your-monkey or
  your-passkey is no longer dropped.
- Serper, Serply, Sofya, SpecStream, Unbrowse, Webz, Brave, Tencent WSA
  and You.com resolve config and environment keys through
  resolve_api_key instead of private strip-and-send copies, so an old
  .env that still holds the template value no longer sends it.
- make doctor asks the same predicate: a placeholder key is reported as
  still holding the .env.example value instead of "set", and a literal
  placeholder in config.yaml falls back to the environment key as the
  runtime does.
- A census test keeps credential environment reads under community/
  wrapped in resolve_api_key.

* fix(web-search): match shipped placeholders exactly and mirror Browserless precedence in doctor

Second review follow-up for #8206:

- is_template_placeholder() matches exactly the your-... values that
  .env.example ships instead of a pattern, so a token an operator chose
  (a self-hosted Browserless TOKEN such as your-review-team-token) is
  sent again. A test keeps the set equal to the template.
- make doctor mirrors Browserless: a configured token field is
  authoritative, blank or not, and BROWSERLESS_TOKEN is read only when
  the field is omitted. A blank or placeholder token now warns instead
  of reporting the variable, cross-checked against the client.
- The keyless Jina and You.com providers stay ok but name JINA_API_KEY
  or YDC_API_KEY when a placeholder there is ignored, and a literal
  placeholder api_key in config.yaml is named when no env key backs it.

* fix(doctor): match each web provider's key precedence; drop the dead Jina api_key from docs

Found while re-checking #8206 against every provider's runtime:

- Browserless used as web_fetch had no doctor key check; it now gets the
  same authoritative token handling as web_capture.
- InfoQuest reads only INFOQUEST_API_KEY, so doctor no longer counts an
  api_key field in its tool config as configured.
- GroundRoute needs a key for every call but had no doctor entry, so a
  missing key reported ok.
- You.com names a placeholder api_key in config that it ignores.
- The Jina web_fetch docs snippet carried api_key: $JINA_API_KEY, which
  the tool never reads. With JINA_API_KEY now commented out in
  .env.example, that line made config loading fail on a fresh install.
- The provider_credentials docstring now scopes doctor's use of the
  predicate to web provider keys, and the scripts guide no longer says
  .env.example ships live placeholder keys.
This commit is contained in:
kongxin 2026-11-13 23:23:29 +08:00 committed by GitHub
parent 077773a967
commit cee54cdd49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 521 additions and 100 deletions

View File

@ -1,40 +1,40 @@
# Serper API Key (Google Search) - https://serper.dev
SERPER_API_KEY=your-serper-api-key
# SERPER_API_KEY=your-serper-api-key
# Optional operator-controlled Serper-compatible API base URL, without /search or /images.
# The configured API key is sent in X-API-KEY to this host; use a trusted provider.
SERPER_BASE_URL=https://google.serper.dev
# Exa API Key - https://exa.ai
EXA_API_KEY=your-exa-api-key
# EXA_API_KEY=your-exa-api-key
# Optional operator-controlled Exa-compatible API base URL for the Exa web_search and web_fetch tools.
# The configured API key is sent in x-api-key to this host; use a trusted provider.
EXA_BASE_URL=https://api.exa.ai
# Serply API Key (Google Search, News and Scholar) - https://serply.io
SERPLY_API_KEY=your-serply-api-key
# SERPLY_API_KEY=your-serply-api-key
# TAVILY API Key
TAVILY_API_KEY=your-tavily-api-key
# TAVILY_API_KEY=your-tavily-api-key
# Optional Webz.io News Search key (select Webz in make setup)
# WEBZ_API_KEY=your-webz-api-key
# Jina API Key
JINA_API_KEY=your-jina-api-key
# Jina API Key (optional; the Jina Reader web_fetch works without one at a lower rate limit)
# JINA_API_KEY=your-jina-api-key
# InfoQuest API Key
INFOQUEST_API_KEY=your-infoquest-api-key
# INFOQUEST_API_KEY=your-infoquest-api-key
# Sofya API Key (web search and fetch) - https://sofya.co
SOFYA_API_KEY=your-sofya-api-key
# SOFYA_API_KEY=your-sofya-api-key
# SpecStream API Key (web search and fetch) - https://specstream.ai
SPECSTREAM_API_KEY=your-specstream-api-key
# SPECSTREAM_API_KEY=your-specstream-api-key
# Unbrowse API Key (web fetch) - https://unbrowse.ai/app
UNBROWSE_API_KEY=your-unbrowse-api-key
# UNBROWSE_API_KEY=your-unbrowse-api-key
# Browser CORS allowlist for split-origin or port-forwarded deployments (comma-separated exact origins).
# Leave unset when using the unified nginx endpoint, e.g. http://localhost:2026.

View File

@ -2112,6 +2112,8 @@ models:
- `DEER_FLOW_SKILLS_PATH` - Skills directory when `skills.path` is omitted
- `GATEWAY_ENABLE_DOCS` - Set to `false` to disable Swagger UI (`/docs`), ReDoc (`/redoc`), and OpenAPI schema (`/openapi.json`) endpoints (default: `true`)
Search, fetch and capture providers treat an example value that `.env.example` ships (such as `your-jina-api-key`, matched exactly) as unset, in `config.yaml` and in the environment alike: a keyless provider such as the Jina Reader uses its free tier, and a provider that needs a key reports it missing. Any other value is sent as written, so a self-hosted token you chose keeps working. Browserless reads `BROWSERLESS_TOKEN` only when its `token` field is omitted, so a blank or placeholder `token` is not replaced by the variable. `make doctor` applies the same rules and names the variable or field that still holds a placeholder, including `JINA_API_KEY` and `YDC_API_KEY` for the keyless providers.
## Backend dotenv selection
Set `DEER_FLOW_ENV_FILE` in the backend process environment **before startup**

View File

@ -17,6 +17,7 @@ import os
import httpx
from langchain.tools import tool
from deerflow.community.provider_credentials import resolve_api_key
from deerflow.community.search_time_range import BRAVE_FRESHNESS_BY_TIME_RANGE, SearchTimeRange
from deerflow.community.url_safety import safe_public_result_url as _safe_public_url
from deerflow.config import get_app_config
@ -35,14 +36,8 @@ _api_key_warned: set[str] = set()
def _get_api_key(tool_name: str = "web_search") -> str | None:
config = get_app_config().get_tool_config(tool_name)
if config is not None:
api_key = (config.model_extra or {}).get("api_key")
if isinstance(api_key, str) and api_key.strip():
return api_key.strip()
env_key = os.getenv("BRAVE_SEARCH_API_KEY")
if isinstance(env_key, str) and env_key.strip():
return env_key.strip()
return None
configured = resolve_api_key((config.model_extra or {}).get("api_key")) if config is not None else None
return configured or resolve_api_key(os.getenv("BRAVE_SEARCH_API_KEY"))
def _coerce_max_results(

View File

@ -13,6 +13,12 @@ trailing newline, and putting that raw value into a header makes the HTTP
transport refuse it with an error that quotes the whole header, key included
(``tests/test_provider_api_key_whitespace.py``).
``is_template_placeholder`` is the one definition of an ``.env.example`` example
value. ``scripts/doctor.py`` asks it for every web provider key, so the diagnosis
and the runtime agree on which keys count as configured. Model credentials do not
pass through here: their clients send whatever is set, so doctor's model check
flags likely sample values with its own, broader rule.
The marker and the message are module constants because the strings are part of
the contract: provider tests and operators' log grep patterns assert on them.
"""
@ -24,6 +30,66 @@ from pydantic import AnyHttpUrl, SecretStr
REDACTION_MARKER = "[REDACTED]"
URL_USERINFO_ERROR = "base_url must not contain username or password information"
# Every ``your-...`` example value ``.env.example`` ships, matched exactly. A rule
# that guesses at the shape would also drop a token an operator chose, such as a
# self-hosted Browserless ``TOKEN`` named ``your-review-team-token``.
# ``tests/test_provider_credentials_single_owner.py`` keeps this set equal to the
# template's values.
ENV_TEMPLATE_PLACEHOLDERS = frozenset(
{
"your-anthropic-auth-token",
"your-claude-code-oauth-token",
"your-deepseek-api-key",
"your-dingtalk-client-id",
"your-dingtalk-client-secret",
"your-discord-bot-token",
"your-e2b-api-key",
"your-exa-api-key",
"your-feishu-app-id",
"your-feishu-app-secret",
"your-fernet-key",
"your-firecrawl-api-key",
"your-gemini-api-key",
"your-github-token",
"your-image-generation-api-key",
"your-infoquest-api-key",
"your-jina-api-key",
"your-langsmith-api-key",
"your-langsmith-project",
"your-minimax-api-key",
"your-novita-api-key",
"your-openai-api-key",
"your-openviking-user-api-key",
"your-opper-api-key",
"your-provisioner-api-key",
"your-serper-api-key",
"your-serply-api-key",
"your-shared-internal-token",
"your-slack-app-token",
"your-slack-bot-token",
"your-sofya-api-key",
"your-specstream-api-key",
"your-stepfun-api-key",
"your-tavily-api-key",
"your-telegram-bot-token",
"your-unbrowse-api-key",
"your-vllm-api-key",
"your-volcengine-api-key",
"your-webz-api-key",
"your-wecom-bot-id",
"your-wecom-bot-secret",
}
)
def is_template_placeholder(value: str) -> bool:
"""Whether ``value`` is an ``.env.example`` example value rather than a credential.
Only the exact shipped value matches, ignoring surrounding whitespace: a key
that merely resembles one (``sk-your-jina-api-key``) is a key.
"""
return value.strip() in ENV_TEMPLATE_PLACEHOLDERS
def resolve_api_key(raw: str | SecretStr | None) -> str | None:
"""Return the configured or environment key with surrounding whitespace removed.
@ -31,10 +97,14 @@ def resolve_api_key(raw: str | SecretStr | None) -> str | None:
Blank or absent reads as ``None``: a missing key is not an error, it is an
unconfigured provider. Unwrapping happens only here, so no provider can end
up comparing (or printing) the repr of a ``SecretStr`` instead of its value.
An ``.env.example`` placeholder also reads as ``None``. ``make config`` copies
that template verbatim, and sending ``your-jina-api-key`` as a key made the
keyless Jina Reader fail every fetch with 401 instead of using its free tier.
"""
value = raw.get_secret_value() if isinstance(raw, SecretStr) else raw
if isinstance(value, str) and value.strip():
return value.strip()
return None if is_template_placeholder(value) else value.strip()
return None

View File

@ -21,6 +21,7 @@ from urllib.parse import urlparse
import httpx
from langchain.tools import tool
from deerflow.community.provider_credentials import resolve_api_key
from deerflow.community.search_max_results import coerce_max_results
from deerflow.community.search_time_range import SearchTimeRange
from deerflow.community.url_safety import safe_public_result_url as _safe_public_url
@ -45,13 +46,7 @@ def _get_api_key(tool_name: str, *, extras: Mapping[str, object] | None = None)
if extras is None:
config = get_app_config().get_tool_config(tool_name)
extras = config.model_extra if config is not None else {}
api_key = extras.get("api_key")
if isinstance(api_key, str) and api_key.strip():
return api_key.strip()
env_key = os.getenv("SERPER_API_KEY")
if isinstance(env_key, str) and env_key.strip():
return env_key.strip()
return None
return resolve_api_key(extras.get("api_key")) or resolve_api_key(os.getenv("SERPER_API_KEY"))
def _count_for_request(value: object) -> int:

View File

@ -17,6 +17,7 @@ import re
import httpx
from langchain.tools import tool
from deerflow.community.provider_credentials import resolve_api_key
from deerflow.community.search_max_results import coerce_max_results
from deerflow.community.url_safety import safe_public_result_url
from deerflow.config import get_app_config
@ -42,14 +43,8 @@ _api_key_warned: set[str] = set()
def _get_api_key(tool_name: str = "web_search") -> str | None:
config = get_app_config().get_tool_config(tool_name)
if config is not None:
api_key = (config.model_extra or {}).get("api_key")
if isinstance(api_key, str) and api_key.strip():
return api_key.strip()
env_key = os.getenv("SERPLY_API_KEY")
if isinstance(env_key, str) and env_key.strip():
return env_key.strip()
return None
configured = resolve_api_key((config.model_extra or {}).get("api_key")) if config is not None else None
return configured or resolve_api_key(os.getenv("SERPLY_API_KEY"))
def _count_for_request(value: object) -> int:

View File

@ -13,6 +13,7 @@ import os
import httpx
from langchain.tools import tool
from deerflow.community.provider_credentials import resolve_api_key
from deerflow.community.search_time_range import SearchTimeRange
from deerflow.community.url_safety import safe_public_result_url
from deerflow.config import get_app_config
@ -33,14 +34,8 @@ _api_key_warned: set[str] = set()
def _get_api_key(tool_name: str) -> str | None:
config = get_app_config().get_tool_config(tool_name)
if config is not None:
api_key = config.model_extra.get("api_key")
if isinstance(api_key, str) and api_key.strip():
return api_key.strip()
env_key = os.getenv("SOFYA_API_KEY")
if isinstance(env_key, str) and env_key.strip():
return env_key.strip()
return None
configured = resolve_api_key(config.model_extra.get("api_key")) if config is not None else None
return configured or resolve_api_key(os.getenv("SOFYA_API_KEY"))
def _coerce_max_results(value: object, default: int = 5, max_allowed: int = _SOFYA_MAX_RESULTS) -> int:

View File

@ -15,6 +15,7 @@ import os
import httpx
from langchain.tools import tool
from deerflow.community.provider_credentials import resolve_api_key
from deerflow.community.search_max_results import coerce_max_results
from deerflow.community.url_safety import safe_public_result_url
from deerflow.config import get_app_config
@ -34,14 +35,8 @@ _api_key_warned: set[str] = set()
def _get_api_key(tool_name: str) -> str | None:
config = get_app_config().get_tool_config(tool_name)
if config is not None:
api_key = config.model_extra.get("api_key")
if isinstance(api_key, str) and api_key.strip():
return api_key.strip()
env_key = os.getenv("SPECSTREAM_API_KEY")
if isinstance(env_key, str) and env_key.strip():
return env_key.strip()
return None
configured = resolve_api_key(config.model_extra.get("api_key")) if config is not None else None
return configured or resolve_api_key(os.getenv("SPECSTREAM_API_KEY"))
def _missing_key_message(tool_name: str) -> str:

View File

@ -9,6 +9,7 @@ from typing import Any
import httpx
from langchain.tools import tool
from deerflow.community.provider_credentials import resolve_api_key
from deerflow.community.url_safety import safe_public_result_url
from deerflow.config import get_app_config
@ -32,13 +33,7 @@ def _get_tool_extras(tool_name: str) -> Mapping[str, Any]:
def _get_api_key(tool_name: str = "web_search", *, extras: Mapping[str, Any] | None = None) -> str | None:
api_key = (extras if extras is not None else _get_tool_extras(tool_name)).get("api_key")
if isinstance(api_key, str) and api_key.strip():
return api_key.strip()
env_key = os.getenv(_API_KEY_ENV)
if isinstance(env_key, str) and env_key.strip():
return env_key.strip()
return None
return resolve_api_key(api_key) or resolve_api_key(os.getenv(_API_KEY_ENV))
def _coerce_max_results(value: object, *, default: int = _DEFAULT_MAX_RESULTS) -> int:

View File

@ -17,6 +17,7 @@ from urllib.parse import urlsplit, urlunsplit
import httpx
from langchain.tools import tool
from deerflow.community.provider_credentials import resolve_api_key
from deerflow.config import get_app_config
logger = logging.getLogger(__name__)
@ -33,14 +34,8 @@ _api_key_warned: set[str] = set()
def _get_api_key(tool_name: str) -> str | None:
config = get_app_config().get_tool_config(tool_name)
if config is not None:
api_key = config.model_extra.get("api_key")
if isinstance(api_key, str) and api_key.strip():
return api_key.strip()
env_key = os.getenv("UNBROWSE_API_KEY")
if isinstance(env_key, str) and env_key.strip():
return env_key.strip()
return None
configured = resolve_api_key(config.model_extra.get("api_key")) if config is not None else None
return configured or resolve_api_key(os.getenv("UNBROWSE_API_KEY"))
def _resolve_render(value: object) -> str:

View File

@ -10,6 +10,7 @@ from typing import Literal
import httpx
from langchain.tools import tool
from deerflow.community.provider_credentials import resolve_api_key
from deerflow.community.search_time_range import SearchTimeRange
from deerflow.community.url_safety import safe_public_result_url
from deerflow.config import get_app_config
@ -112,9 +113,7 @@ async def web_search_tool(
# Lazy configuration resolution can read files; keep it off the event loop.
options = await asyncio.to_thread(_options)
key = options.get("api_key")
key = key.strip() if isinstance(key, str) else ""
key = key or os.getenv("WEBZ_API_KEY", "").strip()
key = resolve_api_key(options.get("api_key")) or resolve_api_key(os.getenv("WEBZ_API_KEY"))
if not key:
return json.dumps({"error": "Webz API key is missing; configure api_key or WEBZ_API_KEY"})
count = _count(max_results if max_results is not None else options.get("max_results", 5))

View File

@ -49,6 +49,7 @@ from importlib.metadata import PackageNotFoundError, version
import httpx
from langchain.tools import tool
from deerflow.community.provider_credentials import resolve_api_key
from deerflow.community.search_max_results import coerce_max_results
from deerflow.community.url_safety import safe_public_result_url
from deerflow.config import get_app_config
@ -98,14 +99,8 @@ def _resolve_tool_settings(tool_name: str) -> tuple[str | None, object]:
if config is not None:
extra = config.model_extra or {}
max_results = extra.get("max_results")
candidate = extra.get("api_key")
if isinstance(candidate, str) and candidate.strip():
api_key = candidate.strip()
if api_key is None:
env_key = os.getenv("YDC_API_KEY")
if isinstance(env_key, str) and env_key.strip():
api_key = env_key.strip()
return api_key, max_results
api_key = resolve_api_key(extra.get("api_key"))
return api_key or resolve_api_key(os.getenv("YDC_API_KEY")), max_results
async def _resolve_user_agent() -> str:

View File

@ -99,3 +99,14 @@ def test_configure_rejects_unknown_flags_without_generating_files(config_checkou
assert not (config_checkout / "config.yaml").exists()
assert not (config_checkout / ".env").exists()
assert not (config_checkout / "frontend" / ".env").exists()
def test_env_template_leaves_every_placeholder_credential_commented():
"""``make config`` and ``make setup`` copy ``.env.example`` verbatim into ``.env``.
A live ``JINA_API_KEY=your-jina-api-key`` line made the default keyless
``web_fetch`` send that text as a key and fail every request with 401, so
every ``your-...`` example value must stay behind a ``#``.
"""
live = [line for line in (REPO_ROOT / ".env.example").read_text(encoding="utf-8").splitlines() if not line.lstrip().startswith("#") and "=" in line and line.split("=", 1)[1].strip().lower().startswith("your-")]
assert live == []

View File

@ -609,6 +609,88 @@ class TestCheckWebSearch:
assert result.fix is not None
assert "make setup" in result.fix
def test_infoquest_ignores_a_configured_api_key_like_its_client(self, tmp_path, monkeypatch):
"""InfoQuest reads only INFOQUEST_API_KEY, so an api_key field must not make doctor report it configured."""
from deerflow.community.infoquest.infoquest_client import InfoQuestClient
monkeypatch.setenv("MY_INFOQUEST_KEY", "iq-real-key")
monkeypatch.setenv("INFOQUEST_API_KEY", "your-infoquest-api-key")
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.infoquest.tools:web_search_tool\n api_key: $MY_INFOQUEST_KEY\n")
result = doctor.check_web_search(cfg)
assert result.status == "warn"
assert result.detail == "infoquest configured but INFOQUEST_API_KEY still holds the .env.example placeholder"
assert InfoQuestClient().api_key_set is False
@pytest.mark.parametrize("tool_name", ["web_search", "web_fetch"])
def test_groundroute_key_is_checked(self, tmp_path, monkeypatch, tool_name):
"""Every GroundRoute call needs a key; doctor used to report ok without one."""
monkeypatch.delenv("GROUNDROUTE_API_KEY", raising=False)
cfg = tmp_path / "config.yaml"
cfg.write_text(f"config_version: 5\ntools:\n - name: {tool_name}\n use: deerflow.community.groundroute.tools:{tool_name}_tool\n")
check = doctor.check_web_search if tool_name == "web_search" else doctor.check_web_fetch
result = check(cfg)
assert result.status == "warn"
assert result.detail == "groundroute configured but GROUNDROUTE_API_KEY not set"
monkeypatch.setenv("GROUNDROUTE_API_KEY", "gr-real-key")
assert check(cfg).status == "ok"
def test_youcom_names_an_ignored_placeholder_in_config(self, tmp_path, monkeypatch):
"""You.com drops it and uses the keyless endpoint, so the status stays ok but the field is named."""
from types import SimpleNamespace
from deerflow.community.youcom import tools as youcom
monkeypatch.delenv("YDC_API_KEY", raising=False)
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.youcom.tools:web_search_tool\n api_key: your-tavily-api-key\n")
result = doctor.check_web_search(cfg)
assert result.status == "ok"
assert result.detail.endswith("; api_key in config holds the .env.example placeholder and is ignored")
monkeypatch.setattr(youcom, "get_app_config", lambda: SimpleNamespace(get_tool_config=lambda name: SimpleNamespace(model_extra={"api_key": "your-tavily-api-key"})))
assert youcom._resolve_tool_settings("web_search")[0] is None
def test_tavily_with_env_template_placeholder_warns(self, tmp_path, monkeypatch):
"""The runtime drops an ``.env.example`` value, so doctor must not call it configured."""
monkeypatch.setenv("TAVILY_API_KEY", "your-tavily-api-key")
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.tavily.tools:web_search_tool\n api_key: $TAVILY_API_KEY\n")
result = doctor.check_web_search(cfg)
assert result.status == "warn"
assert result.detail == "tavily configured but TAVILY_API_KEY still holds the .env.example placeholder"
assert "TAVILY_API_KEY" in (result.fix or "")
def test_literal_placeholder_in_config_falls_back_to_the_env_key(self, tmp_path, monkeypatch):
"""``resolve_api_key`` drops the literal placeholder and the provider reads the environment instead."""
monkeypatch.setenv("SERPER_API_KEY", "real-serper-key")
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.serper.tools:web_search_tool\n api_key: your-serper-api-key\n")
result = doctor.check_web_search(cfg)
assert result.status == "ok"
assert result.detail == "serper (SERPER_API_KEY set)"
def test_literal_placeholder_in_config_is_named_when_no_env_key_backs_it(self, tmp_path, monkeypatch):
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.tavily.tools:web_search_tool\n api_key: your-tavily-api-key\n")
result = doctor.check_web_search(cfg)
assert result.status == "warn"
assert result.detail == "tavily configured but api_key in config still holds the .env.example placeholder"
def test_key_that_only_mentions_your_is_configured(self, tmp_path, monkeypatch):
"""Doctor and the runtime share one rule: ``sk-your-...`` is a key both of them use."""
monkeypatch.setenv("TAVILY_API_KEY", "sk-your-tavily-api-key")
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\ntools:\n - name: web_search\n use: deerflow.community.tavily.tools:web_search_tool\n")
result = doctor.check_web_search(cfg)
assert result.status == "ok"
def test_brave_with_key_ok(self, tmp_path, monkeypatch):
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "bsa-test")
cfg = tmp_path / "config.yaml"
@ -787,6 +869,30 @@ class TestCheckWebFetch:
assert result.status == "ok"
assert "Jina AI" in result.detail
@pytest.mark.usefixtures("delegated_backend_dns")
def test_browserless_fetch_explicit_token_wins_over_the_environment(self, tmp_path, monkeypatch):
"""``web_fetch`` builds the same client as ``web_capture``, so the configured token is authoritative there too."""
from deerflow.community.browserless.tools import _get_browserless_client
monkeypatch.setenv("BROWSERLESS_TOKEN", "browserless-real-token")
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\ntools:\n - name: web_fetch\n use: deerflow.community.browserless.tools:web_fetch_tool\n base_url: https://production-sfo.browserless.io\n token: your-github-token\n")
result = doctor.check_web_fetch(cfg)
assert result.status == "warn"
assert result.detail == "browserless configured but token in config still holds the .env.example placeholder"
assert _get_browserless_client("web_fetch", cfg={"base_url": "https://production-sfo.browserless.io", "token": "your-github-token"}).token == ""
def test_jina_names_the_ignored_template_placeholder(self, tmp_path, monkeypatch):
"""The runtime drops it and fetches anonymously; doctor says so instead of staying silent."""
monkeypatch.setenv("JINA_API_KEY", "your-jina-api-key")
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\ntools:\n - name: web_fetch\n use: deerflow.community.jina_ai.tools:web_fetch_tool\n")
result = doctor.check_web_fetch(cfg)
assert result.status == "ok"
assert result.detail == "Jina AI Reader (no key needed); JINA_API_KEY holds the .env.example placeholder and is ignored"
def test_firecrawl_without_key_warns(self, tmp_path, monkeypatch):
monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False)
cfg = tmp_path / "config.yaml"
@ -1033,6 +1139,64 @@ class TestCheckWebCapture:
assert result.status == "warn"
assert "BROWSERLESS_TOKEN" in (result.fix or "")
@pytest.mark.parametrize(
("token", "detail"),
[
("your-github-token", "browserless configured but token in config still holds the .env.example placeholder"),
('""', "browserless configured but token in config is empty, and BROWSERLESS_TOKEN is read only when token is omitted"),
],
ids=["placeholder", "blank"],
)
def test_browserless_explicit_token_wins_over_the_environment(self, tmp_path, monkeypatch, token, detail):
"""Browserless reads BROWSERLESS_TOKEN only when ``token`` is omitted, so a valid variable cannot rescue the field."""
from deerflow.community.browserless.tools import _get_browserless_client
monkeypatch.setenv("BROWSERLESS_TOKEN", "browserless-real-token")
cfg = tmp_path / "config.yaml"
cfg.write_text(f"config_version: 5\ntools:\n - name: web_capture\n use: deerflow.community.browserless.tools:web_capture_tool\n base_url: https://production-sfo.browserless.io\n token: {token}\n")
result = doctor.check_web_capture(cfg)
assert result.status == "warn"
assert result.detail == detail
assert "remove token to use BROWSERLESS_TOKEN" in (result.fix or "")
runtime_token = _get_browserless_client("web_capture", cfg={"base_url": "https://production-sfo.browserless.io", "token": "" if token == '""' else token}).token
assert runtime_token == "", "doctor must warn exactly when the client sends no token"
def test_browserless_token_reference_to_an_unset_variable_names_it_once(self, tmp_path, monkeypatch):
monkeypatch.delenv("BROWSERLESS_TOKEN", raising=False)
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\ntools:\n - name: web_capture\n use: deerflow.community.browserless.tools:web_capture_tool\n base_url: https://production-sfo.browserless.io\n token: $BROWSERLESS_TOKEN\n")
result = doctor.check_web_capture(cfg)
assert result.status == "warn"
assert result.detail == "browserless configured but BROWSERLESS_TOKEN not set"
def test_browserless_self_hosted_still_warns_about_a_placeholder_token(self, tmp_path, monkeypatch):
"""A self-hosted token is optional, but a placeholder shows the operator meant to set one."""
monkeypatch.setenv("BROWSERLESS_TOKEN", "your-github-token")
cfg = tmp_path / "config.yaml"
cfg.write_text(
"config_version: 5\ntools:\n - name: web_capture\n use: deerflow.community.browserless.tools:web_capture_tool\n base_url: http://localhost:3032\n network_isolation_confirmed: true\n token: $BROWSERLESS_TOKEN\n"
)
result = doctor.check_web_capture(cfg)
assert result.status == "warn"
assert result.detail == "browserless configured but BROWSERLESS_TOKEN still holds the .env.example placeholder"
def test_browserless_operator_chosen_token_is_a_token(self, tmp_path, monkeypatch):
"""Only shipped template values are placeholders; ``your-review-team-token`` is a token someone chose."""
monkeypatch.setenv("BROWSERLESS_TOKEN", "your-review-team-token")
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\ntools:\n - name: web_capture\n use: deerflow.community.browserless.tools:web_capture_tool\n base_url: https://production-sfo.browserless.io\n token: $BROWSERLESS_TOKEN\n")
result = doctor.check_web_capture(cfg)
assert result.status == "ok"
assert "BROWSERLESS_TOKEN set from config" in result.detail
# ---------------------------------------------------------------------------
# check_image_search

View File

@ -246,6 +246,21 @@ async def test_crawl_includes_api_key_when_set(jina_client, monkeypatch):
assert captured_headers["Authorization"] == "Bearer test-key-123"
@pytest.mark.anyio
async def test_crawl_uses_the_anonymous_tier_when_env_holds_the_template_placeholder(jina_client, monkeypatch):
"""A fresh ``make config`` leaves ``JINA_API_KEY=your-jina-api-key``; sending it makes every fetch a 401."""
captured_headers = {}
async def mock_post(self, url, **kwargs):
captured_headers.update(kwargs.get("headers", {}))
return httpx.Response(200, text="ok", request=httpx.Request("POST", url))
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
monkeypatch.setenv("JINA_API_KEY", "your-jina-api-key")
assert await jina_client.crawl("https://example.com") == "ok"
assert "Authorization" not in captured_headers
@pytest.mark.anyio
async def test_crawl_warns_once_when_api_key_missing(jina_client, monkeypatch, caplog):
"""Test that the missing API key warning is logged only once."""

View File

@ -28,7 +28,10 @@ These tests pin three things the extraction must not silently give back:
"""
import ast
import importlib
import re
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
@ -104,6 +107,52 @@ def test_resolve_api_key_normalizes_without_rejecting(raw: Any, expected: str |
assert owner.resolve_api_key(raw) == expected
@pytest.mark.parametrize(
"raw",
[
"your-jina-api-key",
" your-tavily-api-key\n",
SecretStr("your-infoquest-api-key"),
"your-github-token",
"your-feishu-app-secret",
],
)
def test_resolve_api_key_reads_an_env_template_placeholder_as_unset(raw: Any) -> None:
"""``make config`` copies ``.env.example`` verbatim, so its ``your-...`` values are not keys."""
assert owner.resolve_api_key(raw) is None
@pytest.mark.parametrize(
"raw",
[
"sk-your-jina-api-key",
"your-jina-api-key-2f9c",
"jina_your-key",
"yourkey",
"your-monkey",
"your-key",
"YOUR-EXA-API-KEY",
"your-review-team-token",
"your-browserless-token",
],
)
def test_resolve_api_key_keeps_values_the_template_does_not_ship(raw: str) -> None:
"""Self-hosted Browserless and LightRAG take a token the operator chose, whatever it looks like."""
assert owner.resolve_api_key(raw) == raw
assert not owner.is_template_placeholder(raw)
def test_placeholders_are_exactly_the_values_env_example_ships() -> None:
"""A new template value must be added here, or an install copied from it sends the placeholder again."""
template = (COMMUNITY_ROOT.parents[4] / ".env.example").read_text(encoding="utf-8")
shipped = set(re.findall(r"=(your-[a-z0-9-]+)", template))
assert shipped, "found no example values in .env.example"
assert shipped == owner.ENV_TEMPLATE_PLACEHOLDERS
for value in shipped:
assert owner.is_template_placeholder(value)
assert owner.resolve_api_key(value) is None
@pytest.mark.parametrize(
("value", "secret", "expected"),
[
@ -170,6 +219,40 @@ def test_folded_providers_reduced_their_adapters_to_delegation(provider: str) ->
assert not hasattr(faces["tools"], "_redact_api_key"), f"{provider} grew a private redaction copy again"
def _tool_config(extra: dict[str, Any]):
return SimpleNamespace(get_tool_config=lambda name: SimpleNamespace(model_extra=dict(extra)))
# Values ``.env.example`` ships: an install copied from the old template holds them.
TEMPLATE_CONFIG_VALUE = "your-tavily-api-key"
TEMPLATE_ENV_VALUE = "your-serper-api-key"
# provider module, environment variable, how the provider resolves its key
ENV_KEY_READERS = [
("brave", "BRAVE_SEARCH_API_KEY", lambda tools: tools._get_api_key("web_search")),
("serper", "SERPER_API_KEY", lambda tools: tools._get_api_key("web_search", extras={"api_key": TEMPLATE_CONFIG_VALUE})),
("serply", "SERPLY_API_KEY", lambda tools: tools._get_api_key("web_search")),
("sofya", "SOFYA_API_KEY", lambda tools: tools._get_api_key("web_search")),
("specstream", "SPECSTREAM_API_KEY", lambda tools: tools._get_api_key("web_search")),
("tencent_wsa", "TENCENTCLOUD_WSA_APIKEY", lambda tools: tools._get_api_key("web_search", extras={"api_key": TEMPLATE_CONFIG_VALUE})),
("unbrowse", "UNBROWSE_API_KEY", lambda tools: tools._get_api_key("web_fetch")),
("youcom", "YDC_API_KEY", lambda tools: tools._resolve_tool_settings("web_search")[0]),
]
@pytest.mark.parametrize(("provider", "env_name", "read_key"), ENV_KEY_READERS, ids=[case[0] for case in ENV_KEY_READERS])
def test_search_and_fetch_providers_drop_the_env_template_placeholder(provider: str, env_name: str, read_key, monkeypatch: pytest.MonkeyPatch) -> None:
"""An ``.env`` made from the old template still holds ``your-...-api-key``; it must read as unset, not be sent."""
tools = importlib.import_module(f"deerflow.community.{provider}.tools")
monkeypatch.setattr(tools, "get_app_config", lambda: _tool_config({"api_key": TEMPLATE_CONFIG_VALUE}))
monkeypatch.setenv(env_name, TEMPLATE_ENV_VALUE)
assert read_key(tools) is None
monkeypatch.setenv(env_name, f" {PROVIDER_KEY}\n")
assert read_key(tools) == PROVIDER_KEY
def test_lightrag_still_accepts_an_unconfigured_key() -> None:
"""LightRAG may run without authentication: no key is valid settings, not an error."""
settings = _settings("lightrag")
@ -242,7 +325,46 @@ def test_owner_holds_each_rule_exactly_once() -> None:
assert src.count('"[REDACTED]"') == 1
assert src.count("get_secret_value()") == 1
assert sum(1 for node in _code_nodes(tree) if isinstance(node, ast.Attribute) and node.attr == "password") == 1
assert {f.name for f in _functions(tree)} == {"resolve_api_key", "redact_secret", "reject_url_userinfo"}
assert {f.name for f in _functions(tree)} == {"is_template_placeholder", "resolve_api_key", "redact_secret", "reject_url_userinfo"}
_CREDENTIAL_ENV_NAME = re.compile(r"_(?:API_?KEY|TOKEN|SECRET)$")
def _credential_env_reads(tree: ast.Module) -> list[tuple[ast.Call, ast.AST | None]]:
"""``os.getenv``/``os.environ.get`` calls that read a credential variable, with their parent node."""
constants = {target.id: node.value.value for node in tree.body if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str) for target in node.targets if isinstance(target, ast.Name)}
parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)}
reads = []
for node in ast.walk(tree):
if not (isinstance(node, ast.Call) and node.args and isinstance(node.func, ast.Attribute)):
continue
func = node.func
is_getenv = func.attr == "getenv" and getattr(func.value, "id", None) == "os"
is_environ_get = func.attr == "get" and isinstance(func.value, ast.Attribute) and func.value.attr == "environ"
if not (is_getenv or is_environ_get):
continue
arg = node.args[0]
name = arg.value if isinstance(arg, ast.Constant) else constants.get(getattr(arg, "id", None))
if isinstance(name, str) and _CREDENTIAL_ENV_NAME.search(name):
reads.append((node, parents.get(node)))
return reads
def test_search_and_fetch_providers_read_environment_keys_through_the_owner() -> None:
"""A private strip-and-send copy keeps sending the ``.env.example`` placeholder an old ``.env`` still holds.
Sandbox providers are out of scope: they are not search or fetch providers,
and their keys configure the sandbox backend rather than a request header.
"""
offenders = []
for path in _provider_files():
if path.relative_to(COMMUNITY_ROOT).parts[0].endswith("sandbox"):
continue
for call, parent in _credential_env_reads(ast.parse(path.read_text(encoding="utf-8"), filename=str(path))):
if not (isinstance(parent, ast.Call) and getattr(parent.func, "id", None) == "resolve_api_key" and parent.args[:1] == [call]):
offenders.append(f"{path.relative_to(COMMUNITY_ROOT)}:{call.lineno}")
assert offenders == [], f"wrap the environment read in resolve_api_key: {offenders}"
def test_settings_models_reject_userinfo_through_the_shared_validator() -> None:

View File

@ -108,6 +108,20 @@ async def test_missing_key_never_makes_request(webz):
assert not requests
@pytest.mark.asyncio
async def test_env_template_placeholder_is_not_sent_as_a_key(webz, monkeypatch):
"""An ``.env`` made from the old template still holds ``WEBZ_API_KEY=your-webz-api-key``."""
module, options, requests, _, _ = webz
options.update(api_key="your-webz-api-key")
monkeypatch.setenv("WEBZ_API_KEY", "your-webz-api-key")
assert "WEBZ_API_KEY" in json.loads(await module.web_search_tool.ainvoke({"query": "news"}))["error"]
assert not requests
monkeypatch.setenv("WEBZ_API_KEY", "env-key")
await module.web_search_tool.ainvoke({"query": "news"})
assert requests[0].headers["authorization"] == "Bearer env-key"
@pytest.mark.asyncio
@pytest.mark.parametrize("arguments", [{"query": " "}, {"query": "x" * 751}, {"query": "word " * 101}, {"query": "news", "published_from": "yesterday"}, {"query": "news", "published_from": "2026-09-20", "published_to": "2026-09-01"}])
async def test_invalid_input_never_makes_request(webz, arguments):

View File

@ -277,10 +277,10 @@ tools:
- name: web_fetch
group: web
use: deerflow.community.jina_ai.tools:web_fetch_tool
api_key: $JINA_API_KEY # optional; anonymous usage has rate limits
```
Converts web pages to clean Markdown. Works without an API key at reduced rate
limits.
limits; set `JINA_API_KEY` in `.env` for higher limits. The tool reads only that
variable, not an `api_key` field.
</Tabs.Tab>
<Tabs.Tab>
```yaml

View File

@ -268,9 +268,8 @@ tools:
- name: web_fetch
group: web
use: deerflow.community.jina_ai.tools:web_fetch_tool
api_key: $JINA_API_KEY # 可选;匿名使用有速率限制
```
将网页转换为干净的 Markdown。无 API Key 也可使用,但有更严格的速率限制。
将网页转换为干净的 Markdown。无 API Key 也可使用,但有更严格的速率限制;在 `.env` 中设置 `JINA_API_KEY` 可提高限额。该工具只读取这个环境变量,不读取 `api_key` 字段。
</Tabs.Tab>
<Tabs.Tab>
```yaml

View File

@ -52,9 +52,10 @@ Cases marked for real Compose run only when a `docker` CLI is installed.
- **One dotenv selection per deployment.** `DEER_FLOW_COMPOSE_ENV_FILE` is
resolved once, and Compose interpolation, service env files, secret probes and
extras detection all use that path.
- **Do not seed production `.env` from the examples.** `.env.example` holds
placeholder API keys that the production Gateway would receive. Both compose
files mark the env files optional so a fresh checkout still starts and stops.
- **Do not seed production `.env` from the examples.** They hold development
defaults and sample keys, not values an operator chose for production. Both
compose files mark the env files optional so a fresh checkout still starts and
stops.
- **Support bundles export no secrets** and show an external runtime home as
`{DEER_FLOW_HOME}`.
- **Doctor does not echo model credentials.** Obvious sample key values fail the

View File

@ -701,6 +701,7 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
"specstream": "SPECSTREAM_API_KEY",
"tencent_wsa": "TENCENTCLOUD_WSA_APIKEY",
"webz": "WEBZ_API_KEY",
"groundroute": "GROUNDROUTE_API_KEY",
},
"web_fetch": {
"infoquest": "INFOQUEST_API_KEY",
@ -710,6 +711,8 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
"sofya": "SOFYA_API_KEY",
"specstream": "SPECSTREAM_API_KEY",
"unbrowse": "UNBROWSE_API_KEY",
"groundroute": "GROUNDROUTE_API_KEY",
"browserless": "BROWSERLESS_TOKEN",
},
"image_search": {
"brave": "BRAVE_SEARCH_API_KEY",
@ -721,27 +724,77 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
},
}
key_fields = {
"web_fetch": {
"browserless": "token",
},
"web_capture": {
"browserless": "token",
},
}
# Browserless treats a configured token field as authoritative, blank or
# not, and reads BROWSERLESS_TOKEN only when the field is omitted
# (community/browserless/tools.py). The other providers fall back to
# their variable whenever the configured value gives no key.
authoritative_key_providers = {"browserless"}
# InfoQuest reads only INFOQUEST_API_KEY; an api_key field in its tool
# config never reaches the client (community/infoquest/).
env_only_key_providers = {"infoquest"}
# Keyless providers that still send a key when one is set, and whether
# they read an api_key field before the variable (Jina reads only the
# variable).
free_provider_keys = {"youcom": ("YDC_API_KEY", True), "jina_ai": ("JINA_API_KEY", False)}
def _configured_key_detail(tool: dict, default_var: str, key_field: str = "api_key") -> tuple[Status, str] | None:
def _is_placeholder(value: object) -> bool:
# Ask the runtime's owner of the rule: providers drop an .env.example
# value, so doctor must not report it as a configured key. Without
# the harness, the config checks already fail with the fix.
try:
from deerflow.community.provider_credentials import is_template_placeholder
except ImportError:
return False
return isinstance(value, str) and is_template_placeholder(value)
def _real_key(value: object) -> bool:
return isinstance(value, str) and bool(value.strip()) and not _is_placeholder(value)
def _configured_key_detail(tool: dict, default_var: str, key_field: str = "api_key", *, authoritative: bool = False) -> tuple[Status, str] | None:
configured_key = tool.get(key_field)
if isinstance(configured_key, str) and configured_key.strip():
if _real_key(configured_key):
key = configured_key.strip()
if key.startswith("$"):
env_name = key[1:]
val = os.environ.get(env_name)
if val and val.strip():
return ("ok", f"{env_name} set from config")
# The referenced var is unset; fall through to the default
# env var below, which tools use as a runtime fallback.
else:
if not key.startswith("$"):
return ("warn", f"literal {key_field} set in config")
env_name = key[1:]
if _real_key(os.environ.get(env_name)):
return ("ok", f"{env_name} set from config")
if authoritative and key_field in tool:
return None
# A blank or placeholder configured value gives no key, and the tool
# falls back to its default variable. An unset $VAR reference never
# gets here at runtime: config loading fails, which
# check_config_loadable reports.
return ("ok", f"{default_var} set") if _real_key(os.environ.get(default_var)) else None
val = os.environ.get(default_var)
return ("ok", f"{default_var} set") if val and val.strip() else None
def _config_source(tool: dict, key_field: str) -> tuple[str, object]:
"""The configured key's name in a message, and its value after a ``$VAR`` reference."""
value = tool.get(key_field)
if isinstance(value, str) and value.strip().startswith("$"):
name = value.strip()[1:]
return name, os.environ.get(name)
return f"{key_field} in config", value
def _missing_key_reason(tool: dict, var: str, key_field: str, *, authoritative: bool) -> tuple[str, bool]:
"""Why no key reaches the provider, and whether a placeholder is the cause."""
name, value = _config_source(tool, key_field)
if authoritative and key_field in tool:
if _is_placeholder(value):
return f"{name} still holds the .env.example placeholder", True
if name == var:
return f"{var} not set", False
return f"{name} is empty, and {var} is read only when {key_field} is omitted", False
for source, candidate in ((name, value), (var, os.environ.get(var))):
if _is_placeholder(candidate):
return f"{source} still holds the .env.example placeholder", True
return f"{var} not set", False
def _browserless_self_hosted(tool: dict) -> bool:
base_url = str(tool.get("base_url") or "http://localhost:3032").lower()
@ -798,6 +851,13 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
use = tool.get("use", "")
for provider, detail in free_providers.get(tool_name, {}).items():
if provider in use:
var, reads_config = free_provider_keys.get(provider, (None, False))
sources = [_config_source(tool, "api_key")] if reads_config else []
if var:
sources.append((var, os.environ.get(var)))
ignored = next((name for name, value in sources if _is_placeholder(value)), None)
if ignored:
detail = f"{detail}; {ignored} holds the .env.example placeholder and is ignored"
return CheckResult(label, "ok", detail)
for tool in tool_entries:
@ -805,7 +865,10 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
for provider, var in key_providers.get(tool_name, {}).items():
if provider in use:
key_field = key_fields.get(tool_name, {}).get(provider, "api_key")
key_status = _configured_key_detail(tool, var, key_field=key_field)
authoritative = provider in authoritative_key_providers
# What the provider reads: an env-only provider never sees the field.
read = {name: value for name, value in tool.items() if name != key_field} if provider in env_only_key_providers else tool
key_status = _configured_key_detail(read, var, key_field=key_field, authoritative=authoritative)
if key_status:
status, detail = key_status
if status == "warn":
@ -816,13 +879,14 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
fix=f"Move the {key_field} to .env as {var}=<your-key> and reference it as ${var}",
)
return CheckResult(label, "ok", f"{provider} ({detail})")
if tool_name == "web_capture" and provider == "browserless" and _browserless_self_hosted(tool):
reason, placeholder = _missing_key_reason(read, var, key_field, authoritative=authoritative)
if not placeholder and provider == "browserless" and _browserless_self_hosted(tool):
return CheckResult(label, "ok", "browserless (self-hosted, token optional)")
return CheckResult(
label,
"warn",
f"{provider} configured but {var} not set",
fix=f"Add {var}=<your-key> to .env, or run 'make setup'",
f"{provider} configured but {reason}",
fix=f"Add {var}=<your-key> to .env, or run 'make setup'" if not (authoritative and key_field in read) else f"Set {key_field} to ${var} with the real token in .env, or remove {key_field} to use {var}",
)
for tool in tool_entries: