e2b 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,485 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "ostruct"
7
+
8
+ module E2B
9
+ module Services
10
+ # Base class for sandbox services
11
+ #
12
+ # E2B sandboxes expose services through the envd daemon on port 49983.
13
+ # This base class handles communication with that daemon using the
14
+ # Connect RPC protocol (gRPC-over-HTTP with JSON encoding).
15
+ class BaseService
16
+ # Default envd port
17
+ ENVD_PORT = 49983
18
+
19
+ # @param sandbox_id [String] Sandbox ID
20
+ # @param sandbox_domain [String] Sandbox domain (e.g., "e2b.app")
21
+ # @param api_key [String] API key for authentication
22
+ # @param access_token [String, nil] Sandbox-specific access token
23
+ # @param logger [Logger, nil] Optional logger
24
+ def initialize(sandbox_id:, sandbox_domain:, api_key:, access_token: nil, logger: nil)
25
+ @sandbox_id = sandbox_id
26
+ @sandbox_domain = sandbox_domain
27
+ @api_key = api_key
28
+ @access_token = access_token
29
+ @logger = logger
30
+ @envd_client = nil
31
+ end
32
+
33
+ protected
34
+
35
+ # Get the envd HTTP client for this sandbox
36
+ #
37
+ # @return [EnvdHttpClient]
38
+ def envd_client
39
+ @envd_client ||= build_envd_client
40
+ end
41
+
42
+ # Perform GET request to envd
43
+ def envd_get(path, params: {}, timeout: 120)
44
+ envd_client.get(path, params: params, timeout: timeout)
45
+ end
46
+
47
+ # Perform POST request to envd
48
+ def envd_post(path, body: nil, timeout: 120)
49
+ envd_client.post(path, body: body, timeout: timeout)
50
+ end
51
+
52
+ # Perform DELETE request to envd
53
+ def envd_delete(path, timeout: 120)
54
+ envd_client.delete(path, timeout: timeout)
55
+ end
56
+
57
+ # Perform Connect RPC call to envd
58
+ #
59
+ # @param service [String] Service name (e.g., "process.Process")
60
+ # @param method [String] Method name (e.g., "Start")
61
+ # @param body [Hash] Request body
62
+ # @param timeout [Integer] Request timeout in seconds
63
+ # @param on_event [Proc, nil] Callback for streaming events
64
+ # @return [Hash] Response with :events, :stdout, :stderr, :exit_code
65
+ def envd_rpc(service, method, body: {}, timeout: 120, on_event: nil)
66
+ envd_client.rpc(service, method, body: body, timeout: timeout, on_event: on_event)
67
+ end
68
+
69
+ private
70
+
71
+ def build_envd_client
72
+ envd_url = "https://#{ENVD_PORT}-#{@sandbox_id}.#{@sandbox_domain}"
73
+
74
+ EnvdHttpClient.new(
75
+ base_url: envd_url,
76
+ api_key: @api_key,
77
+ access_token: @access_token,
78
+ sandbox_id: @sandbox_id,
79
+ logger: @logger
80
+ )
81
+ end
82
+ end
83
+
84
+ # HTTP client for envd daemon communication
85
+ #
86
+ # Handles both standard HTTP requests and Connect RPC protocol calls.
87
+ # Connect RPC uses a binary envelope format: 1 byte flags + 4 bytes
88
+ # big-endian length + JSON message body.
89
+ class EnvdHttpClient
90
+ DEFAULT_TIMEOUT = 120
91
+
92
+ def initialize(base_url:, api_key:, access_token: nil, sandbox_id:, logger: nil)
93
+ @base_url = base_url.end_with?("/") ? base_url : "#{base_url}/"
94
+ @api_key = api_key
95
+ @access_token = access_token
96
+ @sandbox_id = sandbox_id
97
+ @logger = logger
98
+ @connection = build_connection
99
+ end
100
+
101
+ def get(path, params: {}, timeout: DEFAULT_TIMEOUT)
102
+ handle_response do
103
+ @connection.get(normalize_path(path)) do |req|
104
+ req.params = params
105
+ req.options.timeout = timeout
106
+ end
107
+ end
108
+ end
109
+
110
+ def post(path, body: nil, timeout: DEFAULT_TIMEOUT)
111
+ handle_response do
112
+ @connection.post(normalize_path(path)) do |req|
113
+ req.body = body.to_json if body
114
+ req.options.timeout = timeout
115
+ end
116
+ end
117
+ end
118
+
119
+ def delete(path, timeout: DEFAULT_TIMEOUT)
120
+ handle_response do
121
+ @connection.delete(normalize_path(path)) do |req|
122
+ req.options.timeout = timeout
123
+ end
124
+ end
125
+ end
126
+
127
+ # Connect RPC call with streaming support
128
+ #
129
+ # @param service [String] Service name
130
+ # @param method [String] Method name
131
+ # @param body [Hash] Request body
132
+ # @param timeout [Integer] Timeout in seconds
133
+ # @param on_event [Proc, nil] Callback for streaming events
134
+ # @return [Hash] Response
135
+ def rpc(service, method, body: {}, timeout: DEFAULT_TIMEOUT, on_event: nil)
136
+ path = "/#{service}/#{method}"
137
+ json_body = body.to_json
138
+ envelope = create_connect_envelope(json_body)
139
+
140
+ log_debug("RPC #{service}/#{method}")
141
+
142
+ if on_event
143
+ return handle_streaming_rpc(path, envelope, timeout, on_event)
144
+ end
145
+
146
+ handle_rpc_response(service, method) do
147
+ with_retry("RPC #{service}/#{method}") do
148
+ url = URI.parse("#{@base_url.chomp('/')}#{path}")
149
+ http = build_http(url, timeout)
150
+
151
+ request = Net::HTTP::Post.new(url.request_uri)
152
+ request["Content-Type"] = "application/connect+json"
153
+ request["X-Access-Token"] = @access_token if @access_token
154
+ request["Connection"] = "keep-alive"
155
+ request.body = envelope
156
+
157
+ response = http.request(request)
158
+
159
+ OpenStruct.new(
160
+ status: response.code.to_i,
161
+ success?: response.code.to_i >= 200 && response.code.to_i < 300,
162
+ body: response.body,
163
+ headers: response.to_hash
164
+ )
165
+ end
166
+ end
167
+ end
168
+
169
+ # Streaming RPC with chunked response processing
170
+ def handle_streaming_rpc(path, envelope, timeout, on_event)
171
+ result = { events: [], stdout: "", stderr: "", exit_code: nil }
172
+ buffer = "".b
173
+
174
+ url = URI.parse("#{@base_url.chomp('/')}#{path}")
175
+
176
+ with_retry("Streaming RPC #{path}") do
177
+ http = build_http(url, timeout)
178
+
179
+ request = Net::HTTP::Post.new(url.request_uri)
180
+ request["Content-Type"] = "application/connect+json"
181
+ request["X-Access-Token"] = @access_token if @access_token
182
+ request["Connection"] = "keep-alive"
183
+ request.body = envelope
184
+
185
+ http.start do |conn|
186
+ conn.request(request) do |response|
187
+ unless response.code.to_i.between?(200, 299)
188
+ body = response.body
189
+ handle_error(OpenStruct.new(status: response.code.to_i, success?: false, body: body, headers: response.to_hash))
190
+ end
191
+
192
+ response.read_body do |chunk|
193
+ next if chunk.nil? || chunk.empty?
194
+ buffer << chunk
195
+
196
+ while buffer.bytesize >= 5
197
+ flags = buffer.getbyte(0)
198
+ length = buffer.byteslice(1, 4).unpack1("N")
199
+
200
+ break if length.nil? || buffer.bytesize < 5 + length
201
+
202
+ message_bytes = buffer.byteslice(5, length)
203
+ buffer = buffer.byteslice(5 + length..-1) || "".b
204
+
205
+ next if message_bytes.nil? || message_bytes.empty?
206
+
207
+ message_str = message_bytes.force_encoding("UTF-8")
208
+
209
+ begin
210
+ msg = JSON.parse(message_str)
211
+ msg = msg["result"] if msg["result"]
212
+
213
+ result[:events] << msg
214
+
215
+ stdout_data = nil
216
+ stderr_data = nil
217
+
218
+ if msg["event"]
219
+ event = msg["event"]
220
+
221
+ data_event = event["Data"] || event["data"]
222
+ if data_event
223
+ stdout_data = decode_base64(data_event["stdout"]) if data_event["stdout"]
224
+ stderr_data = decode_base64(data_event["stderr"]) if data_event["stderr"]
225
+ result[:stdout] += stdout_data if stdout_data
226
+ result[:stderr] += stderr_data if stderr_data
227
+ end
228
+
229
+ end_event = event["End"] || event["end"]
230
+ if end_event
231
+ result[:exit_code] = parse_exit_code(end_event["exitCode"] || end_event["exit_code"] || end_event["status"])
232
+ end
233
+ end
234
+
235
+ if msg["stdout"]
236
+ stdout_data = decode_base64(msg["stdout"])
237
+ result[:stdout] += stdout_data
238
+ end
239
+ if msg["stderr"]
240
+ stderr_data = decode_base64(msg["stderr"])
241
+ result[:stderr] += stderr_data
242
+ end
243
+ if msg["exitCode"] || msg["exit_code"]
244
+ result[:exit_code] = parse_exit_code(msg["exitCode"] || msg["exit_code"])
245
+ end
246
+
247
+ on_event.call(
248
+ stdout: stdout_data,
249
+ stderr: stderr_data,
250
+ exit_code: result[:exit_code],
251
+ event: msg
252
+ )
253
+ rescue JSON::ParserError
254
+ # Skip unparseable messages
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
260
+ end
261
+
262
+ result
263
+ end
264
+
265
+ private
266
+
267
+ def normalize_path(path)
268
+ path.to_s.sub(%r{^/+}, "")
269
+ end
270
+
271
+ def build_connection
272
+ ssl_verify = ENV.fetch("E2B_SSL_VERIFY", "true").downcase != "false"
273
+
274
+ Faraday.new(url: @base_url, ssl: { verify: ssl_verify }) do |conn|
275
+ conn.request :json
276
+ conn.response :json, content_type: /\bjson$/
277
+ conn.adapter Faraday.default_adapter
278
+
279
+ conn.headers["E2b-Sandbox-Id"] = @sandbox_id
280
+ conn.headers["E2b-Sandbox-Port"] = "#{BaseService::ENVD_PORT}"
281
+ conn.headers["X-API-Key"] = @api_key
282
+ conn.headers["X-Access-Token"] = @access_token if @access_token
283
+ conn.headers["Content-Type"] = "application/json"
284
+ conn.headers["Accept"] = "application/json"
285
+ conn.headers["User-Agent"] = "e2b-ruby-sdk/#{E2B::VERSION}"
286
+ end
287
+ end
288
+
289
+ def build_http(url, timeout)
290
+ http = Net::HTTP.new(url.host, url.port)
291
+ http.use_ssl = true
292
+ http.open_timeout = 30
293
+ http.read_timeout = timeout
294
+ http.keep_alive_timeout = 30
295
+
296
+ ssl_verify = ENV.fetch("E2B_SSL_VERIFY", "true").downcase != "false"
297
+ http.verify_mode = ssl_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
298
+
299
+ http
300
+ end
301
+
302
+ def with_retry(operation, max_retries: 3)
303
+ retry_count = 0
304
+
305
+ begin
306
+ yield
307
+ rescue OpenSSL::SSL::SSLError, Errno::ECONNRESET, EOFError, Net::OpenTimeout, Net::ReadTimeout => e
308
+ retry_count += 1
309
+
310
+ if retry_count <= max_retries
311
+ sleep_time = 2**retry_count
312
+ log_debug("#{operation}: retry #{retry_count}/#{max_retries} after #{e.class}: #{e.message}")
313
+ sleep(sleep_time)
314
+ retry
315
+ else
316
+ raise E2B::E2BError, "#{operation} failed after #{max_retries} retries: #{e.message}"
317
+ end
318
+ end
319
+ end
320
+
321
+ def handle_response
322
+ response = yield
323
+ handle_error(response) unless response.success?
324
+
325
+ body = response.body
326
+
327
+ if body.is_a?(String) && !body.empty?
328
+ content_type = response.headers["content-type"] rescue "unknown"
329
+ if content_type&.include?("json") || body.start_with?("{", "[")
330
+ begin
331
+ return JSON.parse(body)
332
+ rescue JSON::ParserError
333
+ # Return as-is
334
+ end
335
+ end
336
+ end
337
+
338
+ body
339
+ rescue Faraday::TimeoutError => e
340
+ raise E2B::TimeoutError, "Request timed out: #{e.message}"
341
+ rescue Faraday::ConnectionFailed => e
342
+ raise E2B::E2BError, "Connection to sandbox failed: #{e.message}"
343
+ end
344
+
345
+ def handle_rpc_response(service, method)
346
+ response = yield
347
+
348
+ handle_error(response) unless response.success?
349
+
350
+ body = response.body
351
+ return {} if body.nil? || body.empty?
352
+
353
+ result = { events: [], stdout: "", stderr: "", exit_code: nil }
354
+
355
+ messages = parse_connect_stream(body)
356
+
357
+ messages.each do |msg_str|
358
+ begin
359
+ msg = JSON.parse(msg_str)
360
+ msg = msg["result"] if msg["result"]
361
+
362
+ result[:events] << msg
363
+
364
+ if msg["event"]
365
+ event = msg["event"]
366
+
367
+ data_event = event["Data"] || event["data"]
368
+ if data_event
369
+ result[:stdout] += decode_base64(data_event["stdout"]) if data_event["stdout"]
370
+ result[:stderr] += decode_base64(data_event["stderr"]) if data_event["stderr"]
371
+ end
372
+
373
+ end_event = event["End"] || event["end"]
374
+ if end_event
375
+ exit_value = end_event["exitCode"] || end_event["exit_code"] || end_event["status"]
376
+ result[:exit_code] = parse_exit_code(exit_value)
377
+ end
378
+ end
379
+
380
+ result[:stdout] += decode_base64(msg["stdout"]) if msg["stdout"]
381
+ result[:stderr] += decode_base64(msg["stderr"]) if msg["stderr"]
382
+ if msg["exitCode"] || msg["exit_code"]
383
+ result[:exit_code] = parse_exit_code(msg["exitCode"] || msg["exit_code"])
384
+ end
385
+ rescue JSON::ParserError
386
+ # Skip unparseable messages
387
+ end
388
+ end
389
+
390
+ result
391
+ rescue Faraday::TimeoutError => e
392
+ raise E2B::TimeoutError, "Request timed out: #{e.message}"
393
+ rescue Faraday::ConnectionFailed => e
394
+ raise E2B::E2BError, "Connection to sandbox failed: #{e.message}"
395
+ end
396
+
397
+ def parse_connect_stream(body)
398
+ messages = []
399
+
400
+ # Try binary Connect envelope format first
401
+ if body.bytes.first(1) == [0] && body.bytesize >= 5
402
+ offset = 0
403
+ while offset + 5 <= body.bytesize
404
+ length = body.byteslice(offset + 1, 4).unpack1("N")
405
+ break if length.nil? || offset + 5 + length > body.bytesize
406
+
407
+ message = body.byteslice(offset + 5, length)
408
+ messages << message.force_encoding("UTF-8") if message && !message.empty?
409
+ offset += 5 + length
410
+ end
411
+
412
+ return messages if messages.any?
413
+ end
414
+
415
+ # Fall back to NDJSON
416
+ body.each_line do |line|
417
+ line = line.strip
418
+ messages << line unless line.empty?
419
+ end
420
+
421
+ messages << body if messages.empty? && body.start_with?("{")
422
+
423
+ messages
424
+ end
425
+
426
+ def create_connect_envelope(json_message)
427
+ flags = "\x00".b
428
+ length = [json_message.bytesize].pack("N")
429
+ flags + length + json_message
430
+ end
431
+
432
+ def parse_exit_code(value)
433
+ return 0 if value.nil?
434
+ return value if value.is_a?(Integer)
435
+
436
+ str = value.to_s
437
+ if str =~ /exit status (\d+)/i
438
+ $1.to_i
439
+ elsif str =~ /^(\d+)$/
440
+ $1.to_i
441
+ else
442
+ str.include?("0") ? 0 : 1
443
+ end
444
+ end
445
+
446
+ def decode_base64(data)
447
+ return "" if data.nil? || data.empty?
448
+
449
+ Base64.decode64(data)
450
+ rescue StandardError
451
+ data.to_s
452
+ end
453
+
454
+ def handle_error(response)
455
+ message = extract_error_message(response)
456
+ status = response.status
457
+ headers = response.headers.to_h
458
+
459
+ case status
460
+ when 401, 403
461
+ raise E2B::AuthenticationError.new(message, status_code: status, headers: headers)
462
+ when 404
463
+ raise E2B::NotFoundError.new(message, status_code: status, headers: headers)
464
+ when 429
465
+ raise E2B::RateLimitError.new(message, status_code: status, headers: headers)
466
+ else
467
+ raise E2B::E2BError.new(message, status_code: status, headers: headers)
468
+ end
469
+ end
470
+
471
+ def extract_error_message(response)
472
+ body = response.body
473
+ return body["message"] if body.is_a?(Hash) && body["message"]
474
+ return body["error"] if body.is_a?(Hash) && body["error"]
475
+ return body.to_s if body.is_a?(String) && !body.empty?
476
+
477
+ "HTTP #{response.status} error"
478
+ end
479
+
480
+ def log_debug(message)
481
+ @logger&.debug("[E2B] #{message}")
482
+ end
483
+ end
484
+ end
485
+ end