mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-23 13:06:20 +00:00
* fix(sandbox): report truncated remote glob and grep results
BoxLite, Tenki, E2B, and OpenSandbox run find/grep in the sandbox, cap
the raw output with `| head`, and then filter those lines in Python:
ignored directories such as node_modules are dropped and grep's glob
scope is applied. They reported truncated only when max_results matches
survived the filter. When the capped lines were mostly filtered out, a
search with real matches past the cap came back short or empty with
truncated=False, and glob_tool/grep_tool rendered it as "No files
matched" / "No matches found". With the default max_results=200 and
1,200 files under node_modules, glob("**/*.py") reported no matches for
a workspace that has src/app.py.
remote_search_command now lets one line past its limit through, and
parse_remote_search_output(..., limit=) returns RemoteSearchOutput(text,
truncated): the first `limit` lines and whether the extra line arrived.
Exactly `limit` lines stays a complete result. Each provider passes the
cap it already computed to both calls and returns that truncated from
glob and grep when fewer than max_results results survive filtering.
The glob and grep tools now describe an empty truncated result as
incomplete instead of reporting no matches, which also covers AIO grep's
forwarded truncated flag. Sandbox.glob/grep document truncated as "the
matches may be incomplete".
* docs(changelog): reference #5427 in the remote search truncation entry
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
533 lines
22 KiB
Python
533 lines
22 KiB
Python
from __future__ import annotations
|
|
|
|
import errno
|
|
import logging
|
|
import re
|
|
import shlex
|
|
import threading
|
|
from typing import TYPE_CHECKING
|
|
|
|
from e2b import FileNotFoundException
|
|
from e2b_code_interpreter import Sandbox as E2BClientSandbox
|
|
|
|
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
|
|
from deerflow.sandbox.remote_list_dir import parse_remote_list_dir_output, remote_list_dir_command
|
|
from deerflow.sandbox.remote_search import parse_remote_search_output, remote_search_command
|
|
from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env
|
|
from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line
|
|
|
|
if TYPE_CHECKING:
|
|
from deerflow.community.e2b_sandbox.e2b_sandbox_provider import MountUploadResult
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100 MB
|
|
|
|
# Where DeerFlow's ``/mnt/user-data`` virtual prefix is materialised inside
|
|
# the e2b sandbox. e2b code-interpreter templates default to ``/home/user``
|
|
# as the working directory.
|
|
DEFAULT_E2B_HOME_DIR = "/home/user"
|
|
|
|
_E2B_NOT_FOUND_SIGNATURES = (
|
|
"sandbox was not found",
|
|
"sandbox not found",
|
|
"paused sandbox",
|
|
)
|
|
|
|
|
|
def _is_sandbox_gone_error(exc: BaseException) -> bool:
|
|
msg = str(exc).lower()
|
|
return any(sig in msg for sig in _E2B_NOT_FOUND_SIGNATURES)
|
|
|
|
|
|
class E2BSandbox(Sandbox):
|
|
"""DeerFlow Sandbox adapter that delegates to an e2b cloud sandbox.
|
|
|
|
Args:
|
|
id: DeerFlow-side sandbox id (used as cache key in the provider).
|
|
client: A live ``e2b_code_interpreter.Sandbox`` (sync) instance.
|
|
The caller owns the connection and is responsible for ``kill()``;
|
|
this wrapper only calls ``close()`` on its host-side HTTP client
|
|
during release.
|
|
home_dir: Directory inside the sandbox that backs the
|
|
``VIRTUAL_PATH_PREFIX`` (``/mnt/user-data``) prefix. Defaults to
|
|
:data:`DEFAULT_E2B_HOME_DIR`.
|
|
"""
|
|
|
|
#: Every call is a fresh ``sandbox.commands.run`` execution — no shell
|
|
#: state survives into the next command.
|
|
persistent_shell_sessions = False
|
|
|
|
def __init__(
|
|
self,
|
|
id: str,
|
|
client: E2BClientSandbox,
|
|
*,
|
|
home_dir: str = DEFAULT_E2B_HOME_DIR,
|
|
) -> None:
|
|
super().__init__(id)
|
|
self._client = client
|
|
self._home_dir = home_dir.rstrip("/") or "/"
|
|
self._lock = threading.Lock()
|
|
self._closed = False
|
|
self._dead = False
|
|
self.mount_upload_result: MountUploadResult | None = None
|
|
|
|
# ── Properties / lifecycle ───────────────────────────────────────────
|
|
|
|
@property
|
|
def client(self) -> E2BClientSandbox:
|
|
return self._client
|
|
|
|
@property
|
|
def home_dir(self) -> str:
|
|
return self._home_dir
|
|
|
|
@property
|
|
def sandbox_id(self) -> str:
|
|
"""e2b-side sandbox id (different from DeerFlow's ``self.id`` cache key)."""
|
|
return getattr(self._client, "sandbox_id", None) or self.id
|
|
|
|
def close(self) -> None:
|
|
with self._lock:
|
|
if self._closed:
|
|
return
|
|
self._closed = True
|
|
client = self._client
|
|
self._client = None
|
|
|
|
if client is None:
|
|
return
|
|
|
|
for closer in (
|
|
getattr(client, "close", None),
|
|
getattr(getattr(client, "_transport", None), "close", None),
|
|
):
|
|
if callable(closer):
|
|
try:
|
|
closer()
|
|
except Exception as e:
|
|
logger.warning("Error closing E2BSandbox %s: %s", self.id, e)
|
|
return
|
|
|
|
def _resolve_path(self, path: str) -> str:
|
|
"""Map DeerFlow virtual paths into the e2b sandbox filesystem.
|
|
|
|
``VIRTUAL_PATH_PREFIX`` (``/mnt/user-data``) is rewritten under
|
|
:attr:`home_dir`, mirroring how ``LocalContainerBackend`` bind-mounts
|
|
the host workspace into the AIO container at ``/mnt/user-data``.
|
|
Other absolute paths are returned verbatim so the sandbox can reach
|
|
system directories (``/tmp``, ``/etc``, …) when needed.
|
|
"""
|
|
if not path:
|
|
raise ValueError("path must be a non-empty string")
|
|
normalised = path.replace("\\", "/")
|
|
for segment in normalised.split("/"):
|
|
if segment == "..":
|
|
raise PermissionError(f"Access denied: path traversal detected in '{path}'")
|
|
if normalised == VIRTUAL_PATH_PREFIX or normalised.startswith(f"{VIRTUAL_PATH_PREFIX}/"):
|
|
tail = normalised[len(VIRTUAL_PATH_PREFIX) :].lstrip("/")
|
|
return f"{self._home_dir}/{tail}".rstrip("/") if tail else self._home_dir
|
|
return normalised
|
|
|
|
def execute_command(
|
|
self,
|
|
command: str,
|
|
env: dict[str, str] | None = None,
|
|
timeout: float | None = None,
|
|
) -> str:
|
|
"""Execute a shell command via ``sandbox.commands.run``.
|
|
|
|
Returns the combined stdout/stderr.
|
|
The lock serialises concurrent calls on the same instance
|
|
because the e2b SDK shares a single HTTP/2 connection per sandbox.
|
|
|
|
Args:
|
|
command: The command to execute.
|
|
env: Optional per-call environment variables (request-scoped secrets,
|
|
issue #3861). Validated against the POSIX env-var name rule
|
|
(shared with the local and AIO sandboxes) and passed through to
|
|
e2b as ``envs``, which are scoped to this command only and never
|
|
placed in the command string.
|
|
timeout: Optional per-call command timeout in seconds. ``None`` keeps
|
|
the e2b SDK default (60s).
|
|
"""
|
|
_validate_extra_env(env)
|
|
with self._lock:
|
|
client = self._client
|
|
if client is None:
|
|
return "Error: sandbox client has been closed"
|
|
if self._dead:
|
|
return "Error: e2b sandbox has been reaped by the control plane (idle timeout or explicit pause). The provider will rebuild a fresh sandbox on the next tool call."
|
|
try:
|
|
kwargs: dict[str, object] = {}
|
|
if env is not None:
|
|
kwargs["envs"] = env
|
|
if timeout is not None:
|
|
kwargs["timeout"] = timeout
|
|
result = client.commands.run(command, **kwargs)
|
|
stdout = getattr(result, "stdout", "") or ""
|
|
stderr = getattr(result, "stderr", "") or ""
|
|
exit_code = getattr(result, "exit_code", 0)
|
|
if stdout and stderr:
|
|
output = f"{stdout}\n{stderr}"
|
|
else:
|
|
output = stdout or stderr
|
|
if exit_code not in (0, None):
|
|
# Mirror LocalSandbox: a nonzero exit must survive in the
|
|
# output text even when the command produced output, so
|
|
# evidence consumers (acceptance checklist) can recover the
|
|
# actual shell status.
|
|
output = f"{output}\nExit Code: {exit_code}" if output else f"Command exited with code {exit_code}"
|
|
return output if output else "(no output)"
|
|
except Exception as e:
|
|
if _is_sandbox_gone_error(e):
|
|
self._dead = True
|
|
logger.error("Failed to execute command in e2b sandbox: %s", e)
|
|
return f"Error: {e}"
|
|
|
|
@property
|
|
def is_dead(self) -> bool:
|
|
"""Whether the underlying e2b VM is known to be reaped.
|
|
|
|
Updated lazily by ``execute_command`` and the provider's ``ping`` /
|
|
bootstrap calls — there is no proactive heartbeat. Reading the value
|
|
does *not* round-trip to the API.
|
|
"""
|
|
with self._lock:
|
|
return self._dead
|
|
|
|
def ping(self) -> bool:
|
|
"""Cheap health check: returns False if the e2b VM has been reaped.
|
|
|
|
Run as ``commands.run("true")`` so successful execution implies the
|
|
full HTTP path (auth + control plane + envd) is alive. Sets
|
|
``_dead = True`` on the same "sandbox not found" signature
|
|
:func:`_is_sandbox_gone_error` recognises so subsequent calls
|
|
short-circuit.
|
|
"""
|
|
with self._lock:
|
|
if self._dead or self._client is None:
|
|
return False
|
|
client = self._client
|
|
try:
|
|
client.commands.run("true")
|
|
return True
|
|
except Exception as e:
|
|
if _is_sandbox_gone_error(e):
|
|
with self._lock:
|
|
self._dead = True
|
|
return False
|
|
logger.warning("e2b sandbox ping raised non-fatal error: %s", e)
|
|
return True
|
|
|
|
def read_file(
|
|
self,
|
|
path: str,
|
|
start_line: int | None = None,
|
|
end_line: int | None = None,
|
|
) -> str:
|
|
resolved = self._resolve_path(path)
|
|
try:
|
|
content = self._client.files.read(resolved)
|
|
if start_line is None and end_line is None:
|
|
return content.decode("utf-8", errors="replace") if isinstance(content, bytes) else content or ""
|
|
text = content.decode("utf-8", errors="replace") if isinstance(content, bytes) else content or ""
|
|
lines = text.splitlines()
|
|
start = start_line or 1
|
|
end = end_line if end_line is not None else len(lines)
|
|
content = "\n".join(lines[start - 1 : end])
|
|
return content
|
|
except Exception as e:
|
|
logger.error("Failed to read file %s in e2b sandbox: %s", resolved, e)
|
|
return f"Error: {e}"
|
|
|
|
def download_file(self, path: str) -> bytes:
|
|
normalised = path.replace("\\", "/")
|
|
for segment in normalised.split("/"):
|
|
if segment == "..":
|
|
logger.error("Refused download due to path traversal: %s", path)
|
|
raise PermissionError(f"Access denied: path traversal detected in '{path}'")
|
|
|
|
stripped_path = normalised.lstrip("/")
|
|
allowed_prefix = VIRTUAL_PATH_PREFIX.lstrip("/")
|
|
if stripped_path != allowed_prefix and not stripped_path.startswith(f"{allowed_prefix}/"):
|
|
logger.error(
|
|
"Refused download outside allowed directory: path=%s, allowed_prefix=%s",
|
|
path,
|
|
VIRTUAL_PATH_PREFIX,
|
|
)
|
|
raise PermissionError(f"Access denied: path must be under '{VIRTUAL_PATH_PREFIX}': '{path}'")
|
|
|
|
resolved = self._resolve_path(path)
|
|
# Prefer the streaming API so the 100 MB cap is enforced *before* the
|
|
# whole payload is buffered in the gateway process. ``format="bytes"``
|
|
# is implemented by the e2b SDK as ``bytearray(r.content)`` — i.e. the
|
|
# entire file is materialised in memory before returning — which would
|
|
# let a multi-GB artifact OOM the shared gateway on hosted deployments.
|
|
# ``format="stream"`` returns a ``FileStreamReader`` (an
|
|
# ``Iterator[bytes]``) that owns its HTTP response and releases the
|
|
# pooled connection on exhaustion / close / error.
|
|
with self._lock:
|
|
client = self._client
|
|
if client is None:
|
|
raise RuntimeError("sandbox client has been closed")
|
|
try:
|
|
data = client.files.read(resolved, format="stream")
|
|
except TypeError:
|
|
try:
|
|
data = client.files.read(resolved, format="bytes")
|
|
except Exception as e:
|
|
logger.error("Failed to download file %s from e2b sandbox: %s", resolved, e)
|
|
raise OSError(f"Failed to download file '{path}' from sandbox: {e}") from e
|
|
except Exception as e:
|
|
logger.error("Failed to download file %s from e2b sandbox: %s", resolved, e)
|
|
raise OSError(f"Failed to download file '{path}' from sandbox: {e}") from e
|
|
|
|
if data is None:
|
|
return b""
|
|
|
|
# Buffered fallbacks (bytes/bytearray/str): apply the cap up front so
|
|
# we still refuse oversize payloads even on this path.
|
|
if isinstance(data, (bytes, bytearray)):
|
|
if len(data) > _MAX_DOWNLOAD_SIZE:
|
|
raise OSError(
|
|
errno.EFBIG,
|
|
f"File exceeds maximum download size of {_MAX_DOWNLOAD_SIZE} bytes",
|
|
path,
|
|
)
|
|
return bytes(data)
|
|
if isinstance(data, str):
|
|
encoded = data.encode("utf-8")
|
|
if len(encoded) > _MAX_DOWNLOAD_SIZE:
|
|
raise OSError(
|
|
errno.EFBIG,
|
|
f"File exceeds maximum download size of {_MAX_DOWNLOAD_SIZE} bytes",
|
|
path,
|
|
)
|
|
return encoded
|
|
|
|
chunks: list[bytes] = []
|
|
total = 0
|
|
close = getattr(data, "close", None)
|
|
try:
|
|
try:
|
|
for chunk in data:
|
|
if not chunk:
|
|
continue
|
|
chunk_bytes = chunk if isinstance(chunk, bytes) else bytes(chunk)
|
|
total += len(chunk_bytes)
|
|
if total > _MAX_DOWNLOAD_SIZE:
|
|
raise OSError(
|
|
errno.EFBIG,
|
|
f"File exceeds maximum download size of {_MAX_DOWNLOAD_SIZE} bytes",
|
|
path,
|
|
)
|
|
chunks.append(chunk_bytes)
|
|
except OSError:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to stream file %s from e2b sandbox: %s", resolved, e)
|
|
raise OSError(f"Failed to download file '{path}' from sandbox: {e}") from e
|
|
finally:
|
|
if callable(close):
|
|
try:
|
|
close()
|
|
except Exception:
|
|
pass
|
|
return b"".join(chunks)
|
|
|
|
def list_dir(self, path: str, max_depth: int = 2) -> list[str]:
|
|
resolved = self._resolve_path(path)
|
|
with self._lock:
|
|
client = self._client
|
|
if client is None:
|
|
raise RuntimeError("sandbox client has been closed")
|
|
try:
|
|
result = client.commands.run(remote_list_dir_command(resolved, max_depth))
|
|
except Exception as e:
|
|
logger.error("Failed to list_dir %s in e2b sandbox: %s", resolved, e)
|
|
raise OSError(f"Failed to list_dir {resolved} in e2b sandbox: {e}") from e
|
|
return parse_remote_list_dir_output(
|
|
getattr(result, "stdout", "") or "",
|
|
resolved,
|
|
pipeline_exit_code=getattr(result, "exit_code", None),
|
|
)
|
|
|
|
def write_file(self, path: str, content: str, append: bool = False) -> None:
|
|
resolved = self._resolve_path(path)
|
|
with self._lock:
|
|
client = self._client
|
|
if client is None:
|
|
raise RuntimeError("sandbox client has been closed")
|
|
if append:
|
|
# E2B has no append write. Read-modify-write must treat only
|
|
# explicit not-found as empty; any other read failure would
|
|
# otherwise overwrite the original file with just the tail.
|
|
try:
|
|
existing = client.files.read(resolved) or ""
|
|
except (FileNotFoundException, FileNotFoundError):
|
|
existing = ""
|
|
except Exception:
|
|
logger.error(
|
|
"Append pre-read failed for %s; refusing to overwrite",
|
|
resolved,
|
|
)
|
|
raise
|
|
if isinstance(existing, bytes):
|
|
existing = existing.decode("utf-8", errors="replace")
|
|
content = existing + content
|
|
try:
|
|
client.files.write(resolved, content)
|
|
except Exception as e:
|
|
logger.error("Failed to write file %s in e2b sandbox: %s", resolved, e)
|
|
raise
|
|
|
|
def update_file(self, path: str, content: bytes) -> None:
|
|
resolved = self._resolve_path(path)
|
|
with self._lock:
|
|
client = self._client
|
|
if client is None:
|
|
raise RuntimeError("sandbox client has been closed")
|
|
try:
|
|
# e2b's ``files.write`` accepts either ``str`` or ``bytes`` —
|
|
# passing bytes preserves binary content losslessly.
|
|
client.files.write(resolved, content)
|
|
except Exception as e:
|
|
logger.error("Failed to update file %s in e2b sandbox: %s", resolved, e)
|
|
raise
|
|
|
|
def glob(
|
|
self,
|
|
path: str,
|
|
pattern: str,
|
|
*,
|
|
include_dirs: bool = False,
|
|
max_results: int = 200,
|
|
) -> tuple[list[str], bool]:
|
|
resolved = self._resolve_path(path)
|
|
types = "f,d" if include_dirs else "f"
|
|
hard_limit = max(max_results * 4, max_results + 50)
|
|
# -H follows a symlinked search root (e.g. /mnt/acp-workspace), as list_dir does.
|
|
search = f"find -H {shlex.quote(resolved)} \\( " + " -o ".join(f"-type {t}" for t in types.split(",")) + " \\) -print 2>/dev/null"
|
|
with self._lock:
|
|
client = self._client
|
|
if client is None:
|
|
raise RuntimeError("sandbox client has been closed")
|
|
try:
|
|
result = client.commands.run(remote_search_command(search, resolved, limit=hard_limit))
|
|
except Exception as e:
|
|
logger.error("Failed to glob in e2b sandbox: %s", e)
|
|
raise OSError(f"Failed to glob {resolved} in e2b sandbox: {e}") from e
|
|
# A missing root or a failed find must not read as "no files matched" (#5376).
|
|
output = parse_remote_search_output(getattr(result, "stdout", "") or "", resolved, tool="find", limit=hard_limit)
|
|
|
|
matches: list[str] = []
|
|
root = resolved.rstrip("/") or "/"
|
|
root_prefix = root if root == "/" else f"{root}/"
|
|
for entry in output.text.splitlines():
|
|
# Do NOT strip: trailing whitespace can be part of the filename.
|
|
if not entry:
|
|
continue
|
|
if entry != root and not entry.startswith(root_prefix):
|
|
continue
|
|
if should_ignore_path(entry):
|
|
continue
|
|
rel_path = entry[len(root) :].lstrip("/")
|
|
if not rel_path:
|
|
continue
|
|
if path_matches(pattern, rel_path):
|
|
matches.append(entry)
|
|
if len(matches) >= max_results:
|
|
return matches, True
|
|
return matches, output.truncated
|
|
|
|
def grep(
|
|
self,
|
|
path: str,
|
|
pattern: str,
|
|
*,
|
|
glob: str | None = None,
|
|
literal: bool = False,
|
|
case_sensitive: bool = False,
|
|
max_results: int = 100,
|
|
) -> tuple[list[GrepMatch], bool]:
|
|
regex_source = re.escape(pattern) if literal else pattern
|
|
re.compile(regex_source, 0 if case_sensitive else re.IGNORECASE)
|
|
|
|
resolved = self._resolve_path(path)
|
|
# Build a portable ``grep`` invocation:
|
|
# -r recursive, -n line numbers, -H always print filename, -I skip
|
|
# binary files, -E extended regex (or -F for literal/fixed strings).
|
|
flags = ["-r", "-n", "-H", "-I"]
|
|
if not case_sensitive:
|
|
flags.append("-i")
|
|
if literal:
|
|
flags.append("-F")
|
|
else:
|
|
flags.append("-E")
|
|
if glob is not None:
|
|
# ``grep --include`` only matches by basename, at any depth -- it
|
|
# cannot express a directory-scoping prefix like ``src/`` in
|
|
# ``src/*.js``. Pass just the basename portion as a coarse
|
|
# pre-filter (a superset of the true match set: every file
|
|
# ``path_matches`` can accept also satisfies this basename
|
|
# pattern) and enforce the real directory scope below via
|
|
# ``path_matches``, the same helper ``glob()`` uses.
|
|
include_pattern = glob.split("/")[-1] or glob
|
|
flags.append(f"--include={include_pattern}")
|
|
|
|
per_file_cap = max(max_results, 50)
|
|
total_cap = max(max_results * 4, max_results + 50)
|
|
flags.append(f"-m{per_file_cap}")
|
|
|
|
search = "grep " + " ".join(flags) + f" -- {shlex.quote(regex_source)} {shlex.quote(resolved)} 2>/dev/null"
|
|
|
|
with self._lock:
|
|
client = self._client
|
|
if client is None:
|
|
raise RuntimeError("sandbox client has been closed")
|
|
try:
|
|
result = client.commands.run(remote_search_command(search, resolved, limit=total_cap))
|
|
except Exception as e:
|
|
logger.error("Failed to grep in e2b sandbox: %s", e)
|
|
raise OSError(f"Failed to grep {resolved} in e2b sandbox: {e}") from e
|
|
# A missing root, a missing grep or an unreadable tree must not read as "no matches" (#5376).
|
|
output = parse_remote_search_output(getattr(result, "stdout", "") or "", resolved, tool="grep", limit=total_cap)
|
|
|
|
root = resolved.rstrip("/") or "/"
|
|
root_prefix = root if root == "/" else f"{root}/"
|
|
|
|
matches: list[GrepMatch] = []
|
|
truncated = output.truncated
|
|
for raw in output.text.splitlines():
|
|
try:
|
|
file_path, line_no_str, line_text = raw.split(":", 2)
|
|
except ValueError:
|
|
continue
|
|
try:
|
|
line_number = int(line_no_str)
|
|
except ValueError:
|
|
continue
|
|
if should_ignore_path(file_path):
|
|
continue
|
|
if glob is not None:
|
|
# Restrict to the caller's real directory scope -- the
|
|
# ``--include`` flag above only pre-filtered by basename.
|
|
if file_path != root and not file_path.startswith(root_prefix):
|
|
continue
|
|
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(
|
|
path=file_path,
|
|
line_number=line_number,
|
|
line=truncate_line(line_text),
|
|
)
|
|
)
|
|
if len(matches) >= max_results:
|
|
truncated = True
|
|
break
|
|
return matches, truncated
|