From 55f5d6b6291320b6ea05651d40deb03652015921 Mon Sep 17 00:00:00 2001 From: FanouZeng-TT <124567600+FanouZeng-TT@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:22:38 +0800 Subject: [PATCH] fix(sandbox): clamp negative read_file range bounds (#5655) * fix(sandbox): clamp a negative start_line in provider read_file LocalSandbox.read_file clamps the start line with max(start_line or 1, 1), but e2b, OpenSandbox and BoxLite used start_line or 1, so a negative value reached Python's negative-index slicing and read from the end of the file instead of the first line. Clamp all three the same way, and pin the boundary cases in their existing range tests: a past-EOF start still returns an empty string, a negative start reads from line 1. Surfaced by the review of #5616 (e2b has the same gap, consistency nit). * fix(sandbox): clamp negative end_line across providers Complete the range-bound consistency fix for e2b, OpenSandbox and BoxLite: clamp a negative end_line to zero like LocalSandbox instead of letting Python's negative-index slicing silently drop trailing lines. Add the symmetric boundary assertion to each provider's existing range test. --- backend/packages/harness/deerflow/community/boxlite/box.py | 6 ++++-- .../harness/deerflow/community/e2b_sandbox/e2b_sandbox.py | 6 ++++-- .../harness/deerflow/community/opensandbox/sandbox.py | 6 ++++-- backend/tests/test_boxlite_provider.py | 5 +++++ backend/tests/test_e2b_sandbox_provider.py | 5 +++++ backend/tests/test_opensandbox_provider.py | 5 +++++ 6 files changed, 27 insertions(+), 6 deletions(-) diff --git a/backend/packages/harness/deerflow/community/boxlite/box.py b/backend/packages/harness/deerflow/community/boxlite/box.py index 1850c8aa72..240e37fb5f 100644 --- a/backend/packages/harness/deerflow/community/boxlite/box.py +++ b/backend/packages/harness/deerflow/community/boxlite/box.py @@ -231,8 +231,10 @@ class BoxliteBox(Sandbox): if start_line is None and end_line is None: return content lines = content.splitlines() - start = start_line or 1 - end = end_line if end_line is not None else len(lines) + # Clamp like LocalSandbox.read_file: a negative start would otherwise + # wrap around through Python's negative-index slicing. + start = max(start_line or 1, 1) + end = max(end_line, 0) if end_line is not None else len(lines) return "\n".join(lines[start - 1 : end]) def write_file(self, path: str, content: str, append: bool = False) -> None: 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 429d641666..980fb4738c 100644 --- a/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py +++ b/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py @@ -234,8 +234,10 @@ class E2BSandbox(Sandbox): return content.decode("utf-8", errors="replace") if isinstance(content, bytes) else content or "" text = content.decode("utf-8", errors="replace") if isinstance(content, bytes) else content or "" lines = text.splitlines() - start = start_line or 1 - end = end_line if end_line is not None else len(lines) + # Clamp like LocalSandbox.read_file: a negative start would otherwise + # wrap around through Python's negative-index slicing. + start = max(start_line or 1, 1) + end = max(end_line, 0) if end_line is not None else len(lines) content = "\n".join(lines[start - 1 : end]) return content except Exception as e: diff --git a/backend/packages/harness/deerflow/community/opensandbox/sandbox.py b/backend/packages/harness/deerflow/community/opensandbox/sandbox.py index 643a0722c6..64377f6c48 100644 --- a/backend/packages/harness/deerflow/community/opensandbox/sandbox.py +++ b/backend/packages/harness/deerflow/community/opensandbox/sandbox.py @@ -272,8 +272,10 @@ class OpenSandboxSandbox(Sandbox): if start_line is None and end_line is None: return content or "" lines = (content or "").splitlines() - start = start_line or 1 - end = end_line if end_line is not None else len(lines) + # Clamp like LocalSandbox.read_file: a negative start would otherwise + # wrap around through Python's negative-index slicing. + start = max(start_line or 1, 1) + end = max(end_line, 0) if end_line is not None else len(lines) return "\n".join(lines[start - 1 : end]) def write_file(self, path: str, content: str, append: bool = False) -> None: diff --git a/backend/tests/test_boxlite_provider.py b/backend/tests/test_boxlite_provider.py index 02803dd281..736d6d45e6 100644 --- a/backend/tests/test_boxlite_provider.py +++ b/backend/tests/test_boxlite_provider.py @@ -204,6 +204,11 @@ def test_read_file_supports_optional_line_ranges(monkeypatch: pytest.MonkeyPatch assert box.read_file("/mnt/user-data/workspace/range.txt", start_line=2, end_line=4) == "line 2\nline 3\nline 4" assert box.read_file("/mnt/user-data/workspace/range.txt", start_line=4) == "line 4\nline 5" assert box.read_file("/mnt/user-data/workspace/range.txt", end_line=2) == "line 1\nline 2" + # A start past EOF comes back empty rather than raising, and a negative + # start reads from the first line instead of wrapping around. + assert box.read_file("/mnt/user-data/workspace/range.txt", start_line=99) == "" + assert box.read_file("/mnt/user-data/workspace/range.txt", start_line=-1) == "line 1\nline 2\nline 3\nline 4\nline 5" + assert box.read_file("/mnt/user-data/workspace/range.txt", end_line=-1) == "" assert all(call == ("cat", "--", "/mnt/user-data/workspace/range.txt") for call in calls) diff --git a/backend/tests/test_e2b_sandbox_provider.py b/backend/tests/test_e2b_sandbox_provider.py index f60d9e073d..829d32d911 100644 --- a/backend/tests/test_e2b_sandbox_provider.py +++ b/backend/tests/test_e2b_sandbox_provider.py @@ -3550,6 +3550,11 @@ def test_read_file_supports_bounded_ranges(): assert sb.read_file("/mnt/user-data/workspace/range.txt", start_line=2, end_line=4) == "line 2\nline 3\nline 4" assert sb.read_file("/mnt/user-data/workspace/range.txt", start_line=4) == "line 4\nline 5" assert sb.read_file("/mnt/user-data/workspace/range.txt", end_line=2) == "line 1\nline 2" + # A start past EOF comes back empty rather than raising, and a negative + # start reads from the first line instead of wrapping around. + assert sb.read_file("/mnt/user-data/workspace/range.txt", start_line=99) == "" + assert sb.read_file("/mnt/user-data/workspace/range.txt", start_line=-1) == "line 1\nline 2\nline 3\nline 4\nline 5" + assert sb.read_file("/mnt/user-data/workspace/range.txt", end_line=-1) == "" resolved_path = "/home/user/workspace/range.txt" assert all(path == resolved_path for path, _fmt in files.read_calls), files.read_calls diff --git a/backend/tests/test_opensandbox_provider.py b/backend/tests/test_opensandbox_provider.py index a1e32c54d9..793004ec01 100644 --- a/backend/tests/test_opensandbox_provider.py +++ b/backend/tests/test_opensandbox_provider.py @@ -599,6 +599,11 @@ def test_text_binary_append_and_line_ranges() -> None: assert box.read_file(path, 2, 3) == "two\nthree" box.write_file(path, "\nfour", append=True) assert box.read_file(path) == "one\ntwo\nthree\nfour" + # A start past EOF comes back empty rather than raising, and a negative + # start reads from the first line instead of wrapping around. + assert box.read_file(path, 99) == "" + assert box.read_file(path, -1) == "one\ntwo\nthree\nfour" + assert box.read_file(path, end_line=-1) == "" binary_path = "/mnt/user-data/outputs/blob.bin" box.update_file(binary_path, b"\x00\xffpayload") assert box.download_file(binary_path) == b"\x00\xffpayload"