Totoro 3e536944b7
fix(sandbox): stop remote grep/glob from reporting failures as no matches (#5380)
* fix(sandbox): stop remote grep/glob from reporting failures as no matches

E2B, OpenSandbox, BoxLite and Tenki ran grep/find behind `2>/dev/null | head`, so a missing search root, a missing grep/find binary or an unreadable tree exited 0 with empty stdout and the tools reported "No matches found". Wrap the search in sandbox/remote_search.py, which checks the root first and records the search's own status after head, as remote_list_dir does for list_dir: a missing root raises FileNotFoundError, a failed search raises OSError, and a genuine no-match still returns []. glob's find gains -H for symlinked roots, OpenSandbox's BusyBox fallback keeps the primary grep status, and E2B no longer swallows client errors. Regression tests run each provider's real command in a local POSIX sh.

Fixes #5376

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

* fix(sandbox): fail remote grep/glob on partial traversal errors

grep 2 / find 1 after some results were printed (an unreadable file or
subdirectory) were returned as a complete search. Callers have no
partial-result channel, and #5376 asks for permission and command
failures to raise, so these statuses now raise OSError like any other
failure. Only grep 0/1/141 and find 0/141 pass.

The error for grep 2 / find 1 says that some files or directories could
not be read and asks for a narrower path, so the agent can retry instead
of giving up.

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

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 15:47:58 +08:00

80 lines
3.6 KiB
Python

"""Remote ``grep`` / ``glob`` command wrapper and stdout contract.
Remote providers search with ``grep ... | head`` or ``find ... | head`` under
``sh -lc``. POSIX ``sh`` has no ``pipefail`` and the search's stderr is
discarded, so the pipeline status is ``head``'s: a missing search root, a
missing ``grep``/``find`` binary (127) or an unreadable tree all printed
nothing and exited 0, exactly like a genuine "no matches" (#5376).
:func:`remote_search_command` checks the root first and records the search
command's own status after the bounded output, the same technique as
:mod:`deerflow.sandbox.remote_list_dir`. The script always exits 0 so SDKs that
raise on a non-zero exit still return the marker; the marker alone decides.
"""
from __future__ import annotations
import shlex
from typing import Literal
SearchTool = Literal["grep", "find"]
_STATUS_PREFIX = "__DF_SEARCH_STATUS__:"
_MISSING_ROOT = "missing"
# head closing the pipe after the limit kills the search with SIGPIPE: a
# successful truncation, not an error.
_SIGPIPE = 141
# Statuses of a complete search. grep: 0 = matches, 1 = no match. Everything
# else fails, even after printed results.
_OK_STATUSES: dict[str, tuple[int, ...]] = {"grep": (0, 1, _SIGPIPE), "find": (0, _SIGPIPE)}
# grep 2 / find 1 usually mean an unreadable file or subdirectory. Printed
# results are then incomplete and callers have no partial-result channel, so
# the error tells the agent to narrow the search instead.
_READ_ERROR_STATUS: dict[str, int] = {"grep": 2, "find": 1}
def remote_search_command(search: str, root: str, *, limit: int) -> str:
"""Wrap a ``grep``/``find`` command so its outcome survives ``| head``.
``search`` must write only results to stdout; callers keep ``2>/dev/null``.
"""
quoted = shlex.quote(root)
n = int(limit)
return (
f"set +e; if [ ! -e {quoted} ]; then printf '%s\\n' {_STATUS_PREFIX}{_MISSING_ROOT}; exit 0; fi; "
f'_st=/tmp/df_search_$$; {{ {search}; echo $? > "$_st"; }} | head -n {n}; '
f'st=$(cat "$_st" 2>/dev/null); rm -f "$_st"; '
f"printf '\\n%s\\n' {_STATUS_PREFIX}\"$st\"; exit 0"
)
def parse_remote_search_output(stdout: str | None, root: str, *, tool: SearchTool) -> str:
"""Return the search output without the status marker.
Raises:
FileNotFoundError: The search root does not exist.
OSError: The search did not complete (missing binary, unreadable
root or subtree, invalid invocation) or its status was lost.
"""
# Split on "\n" only: splitlines() would also split on characters that are
# legal in Linux filenames. Callers keep their own per-line handling.
lines = (stdout or "").split("\n")
if lines and lines[-1] == "":
lines.pop()
if not lines or not lines[-1].startswith(_STATUS_PREFIX):
raise OSError(f"Failed to {tool} under {root}: search status marker missing")
raw = lines.pop()[len(_STATUS_PREFIX) :]
if raw == _MISSING_ROOT:
raise FileNotFoundError(root)
if lines and lines[-1] == "":
lines.pop()
try:
status = int(raw)
except ValueError:
raise OSError(f"Failed to {tool} under {root}: search status unavailable") from None
if status in _OK_STATUSES[tool]:
return "\n".join(lines)
if status == _READ_ERROR_STATUS[tool]:
raise OSError(f"Failed to {tool} under {root}: {tool} exited with code {status}, usually because some files or directories could not be read; results would be incomplete, so search a narrower path")
raise OSError(f"Failed to {tool} under {root}: command exited with code {status}")