mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
fix(e2b): preserve special characters in search results (#6573)
* fix(e2b): preserve colon-containing paths in grep results * fix(e2b): preserve non-LF separators in search results --------- Co-authored-by: Heran Liu <210811826+heranliu@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
be62fff070
commit
be69d993c1
@ -2154,6 +2154,8 @@ Concurrent reads and writes to the same file share a gate across synchronous and
|
||||
|
||||
The built-in `grep` tool searches either one text file or all matching text files below a directory, so an agent can search an uploaded document directly without first broadening the request to the entire uploads directory.
|
||||
E2B's `glob` filter preserves spaces, quotes, and dollar signs in filename patterns, while wildcard matching and root-relative directory scoping remain unchanged.
|
||||
E2B `grep` also preserves colons in file and directory names when reporting matching paths and line numbers.
|
||||
E2B `glob` and `grep` preserve non-LF separator characters, such as vertical tabs and Unicode line separators, inside returned paths; `grep` also preserves them inside matched text.
|
||||
|
||||
Remote `ls` excludes ignored descendants before applying its 500-entry listing limit, so dependency and build trees do not crowd out visible files. Explicitly listing an ignored directory still lists its contents; normal depth and output limits remain in effect.
|
||||
|
||||
|
||||
@ -44,3 +44,15 @@ passing it to the shell. The shell must not split or expand filename patterns;
|
||||
grep owns basename globbing and `path_matches` owns root-relative directory
|
||||
scoping. `tests/test_sandbox_search_contract.py` pins this across providers using
|
||||
real temporary files and offline shell transports.
|
||||
|
||||
Use `grep --null` to separate the filename from `line:text`, then parse the
|
||||
line number only after that NUL delimiter. Colons are valid path characters,
|
||||
so never split the whole record on them. Real-shell regressions in
|
||||
`tests/test_e2b_sandbox_provider.py` cover colon-containing files and directories.
|
||||
|
||||
Split `grep` and `glob` output records only on `"\n"`, matching
|
||||
`parse_remote_search_output`. Do not use `splitlines()`: its other boundaries
|
||||
(carriage returns, vertical tabs, form feeds, and control/Unicode separators)
|
||||
can occur inside filenames or matched text. The real-shell tests cover these
|
||||
characters in filenames, parent directories, and grep content; their transport
|
||||
decodes captured bytes without universal-newline conversion.
|
||||
|
||||
@ -440,7 +440,8 @@ class E2BSandbox(Sandbox):
|
||||
matches: list[str] = []
|
||||
root = resolved.rstrip("/") or "/"
|
||||
root_prefix = root if root == "/" else f"{root}/"
|
||||
for entry in output.text.splitlines():
|
||||
# Records are LF-delimited; other splitlines() boundaries can be path characters.
|
||||
for entry in output.text.split("\n"):
|
||||
# Do NOT strip: trailing whitespace can be part of the filename.
|
||||
if not entry:
|
||||
continue
|
||||
@ -478,7 +479,8 @@ class E2BSandbox(Sandbox):
|
||||
# Build a portable ``grep`` invocation:
|
||||
# -r recursive, -n line numbers, -H always print filename, -I skip
|
||||
# binary files, -E extended regex (or -F for literal/fixed strings).
|
||||
flags = ["-r", "-n", "-H", "-I"]
|
||||
# --null separates filenames from line numbers even when a path contains colons.
|
||||
flags = ["-r", "-n", "-H", "-I", "--null"]
|
||||
if not case_sensitive:
|
||||
flags.append("-i")
|
||||
if literal:
|
||||
@ -519,9 +521,11 @@ class E2BSandbox(Sandbox):
|
||||
|
||||
matches: list[GrepMatch] = []
|
||||
truncated = output.truncated
|
||||
for raw in output.text.splitlines():
|
||||
# Keep non-LF separators inside filenames and matched text intact.
|
||||
for raw in output.text.split("\n"):
|
||||
try:
|
||||
file_path, line_no_str, line_text = raw.split(":", 2)
|
||||
file_path, match_text = raw.split("\0", 1)
|
||||
line_no_str, line_text = match_text.split(":", 1)
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
|
||||
@ -4043,7 +4043,7 @@ def _search_stdout(raw: str, *, status: int = 0) -> str:
|
||||
def test_grep_scoped_glob_excludes_unrelated_directory_matches():
|
||||
"""Regression: grep(glob="src/*.js") must not leak matches from sibling
|
||||
directories that merely share the file extension."""
|
||||
raw_stdout = "/home/user/workspace/other_dir/unrelated.js:1:console.log('needle in other_dir');\n/home/user/workspace/src/app.js:1:console.log('needle in src');\n"
|
||||
raw_stdout = "/home/user/workspace/other_dir/unrelated.js\x001:console.log('needle in other_dir');\n/home/user/workspace/src/app.js\x001:console.log('needle in src');\n"
|
||||
client = FakeClient(commands=FakeCommandsAPI([SimpleNamespace(stdout=_search_stdout(raw_stdout), stderr="", exit_code=0)]))
|
||||
sb = _make_sandbox(client)
|
||||
|
||||
@ -4059,7 +4059,7 @@ def test_grep_plain_glob_matches_files_in_any_directory():
|
||||
"""No regression: a plain non-scoped glob (no ``/`` in the pattern) must
|
||||
keep matching files at any depth, same as before the directory-scoping
|
||||
fix."""
|
||||
raw_stdout = "/home/user/workspace/other_dir/deep/mod.py:1:needle in a deeply nested file\n/home/user/workspace/src/app.py:1:needle in a python file too\n"
|
||||
raw_stdout = "/home/user/workspace/other_dir/deep/mod.py\x001:needle in a deeply nested file\n/home/user/workspace/src/app.py\x001:needle in a python file too\n"
|
||||
client = FakeClient(commands=FakeCommandsAPI([SimpleNamespace(stdout=_search_stdout(raw_stdout), stderr="", exit_code=0)]))
|
||||
sb = _make_sandbox(client)
|
||||
|
||||
@ -4089,7 +4089,7 @@ def test_grep_scoped_glob_still_passes_coarse_include_flag():
|
||||
def test_grep_without_glob_is_unaffected():
|
||||
"""No regression: omitting ``glob`` entirely must return every match
|
||||
with no path-based post-filtering."""
|
||||
raw_stdout = "/home/user/workspace/anywhere/file.txt:3:needle here\n"
|
||||
raw_stdout = "/home/user/workspace/anywhere/file.txt\x003:needle here\n"
|
||||
client = FakeClient(commands=FakeCommandsAPI([SimpleNamespace(stdout=_search_stdout(raw_stdout), stderr="", exit_code=0)]))
|
||||
sb = _make_sandbox(client)
|
||||
|
||||
@ -4101,7 +4101,7 @@ def test_grep_without_glob_is_unaffected():
|
||||
|
||||
def test_grep_single_file_path_with_matching_glob():
|
||||
"""A basename glob must also apply when the search root is one file."""
|
||||
raw_stdout = "/home/user/uploads/report.md:2:needle here\n"
|
||||
raw_stdout = "/home/user/uploads/report.md\x002:needle here\n"
|
||||
client = FakeClient(commands=FakeCommandsAPI([SimpleNamespace(stdout=_search_stdout(raw_stdout), stderr="", exit_code=0)]))
|
||||
sb = _make_sandbox(client)
|
||||
|
||||
@ -5894,6 +5894,17 @@ _RS_POSIX = pytest.mark.skipif(
|
||||
os.name == "nt" or any(shutil.which(tool) is None for tool in ("sh", "head", "grep", "find")),
|
||||
reason="POSIX sh, head, grep and find required",
|
||||
)
|
||||
_RS_NON_LF_SEPARATORS = [
|
||||
pytest.param("\r", id="carriage-return"),
|
||||
pytest.param("\v", id="vertical-tab"),
|
||||
pytest.param("\f", id="form-feed"),
|
||||
pytest.param("\x1c", id="file-separator"),
|
||||
pytest.param("\x1d", id="group-separator"),
|
||||
pytest.param("\x1e", id="record-separator"),
|
||||
pytest.param("\x85", id="next-line"),
|
||||
pytest.param("\u2028", id="line-separator"),
|
||||
pytest.param("\u2029", id="paragraph-separator"),
|
||||
]
|
||||
|
||||
|
||||
def _rs_env(tmp_path, failing: str | None = None) -> dict[str, str]:
|
||||
@ -5918,8 +5929,9 @@ class _RsShellCommands:
|
||||
def run(self, cmd: str, envs: dict[str, str] | None = None, **kwargs) -> SimpleNamespace:
|
||||
self.calls.append(cmd)
|
||||
# ``sh -c`` (not ``-lc``) keeps a login profile from overriding the fake PATH.
|
||||
proc = subprocess.run(["sh", "-c", cmd], capture_output=True, text=True, env=self._env, check=False)
|
||||
return SimpleNamespace(stdout=proc.stdout, stderr=proc.stderr, exit_code=proc.returncode)
|
||||
# Decode bytes ourselves so text-mode universal newlines cannot alter CR in paths/content.
|
||||
proc = subprocess.run(["sh", "-c", cmd], capture_output=True, env=self._env, check=False)
|
||||
return SimpleNamespace(stdout=proc.stdout.decode("utf-8"), stderr=proc.stderr.decode("utf-8"), exit_code=proc.returncode)
|
||||
|
||||
|
||||
def _rs_sandbox(tmp_path, failing: str | None = None):
|
||||
@ -5930,6 +5942,66 @@ def _rs_search(sb, op: str, root: str):
|
||||
return sb.grep(root, "needle") if op == "grep" else sb.glob(root, "**/*.py")
|
||||
|
||||
|
||||
@_RS_POSIX
|
||||
@pytest.mark.parametrize("relative_path", ["plain.txt", "report:2026.txt", "report:2:2026.txt", "2026-10-09T13:45:00.txt", "reports:2026/result.txt"])
|
||||
@pytest.mark.parametrize("search_scope", ["directory", "single_file", "filtered_directory"])
|
||||
def test_remote_grep_preserves_colons_in_paths(tmp_path, relative_path, search_scope):
|
||||
target = tmp_path / relative_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text("header\nneedle: hello:world\n", encoding="utf-8")
|
||||
root = target if search_scope == "single_file" else tmp_path
|
||||
glob = "*.txt" if search_scope == "filtered_directory" else None
|
||||
|
||||
matches, truncated = _rs_sandbox(tmp_path).grep(str(root), "needle", glob=glob)
|
||||
|
||||
assert [(match.path, match.line_number, match.line) for match in matches] == [(str(target), 2, "needle: hello:world")]
|
||||
assert truncated is False
|
||||
|
||||
|
||||
@_RS_POSIX
|
||||
@pytest.mark.parametrize("separator", _RS_NON_LF_SEPARATORS)
|
||||
@pytest.mark.parametrize("path_template", ["report{}2026.txt", "reports{}2026/result.txt"], ids=["filename", "parent-directory"])
|
||||
@pytest.mark.parametrize("search_scope", ["directory", "single_file", "filtered_directory"])
|
||||
def test_remote_grep_preserves_non_lf_separators_in_paths(tmp_path, separator, path_template, search_scope):
|
||||
target = tmp_path / path_template.format(separator)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text("header\nneedle: hello:world\n", encoding="utf-8")
|
||||
root = target if search_scope == "single_file" else tmp_path
|
||||
glob = "*.txt" if search_scope == "filtered_directory" else None
|
||||
|
||||
matches, truncated = _rs_sandbox(tmp_path).grep(str(root), "needle", glob=glob)
|
||||
|
||||
assert [(match.path, match.line_number, match.line) for match in matches] == [(str(target), 2, "needle: hello:world")]
|
||||
assert truncated is False
|
||||
|
||||
|
||||
@_RS_POSIX
|
||||
@pytest.mark.parametrize("separator", _RS_NON_LF_SEPARATORS)
|
||||
def test_remote_grep_preserves_non_lf_separators_in_content(tmp_path, separator):
|
||||
target = tmp_path / "plain.txt"
|
||||
line = f"needle: before{separator}after"
|
||||
target.write_bytes(f"header\n{line}\n".encode())
|
||||
|
||||
matches, truncated = _rs_sandbox(tmp_path).grep(str(tmp_path), "needle")
|
||||
|
||||
assert [(match.path, match.line_number, match.line) for match in matches] == [(str(target), 2, line)]
|
||||
assert truncated is False
|
||||
|
||||
|
||||
@_RS_POSIX
|
||||
@pytest.mark.parametrize("separator", _RS_NON_LF_SEPARATORS)
|
||||
@pytest.mark.parametrize("path_template", ["report{}2026.txt", "reports{}2026/result.txt"], ids=["filename", "parent-directory"])
|
||||
def test_remote_glob_preserves_non_lf_separators_in_paths(tmp_path, separator, path_template):
|
||||
target = tmp_path / path_template.format(separator)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text("content\n", encoding="utf-8")
|
||||
|
||||
matches, truncated = _rs_sandbox(tmp_path).glob(str(tmp_path), "*.txt")
|
||||
|
||||
assert matches == [str(target)]
|
||||
assert truncated is False
|
||||
|
||||
|
||||
@_RS_POSIX
|
||||
@pytest.mark.parametrize("op", ["grep", "glob"])
|
||||
def test_remote_search_missing_root_raises_file_not_found(tmp_path, op):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user