diff --git a/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py b/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py index 75badf185..e8bee0613 100644 --- a/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py +++ b/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py @@ -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) diff --git a/backend/tests/test_e2b_sandbox_provider.py b/backend/tests/test_e2b_sandbox_provider.py index 06c4bc8d3..bb5ef817b 100644 --- a/backend/tests/test_e2b_sandbox_provider.py +++ b/backend/tests/test_e2b_sandbox_provider.py @@ -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")]