mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 14:00:35 +00:00
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:
parent
b0b3a8aa22
commit
0dc423605a
13
CHANGELOG.md
13
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user