From 077773a9675fe5467661e8ffa545bd8896b68e72 Mon Sep 17 00:00:00 2001 From: Luck <2115759892@qq.com> Date: Fri, 13 Nov 2026 23:17:18 +0800 Subject: [PATCH] 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 * docs(sandbox): clarify local grep CRLF handling * test(sandbox): cover literal grep line terminators --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Willem Jiang --- .../harness/deerflow/sandbox/search.py | 9 +++++--- backend/tests/test_sandbox_line_endings.py | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) 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