mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 14:06:18 +00:00
* fix(sandbox): report an exactly-full search result as complete in the remote providers `glob` and `grep` decide `truncated` twice: once for the raw output cap (`parse_remote_search_output`, unchanged) and once for `max_results` after the Python-side filters have run. The second decision returned as soon as `max_results` matches had been collected, which cannot tell a search that held exactly that many from one that held more — a tree holding exactly `max_results` eligible matches came back flagged as cut off, and the tool then told the model the result was incomplete. These providers hold the whole listing (the raw stream is capped at `max(max_results * 4, max_results + 50)` lines and reports its own cut-off), so like AIO's `glob` branches they can look one match past the cap before deciding: `AioSandbox.grep`, plus `glob`/`grep` in E2B, OpenSandbox, Tenki and BoxLite now use the same `len(matches) > max_results` rule. This completes what #5449 started for AIO's `glob`; the local provider's half is #5491. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix(sandbox): let remote grep see one match past the per-file cap E2B and OpenSandbox stopped each file's grep at max(max_results, 50) matches, so a single file holding more than max_results hits — with a raw stream far below its limit — ended the Python loop exactly at the cap and reported the result as complete (#5534 review). Retain one extra match per file so the one-match lookahead can observe the overflow and report truncation. A single-file regression at max_results=50 covers 50 matches (complete) vs 51 (truncated) for both providers. Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
537 lines
22 KiB
Python
537 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)
|
|
# Look one match past the cap before deciding: returning on the
|
|
# max-th match cannot tell a search that held exactly
|
|
# ``max_results`` from one that held more, so an exhausted tree
|
|
# was reported as truncated.
|
|
if len(matches) > max_results:
|
|
return matches[:max_results], 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 + 1, 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),
|
|
)
|
|
)
|
|
# Same one-match-past-the-cap rule as glob() above.
|
|
if len(matches) > max_results:
|
|
return matches[:max_results], True
|
|
return matches, truncated
|