mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 13:39:26 +00:00
fix(sandbox): stop E2B append from overwriting on read failure (#5261)
* fix(sandbox): stop E2B append from overwriting on read failure E2B has no native append, so write_file(append=True) read-modify-writes. Treat only FileNotFoundException/FileNotFoundError as an empty file; any other pre-read error must abort so a timeout cannot replace the original contents with just the new fragment. * fix(sandbox): distinguish E2B append pre-read refusal in logs A non-not-found pre-read error now logs as a refused overwrite instead of a write failure. Tests pin the successful read-modify-write path, including a bytes pre-image, so dropping `existing` cannot go green.
This commit is contained in:
parent
cbd6621d52
commit
e5d23943ce
@ -7,6 +7,7 @@ import shlex
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from e2b import FileNotFoundException
|
||||
from e2b_code_interpreter import Sandbox as E2BClientSandbox
|
||||
|
||||
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
|
||||
@ -357,16 +358,24 @@ class E2BSandbox(Sandbox):
|
||||
client = self._client
|
||||
if client is None:
|
||||
raise RuntimeError("sandbox client has been closed")
|
||||
try:
|
||||
if append:
|
||||
if append:
|
||||
# E2B has no append write. Read-modify-write must treat only
|
||||
# explicit not-found as empty; any other read failure would
|
||||
# otherwise overwrite the original file with just the tail.
|
||||
try:
|
||||
existing = client.files.read(resolved) or ""
|
||||
except (FileNotFoundException, FileNotFoundError):
|
||||
existing = ""
|
||||
try:
|
||||
existing = client.files.read(resolved) or ""
|
||||
if isinstance(existing, bytes):
|
||||
existing = existing.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
existing = ""
|
||||
content = (existing or "") + content
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Append pre-read failed for %s; refusing to overwrite",
|
||||
resolved,
|
||||
)
|
||||
raise
|
||||
if isinstance(existing, bytes):
|
||||
existing = existing.decode("utf-8", errors="replace")
|
||||
content = existing + content
|
||||
try:
|
||||
client.files.write(resolved, content)
|
||||
except Exception as e:
|
||||
logger.error("Failed to write file %s in e2b sandbox: %s", resolved, e)
|
||||
|
||||
@ -18,6 +18,7 @@ from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from e2b import FileNotFoundException, TimeoutException
|
||||
from pydantic import ValidationError
|
||||
|
||||
from deerflow.community.e2b_sandbox.capacity import (
|
||||
@ -5213,3 +5214,72 @@ def test_glob_preserves_trailing_space_in_filename():
|
||||
|
||||
assert matches == ["/home/user/notes.txt "]
|
||||
assert truncated is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing_exc", [FileNotFoundError, FileNotFoundException])
|
||||
def test_append_creates_file_when_file_does_not_exist(missing_exc):
|
||||
# Append has no native write mode, so a missing file must still create one
|
||||
# containing only the new fragment. Both the e2b SDK exception and the
|
||||
# stdlib one used by FakeFilesAPI / compatible clients count as not-found.
|
||||
class MissingFilesAPI(FakeFilesAPI):
|
||||
def read(self, path: str, *, format: str | None = None):
|
||||
self.read_calls.append((path, format))
|
||||
raise missing_exc(path)
|
||||
|
||||
files = MissingFilesAPI()
|
||||
sb = _make_sandbox(FakeClient(files=files))
|
||||
|
||||
sb.write_file("/mnt/user-data/outputs/report.txt", "conclusion", append=True)
|
||||
|
||||
assert files.write_calls == [("/home/user/outputs/report.txt", "conclusion")]
|
||||
|
||||
|
||||
def test_append_does_not_overwrite_when_read_fails(caplog):
|
||||
# If the pre-read fails for any reason other than not-found, we cannot
|
||||
# confirm the existing contents. Continuing would write only the tail and
|
||||
# destroy the original file. Fail closed: raise, and never call write.
|
||||
existing = b"important report body"
|
||||
|
||||
class TimeoutFilesAPI(FakeFilesAPI):
|
||||
def read(self, path: str, *, format: str | None = None):
|
||||
self.read_calls.append((path, format))
|
||||
raise TimeoutException("read timed out")
|
||||
|
||||
files = TimeoutFilesAPI(store={"/home/user/outputs/report.txt": existing})
|
||||
sb = _make_sandbox(FakeClient(files=files))
|
||||
|
||||
with caplog.at_level("ERROR"), pytest.raises(TimeoutException, match="read timed out"):
|
||||
sb.write_file("/mnt/user-data/outputs/report.txt", "conclusion", append=True)
|
||||
|
||||
assert files.write_calls == []
|
||||
assert files.store["/home/user/outputs/report.txt"] == existing
|
||||
assert "refusing to overwrite" in caplog.text
|
||||
assert "Failed to write file" not in caplog.text
|
||||
|
||||
|
||||
def test_append_accumulates_existing_content():
|
||||
# The rewrite exists to keep read-modify-write. If someone later drops
|
||||
# `existing` and writes only the tail, the not-found / fail-closed tests
|
||||
# would still pass.
|
||||
files = FakeFilesAPI(store={"/home/user/outputs/report.txt": b"hello"})
|
||||
sb = _make_sandbox(FakeClient(files=files))
|
||||
|
||||
sb.write_file("/mnt/user-data/outputs/report.txt", " world", append=True)
|
||||
|
||||
assert files.write_calls == [("/home/user/outputs/report.txt", "hello world")]
|
||||
|
||||
|
||||
def test_append_decodes_bytes_preimage():
|
||||
# FakeFilesAPI.read() returns str for valid utf-8. A bytes pre-image is
|
||||
# what hits the decode branch before concatenation.
|
||||
class BytesFilesAPI(FakeFilesAPI):
|
||||
def read(self, path: str, *, format: str | None = None):
|
||||
self.read_calls.append((path, format))
|
||||
return self.store[path]
|
||||
|
||||
files = BytesFilesAPI(store={"/home/user/outputs/report.txt": b"hello"})
|
||||
sb = _make_sandbox(FakeClient(files=files))
|
||||
|
||||
sb.write_file("/mnt/user-data/outputs/report.txt", " world", append=True)
|
||||
|
||||
assert files.write_calls == [("/home/user/outputs/report.txt", "hello world")]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user