Class: AgentSandbox::Backends::E2B

Inherits:
Object
  • Object
show all
Defined in:
lib/agent_sandbox/backends/e2b.rb

Overview

E2B cloud backend. Docs: e2b.dev/docs

Uses E2B’s control plane (api.e2b.app) for sandbox lifecycle, and the per-sandbox ‘envd` daemon for file + process operations.

Status of endpoints:

create/kill  -> REST (well-documented)
files        -> envd /files (documented in envd OpenAPI)
exec         -> Connect-RPC over HTTP (best-effort; flip to the official
                Ruby SDK when it exists)

Constant Summary collapse

CONTROL_PLANE =
"https://api.e2b.app".freeze
DEFAULT_OPEN_TIMEOUT =
10
DEFAULT_READ_TIMEOUT =
120
DEFAULT_MAX_RETRIES =

Extra attempts beyond the initial one. max_retries=3 => up to 4 total HTTP round trips on persistent failure.

3
RETRY_BACKOFF_BASE =
0.5
RETRIABLE_STATUSES =
[502, 503, 504].freeze
IDEMPOTENT_METHODS =
%w[GET HEAD DELETE].freeze
SANDBOX_NOT_FOUND_MARKERS =

E2B’s control-plane 404 responses for deleted/missing sandboxes return JSON with sandbox-specific identifying content. Substrings we accept inside provider-structured fields only — not anywhere in an arbitrary response body — so a bare 404 from path drift, a proxy, or an intermediate error page cannot fool stop into clearing the handle to a still-running, still-billing sandbox.

[
  "sandbox not found",
  "sandbox does not exist",
  "sandbox was not found",
  "no such sandbox"
].freeze
SUPPORTED =

E2B’s envd delivers processes through a server-streaming Connect RPC, so true fire-and-forget requires tagging the process + disconnecting via the Connect/StreamInput pair. Advertise the gap via ‘supports?` so `Sandbox#spawn` can reject before provisioning a remote sandbox.

%i[exec write_file read_file port_url].freeze

Instance Method Summary collapse

Constructor Details

#initialize(template: "base", api_key: , timeout: 3600, metadata: {}, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES) ⇒ E2B

Returns a new instance of E2B.

Raises:



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/agent_sandbox/backends/e2b.rb', line 33

def initialize(template: "base", api_key: ENV["E2B_API_KEY"], timeout: 3600,
               metadata: {}, open_timeout: DEFAULT_OPEN_TIMEOUT,
               read_timeout: DEFAULT_READ_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES)
  raise AuthError, "E2B_API_KEY not set" if api_key.nil? || api_key.empty?
  @api_key = api_key
  @template = template
  @timeout = timeout
  @metadata = 
  @open_timeout = open_timeout
  @read_timeout = read_timeout
  @max_retries = max_retries
  @sandbox_id = nil
  @envd_domain = nil
  @access_token = nil
end

Instance Method Details

#consume_exec_stream(bytes) ⇒ Object

Raises:



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
# File 'lib/agent_sandbox/backends/e2b.rb', line 155

def consume_exec_stream(bytes)
  stdout = +""
  stderr = +""
  exit_status = nil
  saw_end = false
  saw_trailer = false

  parse_frames(bytes) do |kind, frame|
    if kind == :trailer
      saw_trailer = true
      if frame.is_a?(Hash) && frame["error"]
        raise Error, "envd stream error: #{frame["error"].inspect}"
      end
      next
    end
    event = frame["event"]
    next unless event
    if (data = event["data"])
      stdout << Base64.decode64(data["stdout"]) if data["stdout"]
      stderr << Base64.decode64(data["stderr"]) if data["stderr"]
    elsif (ending = event["end"])
      saw_end = true
      exit_status = parse_exit_status(ending["status"]) if ending["status"]
    end
  end

  raise Error, "envd exec stream ended without trailer" unless saw_trailer
  raise Error, "envd exec missing end event" unless saw_end
  raise Error, "envd end event missing exit status" if exit_status.nil?

  ExecResult.new(stdout: stdout, stderr: stderr, status: exit_status)
end

#exec(command) ⇒ Object

