mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
fix(middleware): validate exact byte size on sandbox tool output externalization (#6112)
* fix(middleware): validate exact byte size on sandbox tool output externalization * test(middleware): simulate partial write in sandbox externalization size test * Fix PR number in CHANGELOG.md Updated the changelog to reflect the correct issue number for a truncated file write. * Update CHANGELOG_zh.md for issue #6112 --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
ee3159a01d
commit
52b21d13ac
@ -327,6 +327,14 @@ This release closes that milestone with **181 merged pull requests**.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **middleware:** `_externalize_to_sandbox` now validates the full byte count
|
||||
of externalized tool outputs instead of only testing non-emptiness with
|
||||
`test -s`. When a remote sandbox write truncated the file part-way (e.g. disk
|
||||
full or pipe failure), the partial file previously passed validation, handing
|
||||
the model a truncated file path. It now verifies the file size exactly matches
|
||||
the payload, returning `None` and falling back to inline truncation if a write
|
||||
was truncated. ([#6112])
|
||||
|
||||
- **gateway:** A non-ASCII CSRF token, GitHub webhook signature, internal auth
|
||||
token, OIDC `state`, or provisioner `X-API-Key` is now rejected with the
|
||||
usual 403/401 instead of a 500. `hmac.compare_digest` raises `TypeError` for
|
||||
@ -6304,4 +6312,5 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
||||
[#6070]: https://github.com/bytedance/deer-flow/pull/6070
|
||||
[#6076]: https://github.com/bytedance/deer-flow/pull/6076
|
||||
[#6088]: https://github.com/bytedance/deer-flow/pull/6088
|
||||
[#6112]: https://github.com/bytedance/deer-flow/pull/6112
|
||||
|
||||
|
||||
@ -332,6 +332,11 @@
|
||||
|
||||
### 修复
|
||||
|
||||
- **中间件:** `_externalize_to_sandbox` 现在校验外部化工具输出的完整字节大小,
|
||||
而不再仅使用 `test -s` 检查文件是否非空。此前当远程沙箱写入中途被截断时(如磁盘满
|
||||
或管道故障),残缺的文件也会通过校验并把错误路径交付给模型;现在会严格核对字节数与负载一致,
|
||||
发生截断时返回 `None` 并回退到内联截断。([#6112])
|
||||
|
||||
- **Gateway:** 含非 ASCII 字符的 CSRF token、GitHub webhook 签名、内部认证 token、
|
||||
OIDC `state` 或 provisioner `X-API-Key` 现在按常规返回 403/401,而不是 500。
|
||||
`hmac.compare_digest` 遇到含非 ASCII 字符的 `str` 参数会抛出 `TypeError`,而
|
||||
@ -5344,3 +5349,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
||||
[#6070]: https://github.com/bytedance/deer-flow/pull/6070
|
||||
[#6076]: https://github.com/bytedance/deer-flow/pull/6076
|
||||
[#6088]: https://github.com/bytedance/deer-flow/pull/6088
|
||||
[#6112]: https://github.com/bytedance/deer-flow/pull/6112
|
||||
|
||||
@ -223,14 +223,18 @@ def _externalize_to_sandbox(
|
||||
# raising, so we cannot rely on exception propagation here.
|
||||
sandbox.execute_command(f"mkdir -p {shlex.quote(virtual_dir)}")
|
||||
sandbox.write_file(virtual_path, content)
|
||||
# Validate the file landed: execute_command may have silently failed
|
||||
# to create the directory, and write_file backends differ. Refuse to
|
||||
# hand the model an unreadable read_file path.
|
||||
check = sandbox.execute_command(f"test -s {shlex.quote(virtual_path)} && echo OK || echo MISSING")
|
||||
# Validate the file landed completely: execute_command may have silently
|
||||
# failed to create the directory, or write_file may have truncated the
|
||||
# content (disk full, backend pipe error). Refuse to hand the model an
|
||||
# incomplete or unreadable read_file path.
|
||||
expected_bytes = len(content.encode("utf-8"))
|
||||
quoted_path = shlex.quote(virtual_path)
|
||||
check = sandbox.execute_command(f'test -f {quoted_path} && test "$(wc -c < {quoted_path})" -eq {expected_bytes} && echo OK || echo MISSING')
|
||||
if not isinstance(check, str) or check.strip() != "OK":
|
||||
logger.warning(
|
||||
"Sandbox externalize validation failed: path=%s, check=%r",
|
||||
"Sandbox externalize validation failed: path=%s, expected_bytes=%d, check=%r",
|
||||
virtual_path,
|
||||
expected_bytes,
|
||||
check,
|
||||
)
|
||||
return None
|
||||
|
||||
@ -12,6 +12,7 @@ import contextlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
|
||||
@ -1270,9 +1271,10 @@ class TestConfigVersion:
|
||||
class _FakeSandbox:
|
||||
"""In-memory stand-in for a Sandbox. Records calls and supports failure injection."""
|
||||
|
||||
def __init__(self, *, write_ok: bool = True, check_result: str = "OK") -> None:
|
||||
def __init__(self, *, write_ok: bool = True, check_result: str | None = None) -> None:
|
||||
self.commands: list[str] = []
|
||||
self.writes: list[tuple[str, str]] = []
|
||||
self.files: dict[str, str] = {}
|
||||
self._write_ok = write_ok
|
||||
self._check_result = check_result
|
||||
|
||||
@ -1284,14 +1286,28 @@ class _FakeSandbox:
|
||||
) -> str:
|
||||
del env, timeout
|
||||
self.commands.append(command)
|
||||
if command.startswith("test -s"):
|
||||
if self._check_result is not None:
|
||||
return self._check_result
|
||||
# Simulate shell execution of:
|
||||
# test -f <path> && test "$(wc -c < <path>)" -eq <expected> && echo OK || echo MISSING
|
||||
match = re.search(r'test -f (\S+) && test "\$\(wc -c < \S+\)" -eq (\d+)', command)
|
||||
if match:
|
||||
path, expected_bytes = match.group(1), int(match.group(2))
|
||||
if path in self.files:
|
||||
actual_bytes = len(self.files[path].encode("utf-8"))
|
||||
if actual_bytes == expected_bytes:
|
||||
return "OK"
|
||||
return "MISSING"
|
||||
return ""
|
||||
|
||||
def write_file(self, path: str, content: str, append: bool = False) -> None:
|
||||
if not self._write_ok:
|
||||
raise RuntimeError("simulated write failure")
|
||||
self.writes.append((path, content))
|
||||
if append and path in self.files:
|
||||
self.files[path] += content
|
||||
else:
|
||||
self.files[path] = content
|
||||
|
||||
|
||||
class _FakeProvider:
|
||||
@ -1323,7 +1339,8 @@ class TestExternalizeToSandbox:
|
||||
assert result.startswith("/mnt/user-data/outputs/.tool-results/bash-")
|
||||
assert result.endswith(".log")
|
||||
assert any(c.startswith("mkdir -p ") for c in sb.commands)
|
||||
assert any(c.startswith("test -s ") for c in sb.commands)
|
||||
assert any("wc -c" in c for c in sb.commands)
|
||||
assert any("-eq 100" in c for c in sb.commands)
|
||||
assert sb.writes and sb.writes[0][0] == result
|
||||
assert sb.writes[0][1] == "x" * 100
|
||||
|
||||
@ -1355,6 +1372,31 @@ class TestExternalizeToSandbox:
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_byte_size_is_mismatched(self):
|
||||
"""A truncated write (fewer bytes than expected) fails validation and returns None."""
|
||||
from deerflow.agents.middlewares.tool_output_budget_middleware import (
|
||||
_externalize_to_sandbox,
|
||||
)
|
||||
|
||||
class _TruncatingSandbox(_FakeSandbox):
|
||||
def write_file(self, path: str, content: str, append: bool = False) -> None:
|
||||
# Simulate a truncated write (e.g. disk full / broken pipe) where only half lands
|
||||
super().write_file(path, content[: len(content) // 2], append=append)
|
||||
|
||||
sb = _TruncatingSandbox()
|
||||
result = _externalize_to_sandbox(
|
||||
"x" * 100,
|
||||
tool_name="bash",
|
||||
tool_call_id="tc-3-truncated",
|
||||
storage_subdir=".tool-results",
|
||||
sandbox=sb,
|
||||
)
|
||||
assert result is None
|
||||
assert any("-eq 100" in c for c in sb.commands)
|
||||
# Confirm the file was actually written with half size, triggering the real byte mismatch
|
||||
assert sb.writes and len(sb.writes[0][1]) == 50
|
||||
assert any(len(content) == 50 for content in sb.files.values())
|
||||
|
||||
def test_rejects_unsafe_storage_subdir(self):
|
||||
from deerflow.agents.middlewares.tool_output_budget_middleware import (
|
||||
_externalize_to_sandbox,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user