Class: AgentSandbox::Backends::E2B
- Inherits:
-
Object
- Object
- AgentSandbox::Backends::E2B
- 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
- #consume_exec_stream(bytes) ⇒ Object
-
#exec(command) ⇒ Object
envd’s process.Process/Start is a Connect-RPC server-streaming method.
-
#initialize(template: "base", api_key: , timeout: 3600, metadata: {}, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES) ⇒ E2B
constructor
A new instance of E2B.
- #name ⇒ Object
- #port_url(port) ⇒ Object
- #read_file(path) ⇒ Object
-
#sandbox_not_found_response?(error) ⇒ Boolean
Structured 404 check: require a JSON body whose provider fields positively identify “this sandbox is gone”.
-
#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.
- #start ⇒ Object
- #stop ⇒ Object
- #supports?(capability) ⇒ Boolean
- #write_file(path, content) ⇒ Object
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.
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
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 |
#name ⇒ Object
49 |
# File 'lib/agent_sandbox/backends/e2b.rb', line 49 def name = @sandbox_id |
#port_url(port) ⇒ Object
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).
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 |
#start ⇒ Object
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 |
#stop ⇒ Object
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
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 |