diff --git a/backend/packages/harness/deerflow/sandbox/search.py b/backend/packages/harness/deerflow/sandbox/search.py index 634d12aabe..bb7588d24c 100644 --- a/backend/packages/harness/deerflow/sandbox/search.py +++ b/backend/packages/harness/deerflow/sandbox/search.py @@ -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): diff --git a/backend/tests/test_sandbox_line_endings.py b/backend/tests/test_sandbox_line_endings.py index 70aa950517..79ff49f4fd 100644 --- a/backend/tests/test_sandbox_line_endings.py +++ b/backend/tests/test_sandbox_line_endings.py @@ -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