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..1cedcea467d 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,13 @@ 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()] + # find delimits records with "\n" and nothing else, so split + # on that alone: splitlines() would also break on \v, \f, + # \x1c-\x1e and \x85, all of which are legal inside a Linux + # filename. Do NOT strip entries either — a filename that + # legitimately ends in whitespace would be corrupted and + # never resolve again. + return [line for line in output.split("\n") 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 3aec40d27a3..dbba100782e 100644 --- a/backend/packages/harness/deerflow/community/tenki/sandbox.py +++ b/backend/packages/harness/deerflow/community/tenki/sandbox.py @@ -363,7 +363,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, @@ -383,7 +385,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 8c00b6b7f9c..6076e6d8ff5 100644 --- a/backend/tests/test_boxlite_provider.py +++ b/backend/tests/test_boxlite_provider.py @@ -1289,3 +1289,19 @@ def test_sandbox_id_none_user_quirk_pinned(): from deerflow.sandbox.identity import derive_sandbox_scope_token assert BoxliteProvider._sandbox_id("t-1", None) == derive_sandbox_scope_token(user_id="None", thread_id="t-1") + + +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 7b205e57a1e..65e7b812118 100644 --- a/backend/tests/test_e2b_sandbox_provider.py +++ b/backend/tests/test_e2b_sandbox_provider.py @@ -4323,3 +4323,25 @@ def test_stable_seed_matches_shared_identity(): mod = importlib.import_module("deerflow.community.e2b_sandbox.e2b_sandbox_provider") assert mod.E2BSandboxProvider._stable_seed("t-1", "u-1") == derive_sandbox_scope_token(user_id="u-1", thread_id="t-1") + + +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 b6b5576a1a2..b775fc2d2e8 100644 --- a/backend/tests/test_opensandbox_provider.py +++ b/backend/tests/test_opensandbox_provider.py @@ -755,3 +755,17 @@ def test_sandbox_id_matches_shared_identity(): assert OpenSandboxProvider._sandbox_id("t-1", "u-1") == derive_sandbox_scope_token(user_id="u-1", thread_id="t-1") assert OpenSandboxProvider._sandbox_id("t-1", "") == derive_sandbox_scope_token(user_id="", thread_id="t-1") + + +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 91ccc2dd9fa..863b9709a52 100644 --- a/backend/tests/test_tenki_provider.py +++ b/backend/tests/test_tenki_provider.py @@ -1005,3 +1005,16 @@ def test_sandbox_id_matches_shared_identity(): assert TenkiSandboxProvider._sandbox_id("t-1", "u-1") == derive_sandbox_scope_token(user_id="u-1", thread_id="t-1") assert TenkiSandboxProvider._sandbox_id("t-1", "") == derive_sandbox_scope_token(user_id="", thread_id="t-1") + + +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. + 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