mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-15 17:18:38 +00:00
* fix(sandbox): report truncated remote glob and grep results
BoxLite, Tenki, E2B, and OpenSandbox run find/grep in the sandbox, cap
the raw output with `| head`, and then filter those lines in Python:
ignored directories such as node_modules are dropped and grep's glob
scope is applied. They reported truncated only when max_results matches
survived the filter. When the capped lines were mostly filtered out, a
search with real matches past the cap came back short or empty with
truncated=False, and glob_tool/grep_tool rendered it as "No files
matched" / "No matches found". With the default max_results=200 and
1,200 files under node_modules, glob("**/*.py") reported no matches for
a workspace that has src/app.py.
remote_search_command now lets one line past its limit through, and
parse_remote_search_output(..., limit=) returns RemoteSearchOutput(text,
truncated): the first `limit` lines and whether the extra line arrived.
Exactly `limit` lines stays a complete result. Each provider passes the
cap it already computed to both calls and returns that truncated from
glob and grep when fewer than max_results results survive filtering.
The glob and grep tools now describe an empty truncated result as
incomplete instead of reporting no matches, which also covers AIO grep's
forwarded truncated flag. Sandbox.glob/grep document truncated as "the
matches may be incomplete".
* docs(changelog): reference #5427 in the remote search truncation entry
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
239 lines
11 KiB
Python
239 lines
11 KiB
Python
"""Remote grep/glob command wrapper and stdout contract, including a real POSIX sh pipeline (#5376)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shlex
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
from deerflow.sandbox.remote_search import parse_remote_search_output, remote_search_command
|
|
|
|
_POSIX_SH = pytest.mark.skipif(
|
|
os.name == "nt" or shutil.which("sh") is None or shutil.which("head") is None,
|
|
reason="POSIX sh pipeline required",
|
|
)
|
|
_REAL_GREP = pytest.mark.skipif(shutil.which("grep") is None, reason="system grep required")
|
|
_REAL_FIND = pytest.mark.skipif(shutil.which("find") is None, reason="system find required")
|
|
|
|
|
|
def _grep(root: str, pattern: str = "needle") -> str:
|
|
return f"grep -r -H -n -I -E -e {shlex.quote(pattern)} {shlex.quote(root)} 2>/dev/null"
|
|
|
|
|
|
def _find(root: str) -> str:
|
|
return f"find -H {shlex.quote(root)} \\( -type f \\) -print 2>/dev/null"
|
|
|
|
|
|
def _run(command: str, *, env: dict[str, str] | None = None) -> str:
|
|
# Providers invoke ``sh -lc``; tests use ``sh -c`` so an injected fake
|
|
# binary on PATH is not overwritten by a login profile.
|
|
proc = subprocess.run(["sh", "-c", command], capture_output=True, text=True, env=env, check=False)
|
|
# The wrapper always exits 0 so SDKs that raise on a non-zero exit still
|
|
# return the status marker.
|
|
assert proc.returncode == 0, proc.stderr
|
|
return proc.stdout
|
|
|
|
|
|
def _env_with_fake(tmp_path, name: str, script: str) -> dict[str, str]:
|
|
bin_dir = tmp_path / "bin"
|
|
bin_dir.mkdir(exist_ok=True)
|
|
fake = bin_dir / name
|
|
fake.write_text(script, encoding="utf-8")
|
|
fake.chmod(fake.stat().st_mode | stat.S_IEXEC)
|
|
env = os.environ.copy()
|
|
env["PATH"] = str(bin_dir) + os.pathsep + env.get("PATH", "")
|
|
return env
|
|
|
|
|
|
# ── parser ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_parse_missing_root_marker_is_file_not_found() -> None:
|
|
with pytest.raises(FileNotFoundError):
|
|
parse_remote_search_output("__DF_SEARCH_STATUS__:missing\n", "/dir", tool="grep", limit=450)
|
|
|
|
|
|
@pytest.mark.parametrize("stdout", ["", "/dir/a.py:1:needle\n"])
|
|
def test_parse_without_marker_is_a_failure_not_a_result(stdout: str) -> None:
|
|
with pytest.raises(OSError, match="status marker missing"):
|
|
parse_remote_search_output(stdout, "/dir", tool="grep", limit=450)
|
|
|
|
|
|
@pytest.mark.parametrize(("tool", "status"), [("grep", 0), ("grep", 141), ("find", 0), ("find", 141)])
|
|
def test_parse_success_keeps_output_and_trailing_space(tool: str, status: int) -> None:
|
|
stdout = f"/dir/notes.txt \n\n__DF_SEARCH_STATUS__:{status}\n"
|
|
assert parse_remote_search_output(stdout, "/dir", tool=tool, limit=450) == ("/dir/notes.txt ", False)
|
|
|
|
|
|
def test_parse_genuine_no_match_is_empty() -> None:
|
|
assert parse_remote_search_output("\n__DF_SEARCH_STATUS__:1\n", "/dir", tool="grep", limit=450) == ("", False)
|
|
assert parse_remote_search_output("\n__DF_SEARCH_STATUS__:0\n", "/dir", tool="find", limit=850) == ("", False)
|
|
|
|
|
|
@pytest.mark.parametrize(("tool", "status"), [("grep", 2), ("grep", 126), ("grep", 127), ("find", 1), ("find", 127)])
|
|
def test_parse_failure_without_output_raises(tool: str, status: int) -> None:
|
|
with pytest.raises(OSError, match=f"exited with code {status}"):
|
|
parse_remote_search_output(f"\n__DF_SEARCH_STATUS__:{status}\n", "/dir", tool=tool, limit=450)
|
|
|
|
|
|
@pytest.mark.parametrize(("tool", "status"), [("grep", 2), ("find", 1)])
|
|
def test_parse_error_after_partial_output_still_raises(tool: str, status: int) -> None:
|
|
# An unreadable file or subdirectory leaves the result incomplete, and callers
|
|
# have no partial-result channel: it must not pass as a complete search, and
|
|
# the error must tell the agent how to recover.
|
|
with pytest.raises(OSError, match=f"exited with code {status}") as info:
|
|
parse_remote_search_output(f"/dir/a.py\n\n__DF_SEARCH_STATUS__:{status}\n", "/dir", tool=tool, limit=450)
|
|
assert "could not be read" in str(info.value)
|
|
assert "narrower path" in str(info.value)
|
|
|
|
|
|
@pytest.mark.parametrize(("tool", "status"), [("grep", 126), ("grep", 127), ("find", 127)])
|
|
def test_parse_other_failures_do_not_blame_unreadable_paths(tool: str, status: int) -> None:
|
|
with pytest.raises(OSError, match=f"exited with code {status}") as info:
|
|
parse_remote_search_output(f"\n__DF_SEARCH_STATUS__:{status}\n", "/dir", tool=tool, limit=450)
|
|
assert "could not be read" not in str(info.value)
|
|
|
|
|
|
def test_parse_unparseable_status_is_a_failure() -> None:
|
|
with pytest.raises(OSError, match="status unavailable"):
|
|
parse_remote_search_output("\n__DF_SEARCH_STATUS__:\n", "/dir", tool="find", limit=850)
|
|
|
|
|
|
@pytest.mark.parametrize(("lines", "truncated"), [(0, False), (3, False), (4, True)])
|
|
def test_parse_reports_truncation_only_when_output_passes_the_limit(lines: int, truncated: bool) -> None:
|
|
"""Callers filter these lines in Python, so fewer results than ``max_results``
|
|
no longer proves the search was complete; only the extra line does. Exactly
|
|
``limit`` lines is a complete result, not a truncated one."""
|
|
body = "".join(f"/dir/f{index}.py\n" for index in range(lines))
|
|
result = parse_remote_search_output(f"{body}\n__DF_SEARCH_STATUS__:0\n", "/dir", tool="find", limit=3)
|
|
assert result.text == "\n".join(f"/dir/f{index}.py" for index in range(min(lines, 3)))
|
|
assert result.truncated is truncated
|
|
|
|
|
|
def test_command_checks_root_first_and_records_status_after_head() -> None:
|
|
command = remote_search_command(_grep("/mnt/data dir"), "/mnt/data dir", limit=450)
|
|
assert command.startswith("set +e; ")
|
|
assert "[ ! -e '/mnt/data dir' ]" in command
|
|
# One line past the limit is what lets the parser tell a full result from a cut one.
|
|
assert command.index("[ ! -e ") < command.index("grep ") < command.index("head -n 451")
|
|
assert command.index("head -n 451") < command.rindex("__DF_SEARCH_STATUS__:")
|
|
assert command.endswith("exit 0")
|
|
|
|
|
|
# ── real POSIX sh ─────────────────────────────────────────────────────────
|
|
|
|
|
|
@_POSIX_SH
|
|
@_REAL_GREP
|
|
def test_naive_grep_head_pipeline_hides_a_missing_root(tmp_path) -> None:
|
|
"""Reproduction of #5376: without the wrapper, head's 0 wins and stdout is empty."""
|
|
proc = subprocess.run(["sh", "-c", _grep(str(tmp_path / "missing")) + " | head -450"], capture_output=True, text=True, check=False)
|
|
assert (proc.returncode, proc.stdout) == (0, "")
|
|
|
|
|
|
@_POSIX_SH
|
|
@_REAL_GREP
|
|
def test_grep_distinguishes_match_no_match_and_missing_root(tmp_path) -> None:
|
|
(tmp_path / "src").mkdir()
|
|
(tmp_path / "src" / "app.py").write_text("def needle():\n return 1\n", encoding="utf-8")
|
|
root = str(tmp_path)
|
|
|
|
found = parse_remote_search_output(_run(remote_search_command(_grep(root), root, limit=450)), root, tool="grep", limit=450)
|
|
assert found == (f"{tmp_path / 'src' / 'app.py'}:1:def needle():", False)
|
|
none = parse_remote_search_output(_run(remote_search_command(_grep(root, "zzz_nothing"), root, limit=450)), root, tool="grep", limit=450)
|
|
assert none == ("", False)
|
|
|
|
missing = str(tmp_path / "missing")
|
|
with pytest.raises(FileNotFoundError):
|
|
parse_remote_search_output(_run(remote_search_command(_grep(missing), missing, limit=450)), missing, tool="grep", limit=450)
|
|
|
|
|
|
@_POSIX_SH
|
|
@pytest.mark.parametrize("binary", ["grep", "find"])
|
|
def test_missing_search_binary_is_a_failure_not_a_no_match(tmp_path, binary: str) -> None:
|
|
env = _env_with_fake(tmp_path, binary, "#!/bin/sh\nexit 127\n")
|
|
root = str(tmp_path)
|
|
search, tool = (_grep(root), "grep") if binary == "grep" else (_find(root), "find")
|
|
with pytest.raises(OSError, match="exited with code 127"):
|
|
parse_remote_search_output(_run(remote_search_command(search, root, limit=450), env=env), root, tool=tool, limit=450)
|
|
|
|
|
|
@_POSIX_SH
|
|
@_REAL_FIND
|
|
def test_find_follows_a_symlinked_root_and_reports_an_empty_tree(tmp_path) -> None:
|
|
real = tmp_path / "real"
|
|
(real / "sub").mkdir(parents=True)
|
|
(real / "sub" / "a.txt").write_text("x", encoding="utf-8")
|
|
link = tmp_path / "link"
|
|
link.symlink_to(real, target_is_directory=True)
|
|
out = parse_remote_search_output(_run(remote_search_command(_find(str(link)), str(link), limit=850)), str(link), tool="find", limit=850)
|
|
assert out == (f"{link}/sub/a.txt", False)
|
|
|
|
empty = tmp_path / "empty"
|
|
empty.mkdir()
|
|
assert parse_remote_search_output(_run(remote_search_command(_find(str(empty)), str(empty), limit=850)), str(empty), tool="find", limit=850) == ("", False)
|
|
|
|
|
|
@_POSIX_SH
|
|
@_REAL_GREP
|
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="root can read an unreadable directory")
|
|
def test_unreadable_root_is_a_failure_not_a_no_match(tmp_path) -> None:
|
|
locked = tmp_path / "locked"
|
|
locked.mkdir()
|
|
(locked / "a.txt").write_text("needle\n", encoding="utf-8")
|
|
locked.chmod(0)
|
|
try:
|
|
with pytest.raises(OSError, match="exited with code 2"):
|
|
parse_remote_search_output(_run(remote_search_command(_grep(str(locked)), str(locked), limit=450)), str(locked), tool="grep", limit=450)
|
|
finally:
|
|
locked.chmod(0o700)
|
|
|
|
|
|
@_POSIX_SH
|
|
@_REAL_GREP
|
|
@_REAL_FIND
|
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="root can read an unreadable directory")
|
|
def test_unreadable_subtree_is_a_failure_not_a_partial_result(tmp_path) -> None:
|
|
(tmp_path / "open").mkdir()
|
|
(tmp_path / "open" / "a.txt").write_text("needle\n", encoding="utf-8")
|
|
locked = tmp_path / "locked"
|
|
locked.mkdir()
|
|
(locked / "b.txt").write_text("needle\n", encoding="utf-8")
|
|
locked.chmod(0)
|
|
root = str(tmp_path)
|
|
try:
|
|
# Both searches print the readable match before failing on the locked subtree.
|
|
with pytest.raises(OSError, match="exited with code 2.*could not be read"):
|
|
parse_remote_search_output(_run(remote_search_command(_grep(root), root, limit=450)), root, tool="grep", limit=450)
|
|
with pytest.raises(OSError, match="exited with code 1.*could not be read"):
|
|
parse_remote_search_output(_run(remote_search_command(_find(root), root, limit=850)), root, tool="find", limit=850)
|
|
finally:
|
|
locked.chmod(0o700)
|
|
|
|
|
|
def _counting_grep(count: int) -> str:
|
|
return f'#!/bin/sh\ni=1\nwhile [ "$i" -le {count} ]; do\n echo "/dir/f$i:1:needle"\n i=$((i+1))\ndone\nexit 0\n'
|
|
|
|
|
|
@_POSIX_SH
|
|
def test_head_truncation_is_not_a_failure(tmp_path) -> None:
|
|
env = _env_with_fake(tmp_path, "grep", _counting_grep(5000))
|
|
out = parse_remote_search_output(_run(remote_search_command(_grep("/"), "/", limit=450), env=env), "/", tool="grep", limit=450)
|
|
lines = out.text.split("\n")
|
|
assert len(lines) == 450
|
|
assert lines[0] == "/dir/f1:1:needle"
|
|
assert out.truncated is True
|
|
|
|
|
|
@_POSIX_SH
|
|
@pytest.mark.parametrize(("count", "truncated"), [(450, False), (451, True)])
|
|
def test_real_pipeline_reports_truncation_at_the_limit_boundary(tmp_path, count: int, truncated: bool) -> None:
|
|
env = _env_with_fake(tmp_path, "grep", _counting_grep(count))
|
|
out = parse_remote_search_output(_run(remote_search_command(_grep("/"), "/", limit=450), env=env), "/", tool="grep", limit=450)
|
|
assert len(out.text.split("\n")) == 450
|
|
assert out.truncated is truncated
|