mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 10:56:02 +00:00
* fix read_file range validation and passthrough * chore: format test_aio_sandbox.py * test: cover e2b sandbox read_file line-range behavior * fix(boxlite): support ranged file reads * fix(sandbox): reject non-positive start lines --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
503 lines
20 KiB
Python
503 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import errno
|
|
import logging
|
|
import re
|
|
import shlex
|
|
import threading
|
|
|
|
from e2b_code_interpreter import Sandbox as E2BClientSandbox
|
|
|
|
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
|
|
from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env
|
|
from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line
|
|
|
|
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`.
|
|
"""
|
|
|
|
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
|
|
|
|
# ── 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) and not output:
|
|
output = 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:
|
|
return []
|
|
try:
|
|
result = client.commands.run(f"find {shlex.quote(resolved)} -maxdepth {int(max_depth)} \\( -type f -o -type d \\) 2>/dev/null | head -500")
|
|
output = getattr(result, "stdout", "") or ""
|
|
return [line.strip() for line in output.splitlines() if line.strip()]
|
|
except Exception as e:
|
|
logger.error("Failed to list_dir %s in e2b sandbox: %s", resolved, e)
|
|
return []
|
|
|
|
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")
|
|
try:
|
|
if append:
|
|
existing = ""
|
|
try:
|
|
existing = client.files.read(resolved) or ""
|
|
if isinstance(existing, bytes):
|
|
existing = existing.decode("utf-8", errors="replace")
|
|
except Exception:
|
|
existing = ""
|
|
content = (existing or "") + content
|
|
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"
|
|
with self._lock:
|
|
client = self._client
|
|
if client is None:
|
|
return [], False
|
|
try:
|
|
hard_limit = max(max_results * 4, max_results + 50)
|
|
cmd = f"find {shlex.quote(resolved)} \\( " + " -o ".join(f"-type {t}" for t in types.split(",")) + f" \\) -print 2>/dev/null | head -{hard_limit}"
|
|
result = client.commands.run(cmd)
|
|
output = getattr(result, "stdout", "") or ""
|
|
except Exception as e:
|
|
logger.error("Failed to glob in e2b sandbox: %s", e)
|
|
return [], False
|
|
|
|
matches: list[str] = []
|
|
root = resolved.rstrip("/") or "/"
|
|
root_prefix = root if root == "/" else f"{root}/"
|
|
for entry in output.splitlines():
|
|
entry = entry.strip()
|
|
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, False
|
|
|
|
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}")
|
|
|
|
cmd = "grep " + " ".join(flags) + f" -- {shlex.quote(regex_source)} {shlex.quote(resolved)} 2>/dev/null" + f" | head -{total_cap}"
|
|
|
|
with self._lock:
|
|
client = self._client
|
|
if client is None:
|
|
return [], False
|
|
try:
|
|
result = client.commands.run(cmd)
|
|
output = getattr(result, "stdout", "") or ""
|
|
except Exception as e:
|
|
logger.error("Failed to grep in e2b sandbox: %s", e)
|
|
return [], False
|
|
|
|
root = resolved.rstrip("/") or "/"
|
|
root_prefix = root if root == "/" else f"{root}/"
|
|
|
|
matches: list[GrepMatch] = []
|
|
truncated = False
|
|
for raw in output.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
|