mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* fix(sandbox): enforce image byte limits during downloads * docs: keep image download guidance within inherited budget * docs(sandbox): specify download overflow error contract * refactor(sandbox): consolidate download budgets and explain AIO locking Signed-off-by: ZJPex <3258236335@qq.com> --------- Signed-off-by: ZJPex <3258236335@qq.com>
238 lines
9.2 KiB
Python
238 lines
9.2 KiB
Python
"""Caller limits must reach the provider before image bytes are buffered."""
|
|
|
|
import errno
|
|
import io
|
|
import os
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import threading
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
|
|
from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox
|
|
from deerflow.community.boxlite.box import BoxliteBox
|
|
from deerflow.community.e2b_sandbox.e2b_sandbox import E2BSandbox
|
|
from deerflow.community.opensandbox.sandbox import OpenSandboxSandbox
|
|
from deerflow.community.tenki.sandbox import TenkiSandbox
|
|
from deerflow.sandbox.local.local_sandbox import LocalSandbox
|
|
from deerflow.tools.builtins.view_image_tool import _MAX_IMAGE_BYTES, _view_image_authorized
|
|
|
|
PATH = "/mnt/user-data/outputs/image.png"
|
|
|
|
|
|
class Stream:
|
|
def __init__(self, chunks, error=None):
|
|
self.chunks = iter(chunks)
|
|
self.error = error
|
|
self.consumed = 0
|
|
self.closed = False
|
|
|
|
def __iter__(self):
|
|
return self
|
|
|
|
def __next__(self):
|
|
chunk = next(self.chunks)
|
|
self.consumed += len(chunk)
|
|
if self.error:
|
|
raise self.error
|
|
return chunk
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
def provider(kind, stream):
|
|
if kind == "e2b":
|
|
return E2BSandbox("remote", SimpleNamespace(files=SimpleNamespace(read=Mock(return_value=stream))))
|
|
if kind == "aio":
|
|
box = object.__new__(AioSandbox)
|
|
box._lock = threading.Lock()
|
|
box._client = SimpleNamespace(file=SimpleNamespace(download_file=Mock(return_value=stream)))
|
|
return box
|
|
if kind == "opensandbox":
|
|
box = object.__new__(OpenSandboxSandbox)
|
|
files = SimpleNamespace(read_bytes_stream=Mock(return_value=stream))
|
|
box._file_op = lambda operation: operation(files)
|
|
return box
|
|
box = object.__new__(TenkiSandbox)
|
|
box._lock = threading.Lock()
|
|
box._closed = False
|
|
box._sandbox = SimpleNamespace(fs=SimpleNamespace(read_stream=Mock(return_value=stream)))
|
|
box._resolve_path = lambda path: path
|
|
box._note_failure = Mock()
|
|
return box
|
|
|
|
|
|
@pytest.mark.parametrize("kind", ["aio", "e2b", "opensandbox", "tenki"])
|
|
@pytest.mark.parametrize("size", [0, 4, 5, 9])
|
|
def test_provider_limit_stops_stream_and_closes(kind, size):
|
|
stream = Stream([b"x"] * size)
|
|
box = provider(kind, stream)
|
|
if size > 4:
|
|
with pytest.raises(OSError) as error:
|
|
box.download_file(PATH, max_bytes=4)
|
|
assert error.value.errno == errno.EFBIG
|
|
else:
|
|
assert box.download_file(PATH, max_bytes=4) == b"x" * size
|
|
assert stream.consumed == min(size, 5)
|
|
assert stream.closed
|
|
|
|
|
|
@pytest.mark.parametrize("kind", ["aio", "e2b", "opensandbox", "tenki"])
|
|
@pytest.mark.parametrize("error", [OSError("transport"), KeyboardInterrupt()])
|
|
def test_provider_closes_stream_on_error_or_interruption(kind, error):
|
|
stream = Stream([b"x"], error)
|
|
with pytest.raises(type(error)):
|
|
provider(kind, stream).download_file(PATH, max_bytes=4)
|
|
assert stream.closed
|
|
|
|
|
|
def test_tool_rejects_21_mib_without_consuming_the_complete_stream(tmp_path, monkeypatch):
|
|
stream = Stream([b"x" * 65536] * (21 * 16))
|
|
box = provider("aio", stream)
|
|
thread_data = {f"{name}_path": str(tmp_path / name) for name in ("workspace", "uploads", "outputs")}
|
|
monkeypatch.setattr("deerflow.sandbox.sandbox_provider.get_sandbox_provider", lambda: SimpleNamespace(get=lambda _: box))
|
|
runtime = SimpleNamespace(state={"thread_data": thread_data, "sandbox": {"sandbox_id": "remote"}}, context={"thread_id": "thread"}, config={})
|
|
result = _view_image_authorized(runtime, PATH, "call")
|
|
assert "Error" in result.update["messages"][0].content
|
|
assert "viewed_images" not in result.update
|
|
assert stream.consumed <= _MAX_IMAGE_BYTES + 65536
|
|
assert stream.closed
|
|
|
|
|
|
def test_e2b_bounded_read_never_retries_buffered_sdk():
|
|
read = Mock(side_effect=TypeError("stream unavailable"))
|
|
box = E2BSandbox("remote", SimpleNamespace(files=SimpleNamespace(read=read)))
|
|
with pytest.raises(OSError):
|
|
box.download_file(PATH, max_bytes=4)
|
|
assert read.call_count == 1
|
|
assert read.call_args.kwargs == {"format": "stream"}
|
|
|
|
|
|
def test_local_growth_after_stat_is_still_bounded(monkeypatch):
|
|
box = object.__new__(LocalSandbox)
|
|
box._resolve_path = lambda path: path
|
|
stream = io.BytesIO(b"x" * 100)
|
|
requests = []
|
|
|
|
class Reader(io.BytesIO):
|
|
def read(self, size=-1):
|
|
requests.append(size)
|
|
return stream.read(size)
|
|
|
|
monkeypatch.setattr("os.path.getsize", lambda _: 4)
|
|
monkeypatch.setattr("builtins.open", lambda *args, **kwargs: Reader())
|
|
with pytest.raises(OSError) as error:
|
|
box.download_file(PATH, max_bytes=4)
|
|
assert error.value.errno == errno.EFBIG
|
|
assert requests == [5]
|
|
assert stream.tell() == 5
|
|
|
|
|
|
@pytest.mark.parametrize("kind", ["aio", "e2b", "opensandbox", "tenki", "local", "boxlite"])
|
|
@pytest.mark.parametrize("limit", [-1, True, 1.5])
|
|
def test_invalid_limit_rejected_before_io(kind, limit):
|
|
box = object.__new__({"local": LocalSandbox, "boxlite": BoxliteBox}[kind]) if kind in {"local", "boxlite"} else provider(kind, Stream([]))
|
|
with pytest.raises(ValueError):
|
|
box.download_file(PATH, max_bytes=limit)
|
|
|
|
|
|
@pytest.mark.parametrize("size", [0, 4, 5, 100])
|
|
def test_boxlite_bounds_actual_shell_read(tmp_path, size):
|
|
git = shutil.which("git")
|
|
shell = str(Path(git).parent.parent / "bin" / "bash.exe") if os.name == "nt" and git else shutil.which("sh")
|
|
if not shell or not Path(shell).exists():
|
|
pytest.skip("POSIX shell is required for the BoxLite transport probe")
|
|
image = tmp_path / "image ; quoted.png"
|
|
image.write_bytes(b"x" * size)
|
|
box = object.__new__(BoxliteBox)
|
|
outputs = []
|
|
|
|
def run(script):
|
|
script = script.replace(shlex.quote(PATH), shlex.quote(image.as_posix()))
|
|
if os.name == "nt":
|
|
script = "export PATH=/usr/bin:/bin\n" + script
|
|
result = subprocess.run([shell, "-c", script], capture_output=True, text=True, timeout=10)
|
|
outputs.append(result.stdout)
|
|
return SimpleNamespace(exit_code=result.returncode, stdout=result.stdout, stderr=result.stderr)
|
|
|
|
box._sh = run
|
|
if size > 4:
|
|
with pytest.raises(OSError) as error:
|
|
box.download_file(PATH, max_bytes=4)
|
|
assert error.value.errno == errno.EFBIG
|
|
else:
|
|
assert box.download_file(PATH, max_bytes=4) == b"x" * size
|
|
assert len(outputs[0]) < 80
|
|
image.unlink()
|
|
with pytest.raises(OSError):
|
|
box.download_file(PATH, max_bytes=4)
|
|
|
|
|
|
@pytest.mark.parametrize("source", ["remote", "old-remote"])
|
|
def test_model_injection_limits_remote_download(tmp_path, monkeypatch, source):
|
|
stream = Stream([b"x" * 65536] * (21 * 16))
|
|
box = provider("aio", stream)
|
|
monkeypatch.setattr("deerflow.sandbox.sandbox_provider.get_sandbox_provider", lambda: SimpleNamespace(get=lambda _: box))
|
|
result = ViewImageMiddleware._read_image_as_data_url({"sandbox": {"sandbox_id": "remote"}}, PATH, "", "image/png", 1, "digest", source)
|
|
assert result is None
|
|
assert stream.consumed <= _MAX_IMAGE_BYTES + 65536
|
|
assert stream.closed
|
|
|
|
|
|
def test_custom_provider_without_limit_fails_closed(tmp_path, monkeypatch):
|
|
calls = []
|
|
|
|
class Legacy:
|
|
def download_file(self, path):
|
|
calls.append(path)
|
|
return b"legacy"
|
|
|
|
monkeypatch.setattr("deerflow.sandbox.sandbox_provider.get_sandbox_provider", lambda: SimpleNamespace(get=lambda _: Legacy()))
|
|
runtime = SimpleNamespace(
|
|
state={"thread_data": {f"{name}_path": str(tmp_path / name) for name in ("workspace", "uploads", "outputs")}, "sandbox": {"sandbox_id": "remote"}},
|
|
context={"thread_id": "thread"},
|
|
config={},
|
|
)
|
|
result = _view_image_authorized(runtime, PATH, "call")
|
|
assert "Error" in result.update["messages"][0].content
|
|
assert calls == []
|
|
|
|
|
|
@pytest.mark.parametrize("kind", ["aio", "e2b", "opensandbox", "tenki"])
|
|
@pytest.mark.parametrize("max_bytes", [None, 100 * 1024 * 1024 + 1])
|
|
def test_caller_cannot_raise_default_provider_ceiling(kind, max_bytes):
|
|
from deerflow.sandbox.download import DEFAULT_DOWNLOAD_LIMIT
|
|
|
|
class OversizedChunk:
|
|
def __len__(self):
|
|
return DEFAULT_DOWNLOAD_LIMIT + 1
|
|
|
|
stream = Stream([OversizedChunk(), b"unread"])
|
|
with pytest.raises(OSError) as error:
|
|
provider(kind, stream).download_file(PATH, max_bytes=max_bytes)
|
|
assert error.value.errno == errno.EFBIG
|
|
assert str(DEFAULT_DOWNLOAD_LIMIT) in str(error.value)
|
|
assert stream.consumed == DEFAULT_DOWNLOAD_LIMIT + 1
|
|
assert stream.closed
|
|
|
|
|
|
@pytest.mark.parametrize("max_bytes", [None, 100 * 1024 * 1024 + 1])
|
|
def test_local_caller_cannot_raise_default_provider_ceiling(monkeypatch, max_bytes):
|
|
from deerflow.sandbox.download import DEFAULT_DOWNLOAD_LIMIT
|
|
|
|
box = object.__new__(LocalSandbox)
|
|
box._resolve_path = lambda path: path
|
|
monkeypatch.setattr("os.path.getsize", lambda _: DEFAULT_DOWNLOAD_LIMIT + 1)
|
|
read = Mock()
|
|
monkeypatch.setattr("builtins.open", read)
|
|
with pytest.raises(OSError) as error:
|
|
box.download_file(PATH, max_bytes=max_bytes)
|
|
assert error.value.errno == errno.EFBIG
|
|
read.assert_not_called()
|