fix(sandbox): match local grep lines without their terminator (#8210)

* fix(sandbox): match local grep lines without their terminator

find_grep_matches ran the regex on each line with its trailing "\n", so
patterns that can consume the newline (\s$, [^a-z]$, \n) matched every
newline-ended line while the returned line was shown stripped. Strip the
LF or CRLF terminator before matching, as grep(1) and the remote sandbox
providers do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(sandbox): clarify local grep CRLF handling

* test(sandbox): cover literal grep line terminators

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Luck 2026-11-13 23:17:18 +08:00 committed by GitHub
parent dba950443d
commit 077773a967
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 28 additions and 3 deletions

View File

@ -311,12 +311,15 @@ def find_grep_matches(
continue
# newline="\n" ends lines where LocalSandbox.read_file does, so a
# hit's line_number is the start_line that reads it back; a bare
# "\r" stays content. A CRLF terminator still reads as "\n", so $
# anchors match as before.
# "\r" stays content. Like native grep on Unix, local matching
# excludes LF. LocalSandbox also strips the CR in CRLF, so $ anchors
# at the end of the line text for both line endings.
with file_path.open(encoding="utf-8", errors="replace", newline="\n") as handle:
for line_number, line in enumerate(handle, start=1):
if line.endswith("\r\n"):
line = line[:-2] + "\n"
line = line[:-2]
elif line.endswith("\n"):
line = line[:-1]
if not literal and len(line) > _max_line_chars:
continue
if matches_line(line):

View File

@ -15,6 +15,8 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
import deerflow.sandbox.local.local_sandbox as local_sandbox
from deerflow.sandbox.local.local_sandbox import LocalSandbox
from deerflow.sandbox.read_file_contract import count_file_lines
@ -216,3 +218,23 @@ def test_read_before_write_content_changes_when_only_line_endings_change(tmp_pat
after = read_current_file_content(runtime, "/mnt/user-data/outputs/file.txt")
assert before != after
@pytest.mark.parametrize(
("pattern", "literal", "expected"),
[
(r"\s$", False, [(2, "trailing ")]),
(r"[^a-z]$", False, [(2, "trailing ")]),
(r"\n", False, []),
pytest.param("\n", True, [], id="literal-newline"),
],
)
def test_grep_does_not_match_the_line_terminator(tmp_path, pattern, literal, expected) -> None:
# LocalSandbox excludes LF and CRLF terminators from matching, so
# a trailing-whitespace search only matches whitespace in the line text.
path = tmp_path / "ws.txt"
path.write_bytes(b"clean\ntrailing \r\nalso clean\r\nlast clean")
matches, _ = LocalSandbox("t").grep(str(path), pattern, literal=literal)
assert [(match.line_number, match.line) for match in matches] == expected