Minh Vu 904cee4a72
fix(sandbox): push read_file ranges into sandbox reads (#3824)
* 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>
2026-07-30 07:53:10 +08:00

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