Repository navigation
fix(sandbox): stop list_dir from reporting failures as empty - #5264
WillemJiang merged 20 commits into
Conversation
Remote providers swallowed find/client errors as [] and 2>/dev/null missing paths as empty stdout. ls_tool then told the agent the directory was (empty). Raise OSError/FileNotFoundError instead so the tool returns Error.
willem-bd
left a comment
There was a problem hiding this comment.
The direction is right — swallowing find/client errors into [] made ls_tool report "(empty)" for broken sandboxes, and raising is the fix. ls_tool already handles both new failure modes (FileNotFoundError -> "Directory not found", OSError/other -> generic error), and list_dir has no other production caller, so the blast radius is contained. Two things worth a second look: (1) the "empty stdout == missing path" invariant is not true when the start point is a symlink to a directory, which E2B actually creates for /mnt/acp-workspace — a path the lead prompt explicitly tells the model to ls; (2) the default LocalSandbox still reports a missing path as "(empty)", so the bug this PR targets remains on the local provider and the abstract contract is now inconsistent across providers.
| # empty stdout is the 2>/dev/null missing-path case, not a real empty dir. | ||
| entries = [line for line in output.splitlines() if line] | ||
| if not entries: | ||
| raise FileNotFoundError(resolved) |
There was a problem hiding this comment.
The new invariant in the comment above is not always true: find defaults to -P, so it does not dereference the start point. When resolved is a symlink to a directory, the start point has type l, neither -type f nor -type d matches, stdout is empty — and this now raises FileNotFoundError for a directory that exists and is listable.
This is not hypothetical in this repo: the E2B bootstrap creates /mnt/acp-workspace as a symlink (sudo ln -sfn {home_dir}/acp-workspace /mnt/acp-workspace, e2b_sandbox_provider.py ~line 1927), and _resolve_path only rewrites /mnt/user-data, so /mnt/acp-workspace reaches find verbatim as a symlink. The lead-agent prompt explicitly instructs the model: "ACP agent results are accessible at /mnt/acp-workspace/ (read-only) — use ls ..." (backend/packages/harness/deerflow/agents/lead_agent/prompt.py:990). With this change that ls returns "Error: Directory not found: /mnt/acp-workspace" instead of a listing.
Suggested fix: find -H {path} ... — dereference only the start point. Verified locally that -H still prints an existing empty directory itself (so the empty-dir case stays distinguishable) and still yields empty stdout for a genuinely missing path. Any agent-created symlink to a directory inside the workspace hits the same thing, and the same applies to the other four providers (for tenki please confirm the busybox find applet supports -H, or use -follow).
There was a problem hiding this comment.
Agreed — find defaults to -P, so a symlink start point has type l and the new empty-stdout check would raise FileNotFoundError for a directory that exists. That is exactly E2B /mnt/acp-workspace.
Switched all five remote list_dir invocations to find -H (dereference only the start point). BusyBox find documents find [-HL], so the same flag is portable on Boxlite/Tenki. Empty-dir vs missing-path still holds: -H still prints an existing directory itself.
| # empty stdout is the 2>/dev/null missing-path case, not a real empty dir. | ||
| entries = [line for line in (r.stdout or "").splitlines() if line] | ||
| if not entries: | ||
| raise FileNotFoundError(resolved) |
There was a problem hiding this comment.
The default provider still has the exact bug this PR fixes: deerflow/sandbox/local/list_dir.py:22 returns [] when the root is not a directory, so on LocalSandbox ls /mnt/user-data/missing still renders as "(empty)" — a misleading answer, and now inconsistent with the five remote providers that say "Directory not found".
Two small additions would make this change coherent:
- Make the local
list_dirhelper raiseFileNotFoundErrorwhenroot_pathis not a directory (it already has the distinguishing information in hand). - Document the raise contract on the abstract
Sandbox.list_dir(backend/packages/harness/deerflow/sandbox/sandbox.py:170).download_fileright above it sets the precedent: "Both local and remote implementations must raiseOSError...". Right now a third-party provider implementing the interface has no way to know empty-list-is-not-allowed is now part of the contract, andbackend/packages/harness/deerflow/sandbox/AGENTS.mddescribes the interface without it.
There was a problem hiding this comment.
Raised FileNotFoundError from the local helper when root_path is not a directory, documented the raise contract on abstract Sandbox.list_dir (same shape as download_file), and noted empty-list-is-not-allowed in sandbox/AGENTS.md.
ls /mnt/user-data/missing now returns Error: Directory not found on LocalSandbox too. A truly empty existing directory still returns [] → (empty).
| logger.error(f"Failed to list directory in sandbox: {e}") | ||
| return [] | ||
| raise OSError(f"Failed to list directory '{path}' in sandbox: {e}") from e | ||
| output = result.data.output if result.data else "" |
There was a problem hiding this comment.
result.data being None is a transport/SDK anomaly (the shell call succeeded but returned no payload), not a missing directory — yet it now surfaces as FileNotFoundError(path), which ls_tool renders as "Error: Directory not found". Consider raising OSError for that specific branch so the two failure classes stay distinguishable, e.g. only treat empty stdout as missing-path when result.data is present.
Minor consistency nit: this provider formats the exception with the caller-supplied path while the other four use resolved. Harmless today (only ls_tool consumes it and it overrides the message), but worth aligning.
There was a problem hiding this comment.
result.data is None now raises OSError (transport/SDK anomaly), and empty stdout with a present payload still raises FileNotFoundError. Exception text uses resolved; AIO has no path mapping, so resolved = path.
Follow-up: AIO find now groups \( -type f -o -type d \) the same way as the other providers. Without parentheses, -type d was not bound by -maxdepth and files could be dropped from the listing.
| sb.list_dir("/home/user") | ||
|
|
||
|
|
||
| def test_list_dir_raises_when_find_returns_no_entries(): |
There was a problem hiding this comment.
opensandbox and tenki get the same behavior change but no equivalent coverage — only aio, boxlite and e2b were extended. Since the raise now depends on each provider's exact find invocation and stdout parsing (and tenki also maps every line through _virtual_path before the emptiness check), a pytest.raises(FileNotFoundError) case in backend/tests/test_opensandbox_provider.py and backend/tests/test_tenki_provider.py would pin the contract per provider, per the TDD rule in AGENTS.md.
There was a problem hiding this comment.
Added pytest.raises(FileNotFoundError) for empty find on OpenSandbox and Tenki. The fake find parsers skip the new -H/-L/-P flags so existing listing tests still resolve the root path. Tenki still maps lines through _virtual_path before the emptiness check.
Empty stdout is not a missing path when find's start point is a symlink (E2B /mnt/acp-workspace). Dereference only the start point with find -H. LocalSandbox now raises FileNotFoundError for a non-directory root, matching remote providers. AIO maps a missing result.data to OSError rather than FileNotFoundError.
|
Thanks @willem-bd — addressed in 9749c6f and 5f2ccf1:
PR description is filled in against #5263. |
Without parentheses, find PATH -maxdepth N -type f -o -type d applies -type d without maxdepth and can drop files from the listing.
willem-bd
left a comment
There was a problem hiding this comment.
Thanks for the fast turnaround — all four prior findings check out at 760a3f7: find -H is applied to all five remote list_dir calls, LocalSandbox raises on a non-directory root, AIO maps a missing result.data to OSError, and OpenSandbox/Tenki now have the FileNotFoundError case covered. I re-validated -H empirically (symlink start point lists, a truly empty dir still prints itself, a missing path stays empty) and the touched suites pass (518 tests; the one test_local_sandbox_provider_mounts failure I hit is pre-existing on origin/main, unrelated to this PR). One remaining consistency gap below, non-blocking.
| # empty stdout is the 2>/dev/null missing-path case, not a real empty dir. | ||
| entries = [self._virtual_path(line) for line in (r.stdout_text or "").splitlines() if line] | ||
| if not entries: | ||
| raise FileNotFoundError(resolved) |
There was a problem hiding this comment.
Non-blocking: this branch treats any empty stdout as "missing path", but 2>/dev/null also swallows an invocation that never produced output — sh -lc exiting 127 (no find binary) or an SDK-level error lands in the same FileNotFoundError(resolved) for a live sandbox.
The status is already on the result object and this file reads it elsewhere (execute_command's result.exit_code check), so the same distinction this PR drew for AIO's result.data is None → OSError is available here. Boxlite and OpenSandbox share the gap: box.py's _sh result carries exit_code, and opensandbox/sandbox.py already reads execution.exit_code / execution.error in format_execution.
Concretely: raise OSError when the exit status is outside (0, 1) — 1 legitimately means find ran and the start point is absent — and reserve FileNotFoundError for the find-ran-but-empty case. Otherwise the OSError half of the contract just added to Sandbox.list_dir is unreachable in these three providers, and the new "empty stdout is the missing-path case" comment overclaims.
There was a problem hiding this comment.
Addressed in add15b7.
list_dir on Tenki, Boxlite, and OpenSandbox now raises OSError when find exit status is outside (0, 1) (OpenSandbox also maps execution.error to OSError). FileNotFoundError is reserved for the find-ran-but-empty case. Added a 127-exit coverage case in each of the three suites.
Tenki, Boxlite, and OpenSandbox treated any empty find stdout as FileNotFoundError, so a missing find binary (exit 127) or SDK error looked like a missing directory. Raise OSError when find status is outside (0, 1); keep FileNotFoundError for the find-ran-but-empty case.
Same gap as Tenki/Boxlite/OpenSandbox: empty find stdout with exit 127 was FileNotFoundError. Raise OSError when the status is outside (0, 1).
| if r.exit_code not in (0, 1): | ||
| raise OSError(f"Failed to list_dir {resolved}: command exited with code {r.exit_code}") | ||
| entries = [line for line in (r.stdout or "").splitlines() if line] | ||
| if not entries: | ||
| raise FileNotFoundError(resolved) |
There was a problem hiding this comment.
[P2] Check find's status, not the pipeline's status
The command above ends with | head -500, and _sh() invokes sh -lc without enabling pipeline failure propagation. Its returned exit code is therefore normally head's status, not find's. If find fails with 127 while head succeeds, this check sees 0 and raises FileNotFoundError for an existing directory, so ls_tool incorrectly reports "Directory not found" instead of a command failure.
I reproduced this through the BoxLite adapter by executing its actual shell pipeline with a shell function standing in for find and returning 127: the pipeline returned 0 and the adapter raised FileNotFoundError. The same pipeline pattern is used by all five remote providers. The new tests inject exit_code=127 directly into SDK results, which bypasses the shell behavior and cannot catch this.
Please capture find's exit status independently while retaining bounded output, distinguish real missing paths from command failures, and add a shell-level regression test. If using pipeline failure propagation, also account for intentional truncation/SIGPIPE from head rather than turning large successful listings into errors.
There was a problem hiding this comment.
Thanks @willem-bd — addressed in 2dd09fc.
find ... | head under sh -lc was reporting head's 0, so find 127 looked like a missing directory. Shared helper sandbox/remote_list_dir.py now:
- Writes find's own
$?to a tempfile on the find side of the pipe, then prints__DF_FIND_STATUS__:Nafterhead, so a 500-line listing cannot truncate the marker. - Treats exit 0/1 as before, and 141 (SIGPIPE from
headclosing the pipe) as successful truncation. - Starts with
set +eso a login-profileerrexitcannot skip the status write. - Shell-level regression: a stub
findexiting 127 through the real pipeline isOSError, notFileNotFoundError. A 5000-line listing truncated byheadstill returns 500 entries.
Provider suites + the new helper tests: 484 passed, 1 skipped.
find | head under sh -lc reports head's exit code, so a missing find binary (127) became FileNotFoundError. Record find's own status after the bounded listing, treat SIGPIPE 141 as truncation success, and add a shell-level regression test.
bytedance#5265 added projects:read/write/delete to the registered route set. The /auth/me tests still pinned the pre-projects list, so CI failed after merging main.
willem-bd
left a comment
There was a problem hiding this comment.
Re-reviewed the delta since 62baa32 (2dd09fc plus the CI test fix in b172d9f). The prior P2 is genuinely fixed: the generated script records find's own $? after head, 141 is treated as successful truncation, set +e defeats a login-profile errexit, and the shell-level regression in tests/test_remote_list_dir.py reproduces the head-0-masks-find-127 case. All six touched suites pass at the head (484 passed, 1 skipped). One residual gap in the new fallback path, inline.
| f"{{ find -H {quoted} -maxdepth {depth} \\( -type f -o -type d \\) 2>/dev/null; " | ||
| f'echo $? > "$_st"; }} | head -n {n}; ' | ||
| f'printf \'\\n%s\\n\' {_STATUS_PREFIX}$(cat "$_st"); ' | ||
| f'rm -f "$_st"' |
There was a problem hiding this comment.
[P2] The pipeline_exit_code fallback can never report a failure, because the script's exit status is rm's, not the pipeline's.
Every provider forwards the sh -lc process status as pipeline_exit_code, and this script's last command is rm -f "$_st". I ran the generated script both ways: a normal run exits 0, and when the status file cannot be written it exits 1 (rm -f still reports EACCES). Both are in _FIND_OK, so whenever the marker is absent or unparsable the parser always lands on "success" and an empty listing becomes FileNotFoundError(resolved).
That silently reinstates exactly the 127 -> "Directory not found" misclassification this commit exists to fix, with no signal. Triggers are plausible: /tmp read-only or absent in a hardened sandbox image, an exec layer that truncates or drops the tail of stdout (OpenSandbox's execution_stdout reassembles streamed log events), or any SDK that discards the final line.
Two ways out, either is fine:
- Make the two signals agree, reserving a non-OK status for "status unknown": capture once with
st=$(cat "$_st" 2>/dev/null), keep theprintf, then end withrm -f "$_st"; exit "${st:-126}". 126 is outside_FIND_OK, so a missing marker now raisesOSError, while a real 0/1/141 still parses. - Or drop the fallback in
parse_remote_list_dir_outputand raiseOSErrorwhenever no marker was found. Loud beats silently wrong here, since the whole point of the helper is to distinguish the two failure classes.
Worth a sibling of test_parse_marker_127_is_command_failure_not_missing_path that passes a real subprocess exit code with no marker, so the fallback cannot regress back to always-OK.
There was a problem hiding this comment.
@willem-bd you're right — the script ended on rm -f, so process status was 0/1 (both in _FIND_OK) whenever the marker was missing. The fallback then turned an empty listing into FileNotFoundError, which is the 127 misclassification this helper was supposed to close.
Fixed both sides in 5bf2490:
- Script now does
st=$(cat "$_st" 2>/dev/null); …; rm -f "$_st"; exit "${st:-126}"so the process status is find's (126 if unknown). - Parser no longer treats a missing marker as success. Non-OK
pipeline_exit_codestill surfaces asOSErrorwith that code; 0/1/141/None without a marker isOSError(marker missing).
Added test_parse_without_marker_real_subprocess_status_is_not_always_ok (sh -c 'exit 0' / exit 1') and pinned proc.returncode == 127 on the generated script.
The generated script ended on `rm -f`, so process status was 0/1 even when find's marker never landed. Both codes are in _FIND_OK, and the parser fallback then classified an empty listing as FileNotFoundError — the 127 misclassification this helper was meant to close. Exit with find's status (126 if unknown). A missing marker is now OSError unless the process status is already a non-OK failure.
willem-bd
left a comment
There was a problem hiding this comment.
Re-reviewed the delta since 7bf5f4b (5bf2490). The round-3 P2 is genuinely fixed, and I verified it empirically rather than by inspection: the generated script now exits with find's own status (reproduced 0 for an existing dir, 1 + FileNotFoundError for a missing path, 127 and 141 through the real pipeline), an unwritable status file yields exit 126 with an empty marker, and a stdout tail that loses the marker raises OSError for every process status including 0 — so the 127 -> "Directory not found" misclassification can no longer come back through the fallback. One new problem, and it is blocking: this push leaves 18 tests in this PR's own touched suites failing at head. Inline.
| raise OSError( | ||
| f"Failed to list_dir {resolved}: command exited with code {pipeline_exit_code}" | ||
| ) | ||
| raise OSError(f"Failed to list_dir {resolved}: find status marker missing") |
There was a problem hiding this comment.
[P1] This branch now reds its own provider suites at 5bf2490: 18 tests fail with OSError: ... find status marker missing, all because the provider fixtures still stub bare find stdout (no __DF_FIND_STATUS__: line) with exit_code=0. The parser change itself is right — marker-less output must not read as success — but the fixtures were not moved to the new contract, so the suites cannot validate this commit.
Reproduced at the head worktree (backend tests, 19 failed / 597 passed / 1 skipped):
test_aio_sandbox.pyx5 —TestListDirSerialization::test_list_dir_uses_lock(:601),::test_list_dir_raises_when_find_returns_no_entries(:623),::test_list_dir_uses_find_H(:641),TestNoChangeTimeout::test_list_dir_passes_no_change_timeout(:687),test_list_dir_preserves_trailing_space_in_filename(:937)test_boxlite_provider.pyx3 — :1307, :1323, :1346test_e2b_sandbox_provider.pyx3 —test_list_dir_preserves_trailing_space_in_filename,test_list_dir_raises_when_find_returns_no_entries(:5224),test_list_dir_uses_find_H_to_dereference_start_pointtest_opensandbox_provider.pyx3 —test_list_glob_and_grep_return_virtual_paths(:603) and the twotest_list_dir_*casestest_tenki_provider.pyx4 — :548, :630, :1023, :1039
(The 19th failure, test_local_sandbox_provider_mounts.py::TestReadOnlyPath::test_bash_write_to_projected_copy_does_not_mutate_source, also fails on origin/main — pre-existing, not this PR.) The same six suites pass at 7bf5f4b (484 passed, 1 skipped), so the red is introduced by this push; the helper additions in test_remote_list_dir.py are the only tests that were updated.
Fix: make each fixture emit the marker and keep exit_code in sync — stdout="...\n\n__DF_FIND_STATUS__:0\n" for listings, "\n__DF_FIND_STATUS__:1\n" for the empty-listing/missing-path cases — or better, drive the fixtures through the real remote_list_dir_command(...) executed against a tmp dir the way test_remote_list_dir.py does, so the generated command and its stubs cannot drift apart on the next contract change.
There was a problem hiding this comment.
Thanks @willem-bd — confirmed. The parser change is right; these fixtures were still stubbing bare find stdout, so they all died on find status marker missing.
Fixed in 30b86f4:
- AIO / Boxlite / E2B stubs now emit
__DF_FIND_STATUS__:0on listings and:1(exit 1) on empty/missing-path cases. - OpenSandbox / Tenki in-memory find fakes append the marker only when the list_dir command is in play (
__DF_FIND_STATUS__:in the script), so glob still gets raw find stdout.
Re-ran the six suites at 30b86f4: 485 passed, 1 skipped.
Parser now requires __DF_FIND_STATUS__ and refuses marker-less stdout. Update AIO/Boxlite/E2B stubs and OpenSandbox/Tenki find fakes so listings carry :0 and missing paths carry :1 with matching exit codes.
willem-bd
left a comment
There was a problem hiding this comment.
Round-5 re-review at 30b86f4. The round-4 blocker is resolved: all 18 provider fixtures (aio x5, boxlite x3, e2b x3, opensandbox x3, tenki x4) now emit __DF_FIND_STATUS__ with a matching exit code, and I re-ran this PR's seven touched suites at this head — 536 passed, 1 skipped, 1 failed, and that one (test_local_sandbox_provider_mounts.py::TestReadOnlyPath::test_bash_write_to_projected_copy_does_not_mutate_source) reproduces identically on origin/main, so it is pre-existing and unrelated. Remote-only run: 485 passed, 1 skipped. I also ran the seven untouched suites that call list_dir (test_ensure_sandbox_initialized, test_local_sandbox_virtual_path_contract, test_sandbox_authorization, test_sandbox_leases, test_sandbox_middleware, test_sandbox_search_tools, test_three_way_skills_mount_e2e) — 194 passed, so the stricter marker-required parser breaks no other test fake. Every prior finding from rounds 1-4 stays fixed at this head (find-vs-head status, the non-rm exit status, the six exit paths, find -H, local FileNotFoundError, AIO result.data is None -> OSError). One remaining item, minor but it will red CI, inline.
| fake_bin.mkdir() | ||
| fake_find = fake_bin / "find" | ||
| fake_find.write_text( | ||
| "#!/bin/sh\n" |
There was a problem hiding this comment.
[minor, CI-blocking] ruff format --check . fails on this new file with the pinned ruff 0.15.12 (backend/uv.lock), and the CI lint-backend job runs exactly that gate via backend/Makefile's make lint, so the lint workflow will red on this PR. ruff format wants the implicit string concatenation collapsed to one literal:
fake_find.write_text(
- "#!/bin/sh\n"
- "i=1\n"
- "while [ \"$i\" -le 5000 ]; do\n"
- " echo \"/dir/f$i\"\n"
- " i=$((i+1))\n"
- "done\n"
- "exit 0\n",
+ '#!/bin/sh\ni=1\nwhile [ "$i" -le 5000 ]; do\n echo "/dir/f$i"\n i=$((i+1))\ndone\nexit 0\n',
encoding="utf-8",
)Verified empirically: ruff format --check . is clean on main (1363 files) and flags only this file, so the drift is introduced by this branch; ruff format tests/test_remote_list_dir.py fixes it. ruff check is clean on every touched file.
There was a problem hiding this comment.
@willem-bd Confirmed and fixed in c1cab769. backend/tests/test_remote_list_dir.py now passes ruff format --check and ruff check. The focused suite also passes: 13 tests passed.
willem-bd
left a comment
There was a problem hiding this comment.
Round-6 re-review at c1cab76. The round-5 blocker is genuinely fixed: test_remote_list_dir.py is now clean under ruff format --check (pinned ruff 0.15.12) and the focused suite passes 13/13 at this head. The delta is a pure string-literal reformat with no behavior change. One remaining CI-gating item that my round-5 pass missed because I only checked the new test file — a second file still fails the same gate, detailed inline.
| # be ``rm``'s (0/1, both in _FIND_OK), which reclassified a lost 127 | ||
| # as FileNotFoundError. | ||
| if pipeline_exit_code is not None and pipeline_exit_code not in _FIND_OK: | ||
| raise OSError( |
There was a problem hiding this comment.
[CI-blocking] ruff format --check . still fails in backend/ at c1cab76 — on this file, not the test file. backend/Makefile's make lint (the lint-backend CI job) runs exactly that gate, so this blocks merge just like the round-5 finding did. I missed it last round by scoping the check to the new test file; this drift has actually been here since 2dd09fc.
Verified with the pinned ruff 0.15.12 (backend/uv.lock) against backend/ruff.toml (line-length = 240, quote-style = double):
$ cd backend && ruff format --check .
Would reformat: packages/harness/deerflow/sandbox/remote_list_dir.py
1 file would be reformatted, 1367 files already formatted
Two spots:
- Here (L79-81): with line-length 240 the joined form fits on one line and there is no magic trailing comma, so ruff collapses the parenthesized call to
raise OSError(f"Failed to list_dir {resolved}: command exited with code {pipeline_exit_code}"). - L40: the single-quoted f-string with escaped single quotes around the printf newlines gets rewritten to a double-quoted literal, dropping the escapes.
ruff format packages/harness/deerflow/sandbox/remote_list_dir.py fixes both; ruff check is already clean on it.
There was a problem hiding this comment.
@willem-bd Confirmed and fixed in 86518cc1. Reformatted backend/packages/harness/deerflow/sandbox/remote_list_dir.py; the full backend passes ruff format --check (1368 files), the target file passes ruff check, and test_remote_list_dir.py passes 13/13.
willem-bd
left a comment
There was a problem hiding this comment.
Round-7 re-review at 86518cc; the delta since 30b86f4 is exactly c1cab76 + 86518cc. The round-5/round-6 CI blocker is now fully resolved: ruff format --check . over backend/ with the pinned ruff 0.15.12 reports 1368 files already formatted, and ruff check on both touched files is clean. The delta is pure formatting with no behavior change — in sandbox/remote_list_dir.py one string literal switches from single to double quotes (identical content) and a multi-line raise OSError(...) collapses onto one line; in tests/test_remote_list_dir.py the inline fake_find script is joined into a single literal. I re-ran this PR's seven touched suites at this head with PYTHONPATH pinned to a worktree of 86518cc (deerflow verified to resolve there): 536 passed, 1 skipped, 1 failed, and the single failure is test_local_sandbox_provider_mounts.py::TestReadOnlyPath::test_bash_write_to_projected_copy_does_not_mutate_source, which I confirmed also fails on the current main lineage, so it is not introduced here. Nothing new to flag; the lint-backend gate should pass at this head.
Fixes #5263
Why
Remote sandbox
list_dirswallowed find/client errors as[], andfind ... 2>/dev/nullon a missing path produced empty stdout.ls_toolthen told the agent the directory was(empty).That is a lying result, not an empty folder: a dead sandbox, a closed client, or a path that does not exist all look like a writable empty directory. The agent can then write into a tree that already has files, or skip recovery.
LocalSandbox had the same missing-path lie (
list_dirreturned[]when the root was not a directory), so the abstract contract was inconsistent across providers.What changed
list_dir(AIO, E2B, Boxlite, OpenSandbox, Tenki) raisesOSErroron command/client failure andFileNotFoundErrorwhenfindprints nothing, solsreturnsError: ....find -Hdereferences only the start point, so a symlink-to-dir (E2B/mnt/acp-workspace) still lists. BusyBoxfind(Boxlite/Tenki) supports-H.result.dataasOSError, notFileNotFoundError, and groups\( -type f -o -type d \)so-type dis bound by-maxdepth.list_dirraisesFileNotFoundErrorwhen the path is not a directory. A truly empty existing directory can still return(empty).Sandbox.list_dirand insandbox/AGENTS.md.Surface area
frontend/backend/applanggraph.json, or prompt changeHumanMessage) — never interpolated into framework-owned system text, even neutralized.docker/or sandboxed executionskills/backend/pyproject.tomlorfrontend/package.json(say what it buys us)Screenshots / Recording
Not applicable; this is a backend sandbox bug fix.
Bug fix verification
tests/test_e2b_sandbox_provider.py::test_list_dir_raises_when_command_failstests/test_e2b_sandbox_provider.py::test_list_dir_raises_when_find_returns_no_entriestests/test_aio_sandbox.py::TestListDirSerialization::test_list_dir_raises_when_exec_failstests/test_local_sandbox_provider_mounts.py::TestSymlinkEscapes::test_list_dir_raises_when_path_is_missingmainand green on this branch? yes (the original remote empty-stdout / swallowed-exception cases; local missing-path was added in review follow-up)result.data is None→OSError; AIOfindtype predicates grouped; E2B/Boxlitefind -H; OpenSandbox/Tenki empty-find →FileNotFoundError.Validation
ruff checkandruff format --checkon the touched sandbox/test filespyteston the list_dir cases intest_e2b_sandbox_provider.py,test_aio_sandbox.py,test_boxlite_provider.py,test_opensandbox_provider.py,test_tenki_provider.py,test_local_sandbox_provider_mounts.py— 33 targeted tests passedAI assistance
Tool(s) used: Pi Grok4.6
How you used it: Source tracing, test-first implementation, review follow-up, and PR writing.