From 52b21d13ac6e674a19cb76013d097e73f108c994 Mon Sep 17 00:00:00 2001 From: Shxiao Date: Thu, 1 Oct 2026 09:38:08 +0900 Subject: [PATCH] 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 --- CHANGELOG.md | 9 ++++ CHANGELOG_zh.md | 6 +++ .../tool_output_budget_middleware.py | 14 ++++-- .../test_tool_output_budget_middleware.py | 48 +++++++++++++++++-- 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1f784dcd8..d88c2b11bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 6d5085ac19..6c44709828 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -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 diff --git a/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py index d4e550dbd2..8e0cc26b25 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/tool_output_budget_middleware.py @@ -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 diff --git a/backend/tests/test_tool_output_budget_middleware.py b/backend/tests/test_tool_output_budget_middleware.py index c763b4fb55..77aa9d9c21 100644 --- a/backend/tests/test_tool_output_budget_middleware.py +++ b/backend/tests/test_tool_output_budget_middleware.py @@ -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 && test "$(wc -c < )" -eq && 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,