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.
This commit is contained in:
FanouZeng-TT 2026-09-25 10:22:38 +08:00 committed by GitHub
parent 3d5cc1efae
commit 55f5d6b629
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 27 additions and 6 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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"