fix(sandbox): allow grep to search a single file (#4512)

This commit is contained in:
Aari 2026-07-28 07:49:13 +08:00 committed by GitHub
parent 28553040fe
commit d455a1815e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 202 additions and 110 deletions

View File

@ -893,6 +893,8 @@ DeerFlow doesn't just *talk* about doing things. It has its own computer.
Each task gets its own execution environment with a full filesystem view — skills, workspace, uploads, outputs. The agent reads, writes, and edits files. It can view images and, when configured safely, execute shell commands.
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.
Image bytes loaded for a vision-model call are transient: DeerFlow removes the hidden base64 message after the model consumes it so later checkpoints do not keep duplicating that payload.
After each run, DeerFlow records a workspace change summary for the run-owned `workspace` and `outputs` directories. The Web UI shows a compact "files changed" badge on the assistant turn; opening it reveals created, modified, and deleted files with text diffs when safe to display. Uploads are excluded because they are user inputs, not agent-generated changes. Large, binary, or sensitive-looking files are shown as metadata only.

View File

@ -536,7 +536,7 @@ copying a raw checkpoint because delta state is not self-contained in one tuple.
### Sandbox System (`packages/harness/deerflow/sandbox/`)
**Interface**: Abstract `Sandbox` with `execute_command(command, env=None)`, `read_file`, `write_file`, `list_dir`. The optional `env` injects per-call environment variables (request-scoped secrets — see Request-Scoped Secrets below); `LocalSandbox` merges it via `subprocess.run(env=...)` and `AioSandbox` routes env-bearing commands through the `bash.exec(env=...)` API on a fresh session.
**Interface**: Abstract `Sandbox` with `execute_command(command, env=None)`, `read_file`, `write_file`, `list_dir`, `glob`, and `grep`. `grep` accepts either one text file or a directory tree. The optional `env` injects per-call environment variables (request-scoped secrets — see Request-Scoped Secrets below); `LocalSandbox` merges it via `subprocess.run(env=...)` and `AioSandbox` routes env-bearing commands through the `bash.exec(env=...)` API on a fresh session.
**Provider Pattern**: `SandboxProvider` with `acquire`, `acquire_async`, `get`, `release` lifecycle. Async agent/tool paths call async sandbox lifecycle hooks so Docker sandbox creation, discovery, cross-process locking, readiness polling, and release stay off the event loop.
**Environment policy** (`sandbox/env_policy.py`): `execute_command` no longer inherits the full `os.environ`. `build_sandbox_env()` scrubs secret-looking names (`*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASS*`/`*CREDENTIAL*`) from the inherited environment before layering injected request secrets on top, so platform credentials (e.g. `OPENAI_API_KEY`) never leak into skill subprocesses. Benign vars (`PATH`, `HOME`, `LANG`, `VIRTUAL_ENV`, ...) are preserved.
**Implementations**:
@ -606,6 +606,8 @@ copying a raw checkpoint because delta state is not self-contained in one tuple.
**Sandbox Tools** (in `packages/harness/deerflow/sandbox/tools.py`):
- `bash` - Execute commands with path translation and error handling. For `LocalSandbox` (host bash), POSIX output is captured through bounded pipe-drain threads and stdin is `/dev/null`, so a backgrounded long-lived process (`server &`) returns immediately instead of blocking the turn on an inherited pipe, while unredirected background output is drained without growing anonymous temp files. Commands that read stdin get immediate EOF. The command runs in its own process group with a wall-clock timeout (`sandbox.bash_command_timeout`, default 600s); on timeout the whole group is killed and the agent gets a notice telling it to background long-lived processes. The bash tool description itself also instructs the model to background long-lived processes (e.g. servers) up front so it doesn't waste the turn waiting on a foreground server. See `LocalSandbox.execute_command` / `_run_posix_command` and `bash_tool`'s docstring.
- `ls` - Directory listing (tree format, max 2 levels)
- `glob` - Find files or directories below a root directory with bounded results
- `grep` - Search one text file or recursively search a directory, with optional glob filtering and bounded line-level results
- `read_file` - Read file contents with optional line range
- `write_file` - Write/append to files, creates directories; overwrites by default and exposes the `append` argument in the model-facing schema for end-of-file writes; subject to the read-before-write gate when `read_before_write.enabled` (see Middleware Chain)
- `str_replace` - Substring replacement (single or all occurrences); same-path serialization is scoped to `(sandbox.id, path)` so isolated sandboxes do not contend on identical virtual paths inside one process; subject to the read-before-write gate when `read_before_write.enabled` (see Middleware Chain)

View File

@ -422,41 +422,45 @@ class AioSandbox(Sandbox):
# (caught by grep_tool's except re.error handler) rather than a
# generic remote API error.
_re.compile(regex_source, 0 if case_sensitive else _re.IGNORECASE)
regex = regex_source if case_sensitive else f"(?i){regex_source}"
if glob is not None:
find_result = self._client.file.find_files(path=path, glob=glob)
candidate_paths = find_result.data.files if find_result.data and find_result.data.files else []
else:
list_result = self._client.file.list_path(path=path, recursive=True, show_hidden=False)
entries = list_result.data.files if list_result.data and list_result.data.files else []
candidate_paths = [entry.path for entry in entries if not entry.is_directory]
total_cap = max(max_results * 4, max_results + 50)
result = self._client.file.grep_files(
path=path,
pattern=pattern,
case_insensitive=not case_sensitive,
fixed_strings=literal,
max_results=total_cap,
max_file_size="1M",
recursive=True,
)
data = result.data
provider_matches = data.matches if data and data.matches else []
root = path.rstrip("/") or "/"
root_prefix = root if root == "/" else f"{root}/"
matches: list[GrepMatch] = []
truncated = False
for file_path in candidate_paths:
truncated = bool(data and data.truncated)
for match in provider_matches:
file_path = match.file
if should_ignore_path(file_path):
continue
search_result = self._client.file.search_in_file(file=file_path, regex=regex)
data = search_result.data
if data is None:
if file_path == root:
rel_path = file_path.rsplit("/", 1)[-1]
elif file_path.startswith(root_prefix):
rel_path = file_path[len(root_prefix) :]
else:
continue
line_numbers = data.line_numbers or []
matched_lines = data.matches or []
for line_number, line in zip(line_numbers, matched_lines):
matches.append(
GrepMatch(
path=file_path,
line_number=line_number if isinstance(line_number, int) else 0,
line=truncate_line(line),
)
if glob is not None and not path_matches(glob, rel_path):
continue
matches.append(
GrepMatch(
path=file_path,
line_number=match.line_number,
line=truncate_line(match.line_content),
)
if len(matches) >= max_results:
truncated = True
return matches, truncated
)
if len(matches) >= max_results:
truncated = True
break
return matches, truncated

View File

@ -325,11 +325,11 @@ class BoxliteBox(Sandbox):
re.compile(pattern, 0 if case_sensitive else re.IGNORECASE)
resolved = self._resolve_path(path)
# busybox+GNU-portable flags: -r recursive (also prints the filename),
# -n line numbers, -I skip binary, -E/-F regex vs fixed. --include and -m
# are omitted for busybox portability; glob-scoping and the result cap are
# applied in Python below.
flags = ["-r", "-n", "-I"]
# busybox+GNU-portable flags: -r recursive, -H always print the filename
# (including when path is a single file), -n line numbers, -I skip
# binary, -E/-F regex vs fixed. --include and -m are omitted for busybox
# portability; glob-scoping and the result cap are applied in Python.
flags = ["-r", "-H", "-n", "-I"]
if not case_sensitive:
flags.append("-i")
flags.append("-F" if literal else "-E")

View File

@ -476,8 +476,8 @@ class E2BSandbox(Sandbox):
# ``--include`` flag above only pre-filtered by basename.
if file_path != root and not file_path.startswith(root_prefix):
continue
rel_path = file_path[len(root) :].lstrip("/")
if not rel_path or not path_matches(glob, rel_path):
rel_path = file_path.rsplit("/", 1)[-1] if file_path == root else file_path[len(root) :].lstrip("/")
if not path_matches(glob, rel_path):
continue
matches.append(
GrepMatch(

View File

@ -448,8 +448,8 @@ class TenkiSandbox(Sandbox):
# helper, same relative-to-root semantics as glob() above.
if file_path != root and not file_path.startswith(root_prefix):
continue
rel_path = file_path[len(root) :].lstrip("/")
if not rel_path or not path_matches(glob, rel_path):
rel_path = posixpath.basename(file_path) if file_path == root else file_path[len(root) :].lstrip("/")
if not path_matches(glob, rel_path):
continue
matches.append(GrepMatch(path=self._virtual_path(file_path), line_number=line_number, line=truncate_line(line_text)))
if len(matches) >= max_results:

View File

@ -161,7 +161,7 @@ class Sandbox(ABC):
case_sensitive: bool = False,
max_results: int = 100,
) -> tuple[list[GrepMatch], bool]:
"""Search for matches inside text files under a directory."""
"""Search for matches inside a text file or files under a directory."""
pass
@abstractmethod

View File

@ -169,7 +169,8 @@ def find_grep_matches(
if not root.exists():
raise FileNotFoundError(root)
if not root.is_dir():
root_is_file = root.is_file()
if not root_is_file and not root.is_dir():
raise NotADirectoryError(root)
regex_source = re.escape(pattern) if literal else pattern
@ -179,44 +180,47 @@ def find_grep_matches(
# Skip lines longer than this to prevent ReDoS on minified / no-newline files.
_max_line_chars = line_summary_length * 10
for current_root, dirs, files in os.walk(root):
dirs[:] = [name for name in dirs if not should_ignore_name(name)]
rel_dir = Path(current_root).relative_to(root)
def candidate_files():
if root_is_file:
yield root, root.name
return
for name in files:
if should_ignore_name(name):
for current_root, dirs, files in os.walk(root):
dirs[:] = [name for name in dirs if not should_ignore_name(name)]
rel_dir = Path(current_root).relative_to(root)
for name in files:
if should_ignore_name(name):
continue
yield Path(current_root) / name, (rel_dir / name).as_posix()
for candidate_path, rel_path in candidate_files():
if glob_pattern is not None and not path_matches(glob_pattern, rel_path):
continue
try:
if not root_is_file and candidate_path.is_symlink():
continue
candidate_path = Path(current_root) / name
rel_path = (rel_dir / name).as_posix()
if glob_pattern is not None and not path_matches(glob_pattern, rel_path):
file_path = candidate_path.resolve()
if not root_is_file and not file_path.is_relative_to(root):
continue
try:
if candidate_path.is_symlink():
continue
file_path = candidate_path.resolve()
if not file_path.is_relative_to(root):
continue
if file_path.stat().st_size > max_file_size or is_binary_file(file_path):
continue
with file_path.open(encoding="utf-8", errors="replace") as handle:
for line_number, line in enumerate(handle, start=1):
if len(line) > _max_line_chars:
continue
if regex.search(line):
matches.append(
GrepMatch(
path=str(file_path),
line_number=line_number,
line=truncate_line(line, line_summary_length),
)
if file_path.stat().st_size > max_file_size or is_binary_file(file_path):
continue
with file_path.open(encoding="utf-8", errors="replace") as handle:
for line_number, line in enumerate(handle, start=1):
if len(line) > _max_line_chars:
continue
if regex.search(line):
matches.append(
GrepMatch(
path=str(file_path),
line_number=line_number,
line=truncate_line(line, line_summary_length),
)
if len(matches) >= max_results:
truncated = True
return matches, truncated
except OSError:
continue
)
if len(matches) >= max_results:
truncated = True
return matches, truncated
except OSError:
continue
return matches, truncated

View File

@ -1993,12 +1993,12 @@ def grep_tool(
case_sensitive: bool = False,
max_results: int = _DEFAULT_GREP_MAX_RESULTS,
) -> str:
"""Search for matching lines inside text files under a root directory.
"""Search for matching lines inside a text file or files under a root directory.
Args:
description: Explain why you are searching file contents in short words. ALWAYS PROVIDE THIS PARAMETER FIRST.
pattern: The string or regex pattern to search for.
path: The **absolute** root directory to search under.
path: The **absolute** file or root directory to search.
glob: Optional glob filter for candidate files, for example `**/*.py`.
literal: Whether to treat `pattern` as a plain string. Default is False.
case_sensitive: Whether matching is case-sensitive. Default is False.

View File

@ -5,7 +5,7 @@ description = "DeerFlow agent harness framework"
requires-python = ">=3.12"
dependencies = [
"agent-client-protocol>=0.4.0",
"agent-sandbox>=0.0.19",
"agent-sandbox>=0.0.30",
"croniter>=6.0.0",
"dotenv>=0.9.9",
"exa-py>=1.0.0",

View File

@ -165,6 +165,17 @@ def test_execute_command_forwards_timeout_to_sdk_and_loop_runner() -> None:
assert run_timeouts == [5]
def test_grep_always_prints_filename_for_single_file_paths() -> None:
fake = _FakeBox(name="box-id")
box = BoxliteBox("box-id", box=fake, run=_fake_run)
box.grep("/mnt/user-data/uploads/report.md", "needle")
grep_commands = [argv[0][2] for argv in fake._exec_history if argv[0][:2] == ("sh", "-lc") and argv[0][2].startswith("grep ")]
assert grep_commands
assert "-H" in grep_commands[-1].split()
def test_execute_command_invalidates_box_on_terminal_transport_error() -> None:
invalidated: list[tuple[str, str]] = []

View File

@ -2072,6 +2072,18 @@ def test_grep_without_glob_is_unaffected():
assert truncated is False
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"
client = FakeClient(commands=FakeCommandsAPI([SimpleNamespace(stdout=raw_stdout, stderr="", exit_code=0)]))
sb = _make_sandbox(client)
matches, truncated = sb.grep("/mnt/user-data/uploads/report.md", "needle", glob="*.md")
assert [m.path for m in matches] == ["/home/user/uploads/report.md"]
assert truncated is False
# ──────────────────────────────────────────────────────────────────────────────
# Capacity enforcement tests (#4339)
# ──────────────────────────────────────────────────────────────────────────────

View File

@ -101,6 +101,26 @@ def test_grep_tool_filters_by_glob_and_skips_binary_files(tmp_path, monkeypatch)
assert str(workspace) not in result
def test_grep_tool_accepts_single_file_path(tmp_path, monkeypatch) -> None:
runtime = _make_runtime(tmp_path)
uploads = tmp_path / "uploads"
report = uploads / "report.md"
report.write_text("Revenue grew 20%\n", encoding="utf-8")
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local"))
result = grep_tool.func(
runtime=runtime,
description="find revenue in the uploaded report",
pattern="Revenue",
path="/mnt/user-data/uploads/report.md",
)
assert "/mnt/user-data/uploads/report.md:1: Revenue grew 20%" in result
assert "Path is not a directory" not in result
assert str(uploads) not in result
def test_grep_tool_truncates_results(tmp_path, monkeypatch) -> None:
runtime = _make_runtime(tmp_path)
workspace = tmp_path / "workspace"
@ -260,24 +280,20 @@ def test_aio_sandbox_grep_parses_json(monkeypatch) -> None:
sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080")
monkeypatch.setattr(
sandbox._client.file,
"list_path",
"grep_files",
lambda **kwargs: SimpleNamespace(
data=SimpleNamespace(
files=[
matches=[
SimpleNamespace(
name="app.py",
path="/mnt/user-data/workspace/app.py",
is_directory=False,
file="/mnt/user-data/workspace/app.py",
line_number=7,
line_content="TODO = True",
)
]
],
truncated=False,
)
),
)
monkeypatch.setattr(
sandbox._client.file,
"search_in_file",
lambda **kwargs: SimpleNamespace(data=SimpleNamespace(line_numbers=[7], matches=["TODO = True"])),
)
matches, truncated = sandbox.grep("/mnt/user-data/workspace", "TODO")
@ -285,6 +301,37 @@ def test_aio_sandbox_grep_parses_json(monkeypatch) -> None:
assert truncated is False
def test_aio_sandbox_grep_accepts_single_file_path(monkeypatch) -> None:
with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"):
sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080")
monkeypatch.setattr(
sandbox._client.file,
"grep_files",
lambda **kwargs: SimpleNamespace(
data=SimpleNamespace(
matches=[
SimpleNamespace(
file="/mnt/user-data/uploads/report.md",
line_number=3,
line_content="Revenue grew 20%",
)
],
truncated=False,
)
),
)
monkeypatch.setattr(
sandbox._client.file,
"list_path",
lambda **kwargs: (_ for _ in ()).throw(AssertionError("single-file grep must not list the path as a directory")),
)
matches, truncated = sandbox.grep("/mnt/user-data/uploads/report.md", "Revenue")
assert matches == [GrepMatch(path="/mnt/user-data/uploads/report.md", line_number=3, line="Revenue grew 20%")]
assert truncated is False
def test_find_glob_matches_raises_not_a_directory(tmp_path) -> None:
file_path = tmp_path / "file.txt"
file_path.write_text("x\n", encoding="utf-8")
@ -296,15 +343,14 @@ def test_find_glob_matches_raises_not_a_directory(tmp_path) -> None:
pass
def test_find_grep_matches_raises_not_a_directory(tmp_path) -> None:
def test_find_grep_matches_accepts_single_file(tmp_path) -> None:
file_path = tmp_path / "file.txt"
file_path.write_text("TODO\n", encoding="utf-8")
try:
find_grep_matches(file_path, "TODO")
assert False, "Expected NotADirectoryError"
except NotADirectoryError:
pass
matches, truncated = find_grep_matches(file_path, "TODO")
assert matches == [GrepMatch(path=str(file_path), line_number=1, line="TODO")]
assert truncated is False
def test_find_grep_matches_skips_symlink_outside_root(tmp_path) -> None:
@ -367,29 +413,30 @@ def test_aio_sandbox_glob_include_dirs_enforces_root_boundary(monkeypatch) -> No
assert truncated is False
def test_aio_sandbox_grep_skips_mismatched_line_number_payloads(monkeypatch) -> None:
def test_aio_sandbox_grep_drops_matches_outside_requested_root(monkeypatch) -> None:
with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"):
sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080")
monkeypatch.setattr(
sandbox._client.file,
"list_path",
"grep_files",
lambda **kwargs: SimpleNamespace(
data=SimpleNamespace(
files=[
matches=[
SimpleNamespace(
name="app.py",
path="/mnt/user-data/workspace/app.py",
is_directory=False,
)
]
file="/mnt/user-data/workspace/app.py",
line_number=7,
line_content="TODO = True",
),
SimpleNamespace(
file="/mnt/user-data/workspace-sibling/leak.py",
line_number=9,
line_content="TODO = False",
),
],
truncated=False,
)
),
)
monkeypatch.setattr(
sandbox._client.file,
"search_in_file",
lambda **kwargs: SimpleNamespace(data=SimpleNamespace(line_numbers=[7], matches=["TODO = True", "extra"])),
)
matches, truncated = sandbox.grep("/mnt/user-data/workspace", "TODO")

View File

@ -543,6 +543,16 @@ def test_grep_passes_capital_h_so_single_file_matches_parse() -> None:
assert grep_scripts and "-H" in shlex.split(grep_scripts[0])
def test_grep_single_file_path_with_matching_glob() -> None:
box = TenkiSandbox("sb", _FakeSandbox())
box.write_file("/mnt/user-data/workspace/a.txt", "needle here\n")
matches, truncated = box.grep("/mnt/user-data/workspace/a.txt", "needle", glob="*.txt")
assert [m.path for m in matches] == ["/mnt/user-data/workspace/a.txt"]
assert truncated is False
def _grep_script(fake: _FakeSandbox) -> list[str]:
scripts = [c["argv"][2] for c in fake.exec_calls if c["argv"][:2] == ("sh", "-lc") and c["argv"][2].startswith("grep ")]
assert scripts, "no grep command was issued"

2
backend/uv.lock generated
View File

@ -952,7 +952,7 @@ tui = [
[package.metadata]
requires-dist = [
{ name = "agent-client-protocol", specifier = ">=0.4.0" },
{ name = "agent-sandbox", specifier = ">=0.0.19" },
{ name = "agent-sandbox", specifier = ">=0.0.30" },
{ name = "aiosqlite", specifier = ">=0.19" },
{ name = "alembic", specifier = ">=1.13" },
{ name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29" },