envd’s process.Process/Start is a Connect-RPC server-streaming method. Wire: framed body (5B header + JSON), response is a stream of frames with start/data/end events. stdout/stderr come base64-encoded. The final frame (flag 0x02) is the end-of-stream trailer: ‘{}` for success or `{…}` when envd failed.



142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/agent_sandbox/backends/e2b.rb', line 142

def exec(command)
  uri = envd_uri("/process.Process/Start")
  req = Net::HTTP::Post.new(uri)
  apply_envd_headers(req)
  req["Content-Type"] = "application/connect+json"
  req["Connect-Protocol-Version"] = "1"
  payload = JSON.generate(process: { cmd: "sh", args: ["-c", command] })
  req.body = pack_frame(payload)

  response = perform_request(uri, req, what: "envd exec")
  consume_exec_stream(response.body)
end

#nameObject



49
# File 'lib/agent_sandbox/backends/e2b.rb', line 49

def name = @sandbox_id

#port_url(port) ⇒ Object

Raises:



204
205
206
207
# File 'lib/agent_sandbox/backends/e2b.rb', line 204

def port_url(port)
  raise Error, "start sandbox first" unless @sandbox_id && @envd_domain
  "https://#{port}-#{@sandbox_id}.#{@envd_domain}"
end

#read_file(path) ⇒ Object



130
131
132
133
134
135
# File 'lib/agent_sandbox/backends/e2b.rb', line 130

def read_file(path)
  uri = envd_uri("/files", user: "user", path: path)
  req = Net::HTTP::Get.new(uri)
  apply_envd_headers(req)
  perform_request(uri, req, what: "envd GET /files").body
end

#sandbox_not_found_response?(error) ⇒ Boolean

Structured 404 check: require a JSON body whose provider fields positively identify “this sandbox is gone”. We only trust the ‘code`, `type`, `error`, or `message` fields, not substring matches against the raw body (which would let an HTML proxy page or CDN error containing the word “sandbox” fool us).

Returns:

  • (Boolean)


97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/agent_sandbox/backends/e2b.rb', line 97

def sandbox_not_found_response?(error)
  return false unless error.status == 404
  body = error.body.to_s
  return false if body.empty?
  parsed = begin
    JSON.parse(body)
  rescue JSON::ParserError
    return false # non-JSON = not an E2B control-plane response
  end
  return false unless parsed.is_a?(Hash)

  code = parsed["code"].to_s.downcase
  type = parsed["type"].to_s.downcase
  return true if code.include?("sandbox") && (code.include?("not_found") || code.include?("not found") || code.include?("missing"))
  return true if type.include?("sandbox") && (type.include?("not_found") || type.include?("not found") || type.include?("missing"))

  %w[message error].each do |field|
    v = parsed[field].to_s.downcase
    next if v.empty?
    return true if SANDBOX_NOT_FOUND_MARKERS.any? { |m| v.include?(m) }
  end
  false
end

#spawn(_command) ⇒ Object

Backend-direct callers (bypassing Sandbox) still deserve a proper AgentSandbox::Error — NOT NotImplementedError, which is a ScriptError and slips past the library’s rescue paths.



198
199
200
201
202
# File 'lib/agent_sandbox/backends/e2b.rb', line 198

def spawn(_command)
  raise UnsupportedOperation,
        "E2B backend does not support spawn yet (needs tagged Connect RPC). " \
        "Use exec for now, or run long-lived processes through the Docker backend."
end

#startObject



51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/agent_sandbox/backends/e2b.rb', line 51

def start
  if @sandbox_id
    # Refuse to provision a second sandbox while a previous one is
    # still unresolved — otherwise a failed stop + restart orphans the
    # first sandbox (and keeps billing it) while losing its handle.
    raise Error, "previous sandbox #{@sandbox_id} still tracked; call stop again or abandon this backend instance"
  end
  body = { templateID: @template, timeout: @timeout, metadata: @metadata, secure: true }
  res = control_request(:post, "/sandboxes", body: body)
  @sandbox_id = res.fetch("sandboxID")
  @envd_domain = res["domain"] || "e2b.app"
  @access_token = res["envdAccessToken"]
  self
end

#stopObject



79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/agent_sandbox/backends/e2b.rb', line 79

def stop
  return unless @sandbox_id
  begin
    control_request(:delete, "/sandboxes/#{@sandbox_id}", expect_json: false)
  rescue SandboxNotFound => e
    raise unless sandbox_not_found_response?(e)
  end
  # Only clear @sandbox_id once we've confirmed the sandbox is gone
  # (2xx or validated 404). Other failures keep it set so start()
  # can refuse rather than orphan the remote sandbox.
  @sandbox_id = nil
end

#supports?(capability) ⇒ Boolean

Returns:

  • (Boolean)


193
# File 'lib/agent_sandbox/backends/e2b.rb', line 193

def supports?(capability) = SUPPORTED.include?(capability)

#write_file(path, content) ⇒ Object



121
122
123
124
125
126
127
128
# File 'lib/agent_sandbox/backends/e2b.rb', line 121

def write_file(path, content)
  uri = envd_uri("/files", user: "user", path: path)
  req = Net::HTTP::Post.new(uri)
  apply_envd_headers(req)
  req["Content-Type"] = "application/octet-stream"
  req.body = content
  perform_request(uri, req, what: "envd POST /files")
end