From d50995206a8d0ed52c6fff254fb2ac02238fbc36 Mon Sep 17 00:00:00 2001 From: Jeremy Schoemaker Date: Sun, 23 Aug 2026 20:43:52 -0500 Subject: [PATCH] fix(sandbox): stop stripping filenames when parsing find output in remote providers The list_dir and glob parsers in the e2b, OpenSandbox, AIO, Tenki, and BoxLite providers called .strip() on every line of find output. A filename that legitimately ends (or begins) in whitespace was corrupted, so the listed path never resolved on any follow-up file API call, and the remote providers diverged from LocalSandbox, which preserves such names via pathlib. splitlines() already removes the line terminators, so filter empty lines only and keep each entry verbatim. Same class of bug as the e2b _sync_outputs_to_host fix (#4861), applied to the search parsers. Adds a trailing-space regression test per provider at the seam each suite already uses. --- .../community/aio_sandbox/aio_sandbox.py | 5 ++++- .../harness/deerflow/community/boxlite/box.py | 6 +++-- .../community/e2b_sandbox/e2b_sandbox.py | 7 ++++-- .../deerflow/community/opensandbox/sandbox.py | 6 +++-- .../deerflow/community/tenki/sandbox.py | 6 +++-- backend/tests/test_aio_sandbox.py | 8 +++++++ backend/tests/test_boxlite_provider.py | 16 ++++++++++++++ backend/tests/test_e2b_sandbox_provider.py | 22 +++++++++++++++++++ backend/tests/test_opensandbox_provider.py | 14 ++++++++++++ backend/tests/test_tenki_provider.py | 13 +++++++++++ 10 files changed, 94 insertions(+), 9 deletions(-) diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py index 3a1a8ed07d9..12168d3970e 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py @@ -365,7 +365,10 @@ def list_dir(self, path: str, max_depth: int = 2) -> list[str]: result = self._client.shell.exec_command(command=f"find {shlex.quote(path)} -maxdepth {max_depth} -type f -o -type d 2>/dev/null | head -500", no_change_timeout=self._DEFAULT_NO_CHANGE_TIMEOUT) output = result.data.output if result.data else "" if output: - return [line.strip() for line in output.strip().split("\n") if line.strip()] + # splitlines() already removed the terminators; do NOT strip + # entries — a filename that legitimately ends in whitespace + # would be corrupted and never resolve again. + return [line for line in output.splitlines() if line] return [] except Exception as e: logger.error(f"Failed to list directory in sandbox: {e}") diff --git a/backend/packages/harness/deerflow/community/boxlite/box.py b/backend/packages/harness/deerflow/community/boxlite/box.py index afdb383f1a6..ce80cee997f 100644 --- a/backend/packages/harness/deerflow/community/boxlite/box.py +++ b/backend/packages/harness/deerflow/community/boxlite/box.py @@ -285,7 +285,9 @@ def download_file(self, path: str) -> bytes: def list_dir(self, path: str, max_depth: int = 2) -> list[str]: resolved = self._resolve_path(path) r = self._sh(f"find {shlex.quote(resolved)} -maxdepth {int(max_depth)} \\( -type f -o -type d \\) 2>/dev/null | head -500") - return [line.strip() for line in (r.stdout or "").splitlines() if line.strip()] + # splitlines() already removed the terminators; do NOT strip entries — + # a filename that legitimately ends in whitespace would be corrupted. + return [line for line in (r.stdout or "").splitlines() if line] def glob( self, @@ -305,7 +307,7 @@ def glob( root = resolved.rstrip("/") or "/" root_prefix = root if root == "/" else f"{root}/" for entry in (r.stdout or "").splitlines(): - entry = entry.strip() + # Do NOT strip: trailing whitespace can be part of the filename. if not entry or (entry != root and not entry.startswith(root_prefix)): continue if should_ignore_path(entry): 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 92295d01264..57a09babae8 100644 --- a/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py +++ b/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py @@ -330,7 +330,10 @@ def list_dir(self, path: str, max_depth: int = 2) -> list[str]: try: result = client.commands.run(f"find {shlex.quote(resolved)} -maxdepth {int(max_depth)} \\( -type f -o -type d \\) 2>/dev/null | head -500") output = getattr(result, "stdout", "") or "" - return [line.strip() for line in output.splitlines() if line.strip()] + # splitlines() already removed the terminators; do NOT strip + # entries — a filename that legitimately ends in whitespace + # would be corrupted and never resolve again. + return [line for line in output.splitlines() if line] except Exception as e: logger.error("Failed to list_dir %s in e2b sandbox: %s", resolved, e) return [] @@ -397,7 +400,7 @@ def glob( root = resolved.rstrip("/") or "/" root_prefix = root if root == "/" else f"{root}/" for entry in output.splitlines(): - entry = entry.strip() + # Do NOT strip: trailing whitespace can be part of the filename. if not entry: continue if entry != root and not entry.startswith(root_prefix): diff --git a/backend/packages/harness/deerflow/community/opensandbox/sandbox.py b/backend/packages/harness/deerflow/community/opensandbox/sandbox.py index 994d4ba5bfb..9c4ea34887d 100644 --- a/backend/packages/harness/deerflow/community/opensandbox/sandbox.py +++ b/backend/packages/harness/deerflow/community/opensandbox/sandbox.py @@ -319,7 +319,9 @@ def list_dir(self, path: str, max_depth: int = 2) -> list[str]: raise ValueError("max_depth must be non-negative") resolved = self._resolve_path(path) execution = self._run(f"find {shlex.quote(resolved)} -maxdepth {depth} \\( -type f -o -type d \\) 2>/dev/null | head -500") - return [line.strip() for line in execution_stdout(execution).splitlines() if line.strip()] + # splitlines() already removed the terminators; do NOT strip entries — + # a filename that legitimately ends in whitespace would be corrupted. + return [line for line in execution_stdout(execution).splitlines() if line] def glob(self, path: str, pattern: str, *, include_dirs: bool = False, max_results: int = 200) -> tuple[list[str], bool]: if max_results <= 0: @@ -334,7 +336,7 @@ def glob(self, path: str, pattern: str, *, include_dirs: bool = False, max_resul root = resolved.rstrip("/") or "/" root_prefix = root if root == "/" else f"{root}/" for entry in execution_stdout(execution).splitlines(): - entry = entry.strip() + # Do NOT strip: trailing whitespace can be part of the filename. if not entry or (entry != root and not entry.startswith(root_prefix)) or should_ignore_path(entry): continue relative = entry[len(root) :].lstrip("/") diff --git a/backend/packages/harness/deerflow/community/tenki/sandbox.py b/backend/packages/harness/deerflow/community/tenki/sandbox.py index c227a7cc738..b6952c45e84 100644 --- a/backend/packages/harness/deerflow/community/tenki/sandbox.py +++ b/backend/packages/harness/deerflow/community/tenki/sandbox.py @@ -362,7 +362,9 @@ def download_file(self, path: str) -> bytes: def list_dir(self, path: str, max_depth: int = 2) -> list[str]: resolved = self._resolve_path(path) r = self._sh(f"find {shlex.quote(resolved)} -maxdepth {int(max_depth)} \\( -type f -o -type d \\) 2>/dev/null | head -500") - return [self._virtual_path(line.strip()) for line in (r.stdout_text or "").splitlines() if line.strip()] + # splitlines() already removed the terminators; do NOT strip entries — + # a filename that legitimately ends in whitespace would be corrupted. + return [self._virtual_path(line) for line in (r.stdout_text or "").splitlines() if line] def glob( self, @@ -382,7 +384,7 @@ def glob( root = resolved.rstrip("/") or "/" root_prefix = root if root == "/" else f"{root}/" for entry in (r.stdout_text or "").splitlines(): - entry = entry.strip() + # Do NOT strip: trailing whitespace can be part of the filename. if not entry or (entry != root and not entry.startswith(root_prefix)): continue if should_ignore_path(entry): diff --git a/backend/tests/test_aio_sandbox.py b/backend/tests/test_aio_sandbox.py index 356a003631f..d4d751488c8 100644 --- a/backend/tests/test_aio_sandbox.py +++ b/backend/tests/test_aio_sandbox.py @@ -578,3 +578,11 @@ def test_close_when_no_close_attr_does_not_raise(self, sandbox): sandbox._client = SimpleNamespace() # no close, no _client_wrapper sandbox.close() # must not raise assert sandbox._client is None + + +def test_list_dir_preserves_trailing_space_in_filename(sandbox): + """ "notes.txt " (trailing space) is a legal Linux filename; find prints it + verbatim, one entry per line, so a per-line strip() corrupts the name.""" + sandbox._client.shell.exec_command = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(output="/test/notes.txt \n/test/sub\n"))) + + assert sandbox.list_dir("/test") == ["/test/notes.txt ", "/test/sub"] diff --git a/backend/tests/test_boxlite_provider.py b/backend/tests/test_boxlite_provider.py index 58ac686b8fe..c1e849fe7e5 100644 --- a/backend/tests/test_boxlite_provider.py +++ b/backend/tests/test_boxlite_provider.py @@ -1226,3 +1226,19 @@ def _swap_during_health_check(*args, **kwargs): ) assert not replacement.is_closed provider.shutdown() + + +def test_list_dir_and_glob_preserve_trailing_space_in_filename() -> None: + # "notes.txt " (trailing space) is a legal Linux filename; find prints it + # verbatim, one entry per line, so a per-line strip() corrupts the name. + class _FindBox: + async def exec(self, *argv, env=None, timeout=None): + return types.SimpleNamespace(stdout="/mnt/user-data/workspace/notes.txt \n", stderr="", exit_code=0) + + box = BoxliteBox("box-id", box=_FindBox(), run=_fake_run) + + assert box.list_dir("/mnt/user-data/workspace") == ["/mnt/user-data/workspace/notes.txt "] + + found, truncated = box.glob("/mnt/user-data/workspace", "notes*") + assert found == ["/mnt/user-data/workspace/notes.txt "] + assert truncated is False diff --git a/backend/tests/test_e2b_sandbox_provider.py b/backend/tests/test_e2b_sandbox_provider.py index a2e2b57df12..b643a36e172 100644 --- a/backend/tests/test_e2b_sandbox_provider.py +++ b/backend/tests/test_e2b_sandbox_provider.py @@ -4112,3 +4112,25 @@ def pause_after_reserve( assert client.closed assert p._sandboxes == {} assert p._reserved_slots == 0 + + +def test_list_dir_preserves_trailing_space_in_filename(): + # "notes.txt " (trailing space) is a legal Linux filename; find prints it + # verbatim, one entry per line, so a per-line strip() corrupts the name and + # every follow-up file API call on the listed path misses the real file. + listing = SimpleNamespace(stdout="/home/user/notes.txt \n/home/user/sub\n", stderr="", exit_code=0) + client = FakeClient(commands=FakeCommandsAPI([listing])) + sb = _make_sandbox(client) + + assert sb.list_dir("/home/user") == ["/home/user/notes.txt ", "/home/user/sub"] + + +def test_glob_preserves_trailing_space_in_filename(): + listing = SimpleNamespace(stdout="/home/user/notes.txt \n", stderr="", exit_code=0) + client = FakeClient(commands=FakeCommandsAPI([listing])) + sb = _make_sandbox(client) + + matches, truncated = sb.glob("/home/user", "notes*") + + assert matches == ["/home/user/notes.txt "] + assert truncated is False diff --git a/backend/tests/test_opensandbox_provider.py b/backend/tests/test_opensandbox_provider.py index 3314db38ecd..dc2bf6a6163 100644 --- a/backend/tests/test_opensandbox_provider.py +++ b/backend/tests/test_opensandbox_provider.py @@ -711,3 +711,17 @@ def slow_create(image: str, **kwargs: Any) -> _FakeRemote: assert len(results) == 2 and results[0] == results[1] assert len(sdk.create_calls) == 1 provider.shutdown() + + +def test_list_dir_and_glob_preserve_trailing_space_in_filename() -> None: + # "notes.txt " (trailing space) is a legal Linux filename; find prints it + # verbatim, one entry per line, so a per-line strip() corrupts the name. + remote = _FakeRemote("remote") + box = _box(remote) + box.write_file("/mnt/user-data/workspace/notes.txt ", "payload") + + assert "/mnt/user-data/workspace/notes.txt " in box.list_dir("/mnt/user-data/workspace") + + found, truncated = box.glob("/mnt/user-data/workspace", "notes*") + assert found == ["/mnt/user-data/workspace/notes.txt "] + assert truncated is False diff --git a/backend/tests/test_tenki_provider.py b/backend/tests/test_tenki_provider.py index 77547f302f8..c20320936ba 100644 --- a/backend/tests/test_tenki_provider.py +++ b/backend/tests/test_tenki_provider.py @@ -941,3 +941,16 @@ def test_integration_real_sandbox(monkeypatch): assert box.read_file("/mnt/user-data/workspace/it.txt") == "tenki-e2e" finally: provider.shutdown() + + +def test_search_preserves_trailing_space_in_filename() -> None: + # "notes.txt " (trailing space) is a legal Linux filename; find prints it + # verbatim, one entry per line, so a per-line strip() corrupts the name. + box = TenkiSandbox("sb", _FakeSandbox()) + box.write_file("/mnt/user-data/workspace/notes.txt ", "payload\n") + + assert box.list_dir("/mnt/user-data/workspace") == ["/mnt/user-data/workspace/notes.txt "] + + found, truncated = box.glob("/mnt/user-data/workspace", "notes*") + assert found == ["/mnt/user-data/workspace/notes.txt "] + assert truncated is False