diff --git a/.env.example b/.env.example
index ac5cba9b01..84c7681211 100644
--- a/.env.example
+++ b/.env.example
@@ -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.
diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md
index 2aa1151268..60554608ad 100644
--- a/backend/docs/CONFIGURATION.md
+++ b/backend/docs/CONFIGURATION.md
@@ -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**
diff --git a/backend/packages/harness/deerflow/community/brave/tools.py b/backend/packages/harness/deerflow/community/brave/tools.py
index 4376a020f5..0f7817d9ca 100644
--- a/backend/packages/harness/deerflow/community/brave/tools.py
+++ b/backend/packages/harness/deerflow/community/brave/tools.py
@@ -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(
diff --git a/backend/packages/harness/deerflow/community/provider_credentials.py b/backend/packages/harness/deerflow/community/provider_credentials.py
index 3c74e6da3b..888c0fbcfd 100644
--- a/backend/packages/harness/deerflow/community/provider_credentials.py
+++ b/backend/packages/harness/deerflow/community/provider_credentials.py
@@ -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
diff --git a/backend/packages/harness/deerflow/community/serper/tools.py b/backend/packages/harness/deerflow/community/serper/tools.py
index 68c42854e1..e563532038 100644
--- a/backend/packages/harness/deerflow/community/serper/tools.py
+++ b/backend/packages/harness/deerflow/community/serper/tools.py
@@ -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:
diff --git a/backend/packages/harness/deerflow/community/serply/tools.py b/backend/packages/harness/deerflow/community/serply/tools.py
index 83c615dad7..1c110c42dd 100644
--- a/backend/packages/harness/deerflow/community/serply/tools.py
+++ b/backend/packages/harness/deerflow/community/serply/tools.py
@@ -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:
diff --git a/backend/packages/harness/deerflow/community/sofya/tools.py b/backend/packages/harness/deerflow/community/sofya/tools.py
index e54b2b04f4..4e2f9061e2 100644
--- a/backend/packages/harness/deerflow/community/sofya/tools.py
+++ b/backend/packages/harness/deerflow/community/sofya/tools.py
@@ -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:
diff --git a/backend/packages/harness/deerflow/community/specstream/tools.py b/backend/packages/harness/deerflow/community/specstream/tools.py
index 7a3899782d..0ab28f8079 100644
--- a/backend/packages/harness/deerflow/community/specstream/tools.py
+++ b/backend/packages/harness/deerflow/community/specstream/tools.py
@@ -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:
diff --git a/backend/packages/harness/deerflow/community/tencent_wsa/tools.py b/backend/packages/harness/deerflow/community/tencent_wsa/tools.py
index 3bceff64aa..d6b2a611be 100644
--- a/backend/packages/harness/deerflow/community/tencent_wsa/tools.py
+++ b/backend/packages/harness/deerflow/community/tencent_wsa/tools.py
@@ -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:
diff --git a/backend/packages/harness/deerflow/community/unbrowse/tools.py b/backend/packages/harness/deerflow/community/unbrowse/tools.py
index 2d53e20e43..5563b4e63c 100644
--- a/backend/packages/harness/deerflow/community/unbrowse/tools.py
+++ b/backend/packages/harness/deerflow/community/unbrowse/tools.py
@@ -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:
diff --git a/backend/packages/harness/deerflow/community/webz/tools.py b/backend/packages/harness/deerflow/community/webz/tools.py
index 5ec44aa621..1352e4b6af 100644
--- a/backend/packages/harness/deerflow/community/webz/tools.py
+++ b/backend/packages/harness/deerflow/community/webz/tools.py
@@ -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))
diff --git a/backend/packages/harness/deerflow/community/youcom/tools.py b/backend/packages/harness/deerflow/community/youcom/tools.py
index c9bc3c55b6..a0600d083a 100644
--- a/backend/packages/harness/deerflow/community/youcom/tools.py
+++ b/backend/packages/harness/deerflow/community/youcom/tools.py
@@ -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:
diff --git a/backend/tests/test_configure_script.py b/backend/tests/test_configure_script.py
index 0551d136f2..571b15d6e2 100644
--- a/backend/tests/test_configure_script.py
+++ b/backend/tests/test_configure_script.py
@@ -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 == []
diff --git a/backend/tests/test_doctor.py b/backend/tests/test_doctor.py
index ba9c6737e9..71ceb90704 100644
--- a/backend/tests/test_doctor.py
+++ b/backend/tests/test_doctor.py
@@ -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
diff --git a/backend/tests/test_jina_client.py b/backend/tests/test_jina_client.py
index 61c7cfecb1..e6bf9ad73a 100644
--- a/backend/tests/test_jina_client.py
+++ b/backend/tests/test_jina_client.py
@@ -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."""
diff --git a/backend/tests/test_provider_credentials_single_owner.py b/backend/tests/test_provider_credentials_single_owner.py
index 823ae95e04..c508ee508a 100644
--- a/backend/tests/test_provider_credentials_single_owner.py
+++ b/backend/tests/test_provider_credentials_single_owner.py
@@ -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:
diff --git a/backend/tests/test_webz_tools.py b/backend/tests/test_webz_tools.py
index 9fcd6c6ba3..56c1ecd75a 100644
--- a/backend/tests/test_webz_tools.py
+++ b/backend/tests/test_webz_tools.py
@@ -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):
diff --git a/frontend/src/content/en/harness/tools.mdx b/frontend/src/content/en/harness/tools.mdx
index afc59f7732..fe0794bea1 100644
--- a/frontend/src/content/en/harness/tools.mdx
+++ b/frontend/src/content/en/harness/tools.mdx
@@ -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.
```yaml
diff --git a/frontend/src/content/zh/harness/tools.mdx b/frontend/src/content/zh/harness/tools.mdx
index 1fffc20399..6565ebc076 100644
--- a/frontend/src/content/zh/harness/tools.mdx
+++ b/frontend/src/content/zh/harness/tools.mdx
@@ -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` 字段。
```yaml
diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md
index 56537e9477..e007b04ee9 100644
--- a/scripts/AGENTS.md
+++ b/scripts/AGENTS.md
@@ -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
diff --git a/scripts/doctor.py b/scripts/doctor.py
index a51f564d25..e3fcdc7047 100644
--- a/scripts/doctor.py
+++ b/scripts/doctor.py
@@ -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}= 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}= to .env, or run 'make setup'",
+ f"{provider} configured but {reason}",
+ fix=f"Add {var}= 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: