Jeremy Schoemaker 6b4f803354
fix(sandbox): preserve trailing whitespace in filenames from list_dir and glob in remote providers (#4980)
* fix(sandbox): stop stripping filenames when parsing find output in remote providers

The list_dir and glob parsers in the e2b, OpenSandbox, AIO, Tenki, and
BoxLite providers called .strip() on every line of find output. A
filename that legitimately ends (or begins) in whitespace was corrupted,
so the listed path never resolved on any follow-up file API call, and
the remote providers diverged from LocalSandbox, which preserves such
names via pathlib.

splitlines() already removes the line terminators, so filter empty lines
only and keep each entry verbatim. Same class of bug as the e2b
_sync_outputs_to_host fix (#4861), applied to the search parsers.

Adds a trailing-space regression test per provider at the seam each
suite already uses.

* fix(sandbox): split find output on \n only, and rename the tenki test

Review follow-ups from willem-bd:

- aio_sandbox.list_dir used str.splitlines(), which also breaks records on
  \v, \f, \x1c-\x1e and \x85 - all legal inside a Linux filename, and all
  contrary to this PR's own rule that the newline is the only delimiter.
  find emits \n and nothing else, so split("\n") is the correct parse.
- Renamed test_search_preserves_trailing_space_in_filename to
  test_list_dir_and_glob_preserve_trailing_space_in_filename, matching the
  sibling tests in test_opensandbox_provider.py and test_boxlite_provider.py.
  The body covers list_dir and glob; it never touches grep.
2026-09-02 09:23:04 +08:00

514 lines
21 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`.
"""
#: 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
# ── 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:
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 ""
# splitlines() already removed the terminators; do NOT strip
# entries — a filename that legitimately ends in whitespace
# would be corrupted and never resolve again.
return [line for line in output.splitlines() if line]
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():
# 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, 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