fix(browserless): resolve the token through the shared credential rule (#8043)

* fix(browserless): resolve the token through the shared credential rule

Browserless read BROWSERLESS_TOKEN and the configured token raw, so a value
from a file kept its trailing newline and became the auth query parameter;
every request then failed authentication. A whitespace-only value was sent as
a token instead of counting as unset. Use provider_credentials.resolve_api_key
like the other search and fetch providers, with the blank-config fallback to
the environment.

* fix(browserless): keep an explicit blank token opt-out

An explicitly configured token ("" or null) is authoritative: a self-hosted
capture endpoint uses it to opt out of authentication, and falling back to
BROWSERLESS_TOKEN sent the cloud credential to that endpoint. Only an omitted
token key falls back to the environment. Regression tests cover the client
token and the outgoing capture query.

* docs(changelog): record the browserless token normalization

Match the sibling #7981 entry in both locales; the fix closes out the
credential-rule family for the bundled search and fetch providers.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
yetuge 2026-11-10 05:59:16 +08:00 committed by GitHub
parent b0b3a8aa22
commit 0dc423605a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 90 additions and 5 deletions

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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 = "<binary>"
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."""