diff --git a/CHANGELOG.md b/CHANGELOG.md index 07b5624390..f53e609069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,15 @@ This section accumulates work toward the **2.3.0** milestone `TAVILY_API_KEY`, `EXA_API_KEY` or `FIRECRAWL_API_KEY` now counts as missing instead of reaching the SDK as is. ([#7981]) +- **community:** Browserless is the last search/fetch provider that read its + token raw. A `BROWSERLESS_TOKEN` or configured `token` from a file kept its + trailing newline and became the auth query parameter, so every `web_fetch` + and `web_capture` request failed authentication while the key was correct. + Both now go through `provider_credentials.resolve_api_key`, and only an + omitted `token` key falls back to the environment: an explicit `""` or + `null` keeps opting a self-hosted capture endpoint out of authentication. + ([#8043]) + - **docker:** Images built from a checkout no longer contain its local configuration. `.dockerignore` did not exclude `config.yaml`, `config.yaml.bak`, `extensions_config.json` or `mcp_config.json`, so a @@ -16199,6 +16208,6 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#7981]: https://github.com/bytedance/deer-flow/pull/7981 [#7992]: https://github.com/bytedance/deer-flow/pull/7992 [#7993]: https://github.com/bytedance/deer-flow/pull/7993 -[#8006]: https://github.com/bytedance/deer-flow/pull/8006 - [#7998]: https://github.com/bytedance/deer-flow/issues/7998 +[#8006]: https://github.com/bytedance/deer-flow/pull/8006 +[#8043]: https://github.com/bytedance/deer-flow/pull/8043 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index d9f6e80596..ec9b83fd46 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -53,6 +53,12 @@ 把 `FIRECRAWL_API_KEY` 发送到 fastCRW 端点;`TAVILY_API_KEY`、`EXA_API_KEY` 或 `FIRECRAWL_API_KEY` 只含空白时按未设置处理,不再原样交给 SDK。([#7981]) +- **社区工具:** Browserless 是最后一个直接读取原始 token 的搜索/抓取提供方。从文件读取的 + `BROWSERLESS_TOKEN` 或配置 `token` 会保留尾部换行,并被原样用作鉴权查询参数,导致 + `web_fetch` 和 `web_capture` 在密钥本身正确时也全部鉴权失败。现在两者都经过 + `provider_credentials.resolve_api_key` 处理,且只有省略 `token` 键时才回退到环境变量: + 显式配置 `""` 或 `null` 仍可让自建 capture 端点跳过鉴权。([#8043]) + - **docker:** 从本地 checkout 构建的镜像不再包含本地配置。`.dockerignore` 此前没有排除 `config.yaml`、`config.yaml.bak`、`extensions_config.json` 和 `mcp_config.json`,因此配置放在旧位置 `backend/` 时,`COPY backend ./backend` @@ -12837,3 +12843,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#7992]: https://github.com/bytedance/deer-flow/pull/7992 [#7993]: https://github.com/bytedance/deer-flow/pull/7993 [#8006]: https://github.com/bytedance/deer-flow/pull/8006 +[#8043]: https://github.com/bytedance/deer-flow/pull/8043 diff --git a/backend/packages/harness/deerflow/community/browserless/tools.py b/backend/packages/harness/deerflow/community/browserless/tools.py index 6c9a258f04..3184395840 100644 --- a/backend/packages/harness/deerflow/community/browserless/tools.py +++ b/backend/packages/harness/deerflow/community/browserless/tools.py @@ -11,6 +11,7 @@ from langchain.tools import InjectedToolCallId, tool from langchain_core.messages import ToolMessage from langgraph.types import Command +from deerflow.community.provider_credentials import resolve_api_key from deerflow.community.url_safety import resolve_host_addresses as _resolve_host_addresses from deerflow.community.url_safety import validate_delegated_backend_url, validate_public_http_url from deerflow.config import get_app_config @@ -85,13 +86,18 @@ def _get_browserless_client(tool_name: str = "web_fetch", cfg: dict | None = _UN if cfg is _UNSET: cfg = _get_tool_config(tool_name) base_url = DEFAULT_BASE_URL - token = os.getenv("BROWSERLESS_TOKEN", "") + token = resolve_api_key(os.getenv("BROWSERLESS_TOKEN")) timeout_s = 30.0 if cfg is not None: base_url = cfg.get("base_url", base_url) - token = cfg.get("token", token) + if "token" in cfg: + # An explicitly configured value is authoritative, including "" and + # null: a self-hosted capture endpoint opts out of the cloud token, + # and falling back to BROWSERLESS_TOKEN would send that credential to + # it. Only an omitted key falls back to the environment. + token = resolve_api_key(cfg.get("token")) timeout_s = _resolve_timeout(cfg, timeout_s) - return BrowserlessClient(base_url=base_url, token=token, timeout_s=timeout_s) + return BrowserlessClient(base_url=base_url, token=token or "", timeout_s=timeout_s) def _as_bool(value: object, default: bool) -> bool: diff --git a/backend/tests/test_browserless_client.py b/backend/tests/test_browserless_client.py index 1b1e05797c..a402ca2719 100644 --- a/backend/tests/test_browserless_client.py +++ b/backend/tests/test_browserless_client.py @@ -442,6 +442,69 @@ class TestBrowserlessTools: """YAML `timeout_s: off` parses as False; 0.0 would time out every request.""" assert self._client_with_config({"timeout_s": False}).timeout_s == 30.0 + async def test_env_token_is_stripped(self): + """A token read from a file keeps its trailing newline. + + ``resolve_api_key`` is the shared credential rule for search and fetch + providers; without it the raw value becomes the auth query parameter and + every Browserless request fails authentication. + """ + with patch("deerflow.community.browserless.tools._get_tool_config") as mock_cfg: + mock_cfg.return_value = {"base_url": "https://production-sfo.browserless.io"} + with patch.dict("os.environ", {"BROWSERLESS_TOKEN": " env-token\r\n"}, clear=True): + client = tools._get_browserless_client("web_capture") + + assert client.token == "env-token" + + async def test_config_token_is_stripped(self): + assert self._client_with_config({"token": " cfg-token\n"}).token == "cfg-token" + + async def test_blank_config_token_does_not_fall_back_to_env(self): + """An explicit blank token opts the tool out of the environment token. + + A self-hosted capture endpoint sets ``token: ""`` so it receives no + authentication; falling back to BROWSERLESS_TOKEN would send the cloud + credential to it. + """ + with patch("deerflow.community.browserless.tools._get_tool_config") as mock_cfg: + mock_cfg.return_value = {"token": ""} + with patch.dict("os.environ", {"BROWSERLESS_TOKEN": "env-token"}, clear=True): + client = tools._get_browserless_client("web_capture") + + assert client.token == "" + + async def test_null_config_token_does_not_fall_back_to_env(self): + with patch("deerflow.community.browserless.tools._get_tool_config") as mock_cfg: + mock_cfg.return_value = {"token": None} + with patch.dict("os.environ", {"BROWSERLESS_TOKEN": "env-token"}, clear=True): + client = tools._get_browserless_client("web_capture") + + assert client.token == "" + + async def test_whitespace_only_token_sends_no_auth_parameter(self): + assert self._client_with_config({"token": "\r\n"}).token == "" + + async def test_capture_query_omits_token_when_config_opts_out(self): + """The outgoing capture request must not carry the environment token.""" + with patch("deerflow.community.browserless.tools._get_tool_config") as mock_cfg: + mock_cfg.return_value = {"token": ""} + with patch.dict("os.environ", {"BROWSERLESS_TOKEN": "cloud-token"}, clear=True): + client = tools._get_browserless_client("web_capture") + + with patch("deerflow.community.browserless.browserless_client.httpx.AsyncClient") as mock_cls: + mock_ctx = MagicMock() + mock_cls.return_value.__aenter__.return_value = mock_ctx + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.content = b"\x89PNG\r\n\x1a\nimage" + mock_resp.text = "" + mock_resp.headers = {"Content-Type": "image/png"} + mock_ctx.post = AsyncMock(return_value=mock_resp) + + await client.capture_screenshot("https://example.com") + + assert mock_ctx.post.call_args.kwargs["params"] is None + @patch("deerflow.community.browserless.tools._get_browserless_client") async def test_web_fetch_tool_success(self, mock_get_client): """web_fetch_tool successfully fetches and extracts content."""