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: