Spaces:
Running
Running
File size: 5,897 Bytes
70f2179 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 | # Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.
"""E2B implementation of :class:`SandboxBackend`."""
from __future__ import annotations
import os
import threading
from pathlib import PurePosixPath
from e2b import Sandbox
from e2b.sandbox_sync.commands.command_handle import CommandHandle
from .base import BgJob, ExecResult, SandboxBackend, SandboxHandle
class E2BBgJob:
"""Wraps an E2B ``CommandHandle`` to satisfy :class:`BgJob`.
The E2B SDK's ``CommandHandle.wait()`` blocks indefinitely with no native
timeout. We poll in a worker thread and raise ``TimeoutError`` if the
process does not exit within the caller-supplied budget.
"""
def __init__(self, handle: CommandHandle) -> None:
self._handle = handle
self._result: "object | None" = None
self._error: BaseException | None = None
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def _run(self) -> None:
try:
self._result = self._handle.wait()
except BaseException as exc: # noqa: BLE001
self._error = exc
@property
def pid(self) -> int:
return self._handle.pid
def wait(self, timeout: float | None = None) -> int:
self._thread.join(timeout)
if self._thread.is_alive():
raise TimeoutError(
f"Background command did not exit within {timeout}s"
)
if self._error is not None:
# E2B raises CommandExitException on non-zero; treat as exit code.
code = getattr(self._error, "exit_code", None)
if code is None:
raise self._error
return int(code)
return int(self._result.exit_code) if self._result is not None else 0
def kill(self) -> None:
try:
self._handle.kill()
except Exception:
pass
class E2BSandboxHandle:
"""Wraps a live ``e2b.Sandbox`` to satisfy :class:`SandboxHandle`."""
def __init__(self, sandbox: Sandbox) -> None:
self._sbx = sandbox
@property
def sandbox_id(self) -> str:
return self._sbx.sandbox_id
@property
def raw(self) -> Sandbox:
"""Escape hatch for callers that need the underlying SDK object."""
return self._sbx
def exec(
self,
cmd: str,
*,
envs: dict[str, str] | None = None,
cwd: str | None = None,
timeout: float | None = 60,
) -> ExecResult:
from e2b.sandbox.commands.command_handle import CommandExitException
try:
result = self._sbx.commands.run(
cmd,
envs=envs,
cwd=cwd,
timeout=timeout,
background=False,
)
return ExecResult(
exit_code=result.exit_code,
stdout=result.stdout,
stderr=result.stderr,
)
except CommandExitException as exc:
# Non-zero exit codes are expected in many contexts (e.g. polling
# healthz before the server is up). Surface them as a proper
# ExecResult instead of an exception.
return ExecResult(
exit_code=int(getattr(exc, "exit_code", 1)),
stdout=str(getattr(exc, "stdout", "") or ""),
stderr=str(getattr(exc, "stderr", "") or str(exc)),
)
def start_bg(
self,
cmd: str,
*,
envs: dict[str, str] | None = None,
cwd: str | None = None,
timeout: float = 0,
) -> BgJob:
"""Start a background command.
``timeout=0`` disables E2B's server-side command deadline (the default
is 60s, which would otherwise kill long-running agent processes).
Sandbox lifetime still bounds the job.
"""
handle = self._sbx.commands.run(
cmd,
envs=envs,
cwd=cwd,
background=True,
timeout=timeout,
)
return E2BBgJob(handle)
def write_text(self, path: str, content: str) -> None:
parent = str(PurePosixPath(path).parent)
if parent not in ("", "/"):
self._sbx.files.make_dir(parent)
self._sbx.files.write(path, content)
def read_text(self, path: str) -> str:
return self._sbx.files.read(path)
def exists(self, path: str) -> bool:
return self._sbx.files.exists(path)
def kill(self) -> None:
self._sbx.kill()
class E2BSandboxBackend:
"""Creates E2B sandboxes for OpenCode rollouts.
The backend uses the E2B default base template unless ``template`` is
provided. Resource sizing and other E2B-specific options can be forwarded
via ``sandbox_kwargs``.
"""
def __init__(
self,
*,
api_key: str | None = None,
template: str | None = None,
sandbox_kwargs: dict | None = None,
) -> None:
self._api_key = api_key or os.environ.get("E2B_API_KEY")
if not self._api_key:
raise RuntimeError(
"E2BSandboxBackend requires an api_key or E2B_API_KEY env var."
)
self._template = template
self._sandbox_kwargs = sandbox_kwargs or {}
def create(
self,
*,
timeout_s: int = 900,
envs: dict[str, str] | None = None,
metadata: dict[str, str] | None = None,
) -> SandboxHandle:
sbx = Sandbox.create(
template=self._template,
timeout=timeout_s,
envs=envs,
metadata=metadata,
api_key=self._api_key,
**self._sandbox_kwargs,
)
return E2BSandboxHandle(sbx)
|