e2b 0.3.3 → 0.3.5

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.
@@ -4,6 +4,7 @@ require "base64"
4
4
  require "net/http"
5
5
  require "openssl"
6
6
  require "rubygems/version"
7
+ require_relative "envd_base64"
7
8
 
8
9
  module E2B
9
10
  module Services
@@ -14,7 +15,7 @@ module E2B
14
15
  # Connect RPC protocol (gRPC-over-HTTP with JSON encoding).
15
16
  class BaseService
16
17
  # Default envd port
17
- ENVD_PORT = 49983
18
+ ENVD_PORT = 49_983
18
19
  DEFAULT_USERNAME = "user"
19
20
  ENVD_DEFAULT_USER_VERSION = Gem::Version.new("0.4.0")
20
21
  ENVD_RECURSIVE_WATCH_VERSION = Gem::Version.new("0.1.4")
@@ -149,7 +150,7 @@ module E2B
149
150
  end
150
151
  end
151
152
 
152
- def initialize(base_url:, api_key:, access_token: nil, sandbox_id:, logger: nil)
153
+ def initialize(base_url:, api_key:, sandbox_id:, access_token: nil, logger: nil)
153
154
  @base_url = base_url.end_with?("/") ? base_url : "#{base_url}/"
154
155
  @api_key = api_key
155
156
  @access_token = access_token
@@ -202,16 +203,14 @@ module E2B
202
203
 
203
204
  log_debug("RPC #{service}/#{method}")
204
205
 
205
- if on_event
206
- return handle_streaming_rpc(path, envelope, timeout, on_event, headers)
207
- end
206
+ return handle_streaming_rpc(path, envelope, timeout, on_event, headers) if on_event
208
207
 
209
208
  # Unary RPCs: try Connect protocol first, fall back to plain JSON.
210
209
  # Some envd versions (e.g., 0.5.4 on self-hosted) reject
211
210
  # application/connect+json for unary calls but accept application/json.
212
211
  handle_rpc_response(service, method) do
213
212
  with_retry("RPC #{service}/#{method}") do
214
- url = URI.parse("#{@base_url.chomp('/')}#{path}")
213
+ url = URI.parse("#{@base_url.chomp("/")}#{path}")
215
214
  http = build_http(url, timeout)
216
215
 
217
216
  request = Net::HTTP::Post.new(url.request_uri)
@@ -250,17 +249,35 @@ module E2B
250
249
  # inherit proxy configuration and SSL settings. The streaming is handled
251
250
  # via Faraday's on_data callback for chunked response processing.
252
251
  #
253
- # Streaming RPCs are NOT idempotent (e.g. process.Process/Start spawns a
254
- # process), so we only retry while no events have been emitted to the
255
- # caller yet. Once any byte has been delivered via on_event, a retry
256
- # would replay output AND start a second process server-side.
252
+ # Retry policy:
253
+ #
254
+ # * `process.Process/Start` is **never retried**. The POST may have
255
+ # already reached envd and triggered a process spawn before the
256
+ # transport error surfaced; retrying would race a second process
257
+ # against the first. We've seen `git clone` fail with "destination
258
+ # already exists" exactly this way — first attempt spawns a process
259
+ # that mkdir's the target, transport blips, retry spawns process #2
260
+ # that finds the target non-empty. Caller decides what to do.
261
+ #
262
+ # * Other streaming paths (`Connect` re-attach, etc.) retry while no
263
+ # events have been emitted yet. Once any byte has been delivered
264
+ # via on_event, a retry would replay output to the caller, so we
265
+ # abort.
257
266
  def handle_streaming_rpc(path, envelope, timeout, on_event, headers)
258
267
  result = { events: [], stdout: "", stderr: "", exit_code: nil }
259
268
  buffer = "".b
260
269
 
261
270
  full_path = normalize_path(path)
271
+ non_idempotent = path.end_with?("/Start")
272
+
273
+ retry_opts =
274
+ if non_idempotent
275
+ { max_retries: 0 }
276
+ else
277
+ { abort_if: -> { result[:events].any? } }
278
+ end
262
279
 
263
- with_retry("Streaming RPC #{path}", abort_if: -> { result[:events].any? }) do
280
+ with_retry("Streaming RPC #{path}", **retry_opts) do
264
281
  ssl_verify = ENV.fetch("E2B_SSL_VERIFY", "true").downcase != "false"
265
282
 
266
283
  streaming_conn = Faraday.new(url: @base_url, ssl: { verify: ssl_verify }) do |conn|
@@ -286,10 +303,11 @@ module E2B
286
303
  req.body = envelope
287
304
  req.options.on_data = proc do |chunk, _overall_size, _env|
288
305
  next if chunk.nil? || chunk.empty?
306
+
289
307
  buffer << chunk
290
308
 
291
309
  while buffer.bytesize >= 5
292
- flags = buffer.getbyte(0)
310
+ buffer.getbyte(0)
293
311
  length = buffer.byteslice(1, 4).unpack1("N")
294
312
 
295
313
  break if length.nil? || buffer.bytesize < 5 + length
@@ -315,29 +333,28 @@ module E2B
315
333
 
316
334
  data_event = event["Data"] || event["data"]
317
335
  if data_event
318
- stdout_data = decode_base64(data_event["stdout"]) if data_event["stdout"]
319
- stderr_data = decode_base64(data_event["stderr"]) if data_event["stderr"]
336
+ stdout_data = EnvdBase64.decode_process_output(data_event["stdout"]) if data_event["stdout"]
337
+ stderr_data = EnvdBase64.decode_process_output(data_event["stderr"]) if data_event["stderr"]
320
338
  result[:stdout] += stdout_data if stdout_data
321
339
  result[:stderr] += stderr_data if stderr_data
322
340
  end
323
341
 
324
342
  end_event = event["End"] || event["end"]
325
343
  if end_event
326
- result[:exit_code] = parse_exit_code(end_event["exitCode"] || end_event["exit_code"] || end_event["status"])
344
+ result[:exit_code] =
345
+ parse_exit_code(end_event["exitCode"] || end_event["exit_code"] || end_event["status"])
327
346
  end
328
347
  end
329
348
 
330
349
  if msg["stdout"]
331
- stdout_data = decode_base64(msg["stdout"])
350
+ stdout_data = EnvdBase64.decode_process_output(msg["stdout"])
332
351
  result[:stdout] += stdout_data
333
352
  end
334
353
  if msg["stderr"]
335
- stderr_data = decode_base64(msg["stderr"])
354
+ stderr_data = EnvdBase64.decode_process_output(msg["stderr"])
336
355
  result[:stderr] += stderr_data
337
356
  end
338
- if msg["exitCode"] || msg["exit_code"]
339
- result[:exit_code] = parse_exit_code(msg["exitCode"] || msg["exit_code"])
340
- end
357
+ result[:exit_code] = parse_exit_code(msg["exitCode"] || msg["exit_code"]) if msg["exitCode"] || msg["exit_code"]
341
358
 
342
359
  on_event.call(
343
360
  stdout: stdout_data,
@@ -352,9 +369,7 @@ module E2B
352
369
  end
353
370
  end
354
371
 
355
- unless response.status.between?(200, 299)
356
- handle_error(response)
357
- end
372
+ handle_error(response) unless response.status.between?(200, 299)
358
373
  end
359
374
 
360
375
  result
@@ -409,13 +424,20 @@ module E2B
409
424
  end
410
425
 
411
426
  def resolve_proxy(url)
412
- no_proxy = ENV["no_proxy"] || ENV["NO_PROXY"]
427
+ no_proxy = ENV["no_proxy"] || ENV.fetch("NO_PROXY", nil)
413
428
  if no_proxy
414
429
  no_proxy_hosts = no_proxy.split(",").map(&:strip)
415
430
  return nil if no_proxy_hosts.any? { |h| url.host.end_with?(h) || h == "*" }
416
431
  end
417
432
 
418
- proxy_env = url.scheme == "https" ? (ENV["https_proxy"] || ENV["HTTPS_PROXY"]) : (ENV["http_proxy"] || ENV["HTTP_PROXY"])
433
+ proxy_env = if url.scheme == "https"
434
+ ENV["https_proxy"] || ENV.fetch("HTTPS_PROXY",
435
+ nil)
436
+ else
437
+ ENV["http_proxy"] || ENV.fetch(
438
+ "HTTP_PROXY", nil
439
+ )
440
+ end
419
441
  return nil unless proxy_env
420
442
 
421
443
  URI.parse(proxy_env)
@@ -436,14 +458,12 @@ module E2B
436
458
 
437
459
  retry_count += 1
438
460
 
439
- if retry_count <= max_retries
440
- sleep_time = 2**retry_count
441
- log_debug("#{operation}: retry #{retry_count}/#{max_retries} after #{e.class}: #{e.message}")
442
- sleep(sleep_time)
443
- retry
444
- else
445
- raise E2B::E2BError, "#{operation} failed after #{max_retries} retries: #{e.message}"
446
- end
461
+ raise E2B::E2BError, "#{operation} failed after #{max_retries} retries: #{e.message}" unless retry_count <= max_retries
462
+
463
+ sleep_time = 2**retry_count
464
+ log_debug("#{operation}: retry #{retry_count}/#{max_retries} after #{e.class}: #{e.message}")
465
+ sleep(sleep_time)
466
+ retry
447
467
  end
448
468
  end
449
469
 
@@ -454,7 +474,11 @@ module E2B
454
474
  body = response.body
455
475
 
456
476
  if body.is_a?(String) && !body.empty?
457
- content_type = response.headers["content-type"] rescue "unknown"
477
+ content_type = begin
478
+ response.headers["content-type"]
479
+ rescue StandardError
480
+ "unknown"
481
+ end
458
482
  if content_type&.include?("json") || body.start_with?("{", "[")
459
483
  begin
460
484
  return JSON.parse(body)
@@ -471,7 +495,7 @@ module E2B
471
495
  raise E2B::E2BError, "Connection to sandbox failed: #{e.message}"
472
496
  end
473
497
 
474
- def handle_rpc_response(service, method)
498
+ def handle_rpc_response(_service, _method)
475
499
  response = yield
476
500
 
477
501
  handle_error(response) unless response.success?
@@ -484,36 +508,32 @@ module E2B
484
508
  messages = parse_connect_stream(body)
485
509
 
486
510
  messages.each do |msg_str|
487
- begin
488
- msg = JSON.parse(msg_str)
489
- msg = msg["result"] if msg["result"]
511
+ msg = JSON.parse(msg_str)
512
+ msg = msg["result"] if msg["result"]
490
513
 
491
- result[:events] << msg
514
+ result[:events] << msg
492
515
 
493
- if msg["event"]
494
- event = msg["event"]
495
-
496
- data_event = event["Data"] || event["data"]
497
- if data_event
498
- result[:stdout] += decode_base64(data_event["stdout"]) if data_event["stdout"]
499
- result[:stderr] += decode_base64(data_event["stderr"]) if data_event["stderr"]
500
- end
516
+ if msg["event"]
517
+ event = msg["event"]
501
518
 
502
- end_event = event["End"] || event["end"]
503
- if end_event
504
- exit_value = end_event["exitCode"] || end_event["exit_code"] || end_event["status"]
505
- result[:exit_code] = parse_exit_code(exit_value)
506
- end
519
+ data_event = event["Data"] || event["data"]
520
+ if data_event
521
+ result[:stdout] += EnvdBase64.decode_process_output(data_event["stdout"]) if data_event["stdout"]
522
+ result[:stderr] += EnvdBase64.decode_process_output(data_event["stderr"]) if data_event["stderr"]
507
523
  end
508
524
 
509
- result[:stdout] += decode_base64(msg["stdout"]) if msg["stdout"]
510
- result[:stderr] += decode_base64(msg["stderr"]) if msg["stderr"]
511
- if msg["exitCode"] || msg["exit_code"]
512
- result[:exit_code] = parse_exit_code(msg["exitCode"] || msg["exit_code"])
525
+ end_event = event["End"] || event["end"]
526
+ if end_event
527
+ exit_value = end_event["exitCode"] || end_event["exit_code"] || end_event["status"]
528
+ result[:exit_code] = parse_exit_code(exit_value)
513
529
  end
514
- rescue JSON::ParserError
515
- # Skip unparseable messages
516
530
  end
531
+
532
+ result[:stdout] += EnvdBase64.decode_process_output(msg["stdout"]) if msg["stdout"]
533
+ result[:stderr] += EnvdBase64.decode_process_output(msg["stderr"]) if msg["stderr"]
534
+ result[:exit_code] = parse_exit_code(msg["exitCode"] || msg["exit_code"]) if msg["exitCode"] || msg["exit_code"]
535
+ rescue JSON::ParserError
536
+ # Skip unparseable messages
517
537
  end
518
538
 
519
539
  result
@@ -568,22 +588,14 @@ module E2B
568
588
 
569
589
  str = value.to_s
570
590
  if str =~ /exit status (\d+)/i
571
- $1.to_i
591
+ ::Regexp.last_match(1).to_i
572
592
  elsif str =~ /^(\d+)$/
573
- $1.to_i
593
+ ::Regexp.last_match(1).to_i
574
594
  else
575
595
  1
576
596
  end
577
597
  end
578
598
 
579
- def decode_base64(data)
580
- return "" if data.nil? || data.empty?
581
-
582
- Base64.decode64(data)
583
- rescue StandardError
584
- data.to_s
585
- end
586
-
587
599
  def handle_error(response)
588
600
  message = extract_error_message(response)
589
601
  status = response.status
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
3
+ require_relative "envd_base64"
4
4
 
5
5
  module E2B
6
6
  module Services
@@ -189,9 +189,7 @@ module E2B
189
189
  # @raise [CommandExitError] if exit code is non-zero
190
190
  def wait(on_stdout: nil, on_stderr: nil, on_pty: nil)
191
191
  consume_events(on_stdout: on_stdout, on_stderr: on_stderr, on_pty: on_pty)
192
- unless @disconnected || completed?
193
- raise E2BError, "Command ended without an end event"
194
- end
192
+ raise E2BError, "Command ended without an end event" unless @disconnected || completed?
195
193
 
196
194
  build_result.tap do |cmd_result|
197
195
  unless cmd_result.success?
@@ -269,15 +267,9 @@ module E2B
269
267
  return if @finished
270
268
 
271
269
  each do |stdout_chunk, stderr_chunk, pty_chunk|
272
- if stdout_chunk
273
- on_stdout&.call(stdout_chunk)
274
- end
275
- if stderr_chunk
276
- on_stderr&.call(stderr_chunk)
277
- end
278
- if pty_chunk
279
- on_pty&.call(pty_chunk)
280
- end
270
+ on_stdout&.call(stdout_chunk) if stdout_chunk
271
+ on_stderr&.call(stderr_chunk) if stderr_chunk
272
+ on_pty&.call(pty_chunk) if pty_chunk
281
273
  end
282
274
 
283
275
  @finished = true
@@ -306,7 +298,7 @@ module E2B
306
298
  #
307
299
  # @yield [stdout, stderr, pty]
308
300
  # @return [void]
309
- def iterate_materialized_events
301
+ def iterate_materialized_events(&block)
310
302
  events = result_value(:events) || []
311
303
  while @materialized_event_index < events.length
312
304
  break if @disconnected
@@ -314,9 +306,7 @@ module E2B
314
306
  event_hash = events[@materialized_event_index]
315
307
  @materialized_event_index += 1
316
308
 
317
- process_message(event_hash) do |stdout_chunk, stderr_chunk, pty_chunk|
318
- yield stdout_chunk, stderr_chunk, pty_chunk
319
- end
309
+ process_message(event_hash, &block)
320
310
  end
321
311
  end
322
312
 
@@ -327,14 +317,12 @@ module E2B
327
317
  #
328
318
  # @yield [stdout, stderr, pty]
329
319
  # @return [void]
330
- def iterate_streaming_events
320
+ def iterate_streaming_events(&block)
331
321
  catch(:stop_iteration) do
332
322
  @events_proc.call do |event_hash|
333
323
  throw :stop_iteration if @disconnected
334
324
 
335
- process_message(event_hash) do |stdout_chunk, stderr_chunk, pty_chunk|
336
- yield stdout_chunk, stderr_chunk, pty_chunk
337
- end
325
+ process_message(event_hash, &block)
338
326
  end
339
327
  end
340
328
  end
@@ -353,15 +341,15 @@ module E2B
353
341
  # @param message [Hash] A raw stream message
354
342
  # @yield [stdout, stderr, pty]
355
343
  # @return [void]
356
- def process_message(message)
344
+ def process_message(message, &block)
357
345
  return unless message.is_a?(Hash)
358
346
 
359
347
  event = message["event"]
360
- process_event(event) { |stdout_chunk, stderr_chunk, pty_chunk| yield stdout_chunk, stderr_chunk, pty_chunk } if event.is_a?(Hash)
348
+ process_event(event, &block) if event.is_a?(Hash)
361
349
 
362
350
  if event.nil?
363
- stdout_chunk = decode_base64(message["stdout"])
364
- stderr_chunk = decode_base64(message["stderr"])
351
+ stdout_chunk = EnvdBase64.decode_process_output(message["stdout"])
352
+ stderr_chunk = EnvdBase64.decode_process_output(message["stderr"])
365
353
 
366
354
  if stdout_chunk && !stdout_chunk.empty?
367
355
  append_stdout(stdout_chunk)
@@ -387,9 +375,9 @@ module E2B
387
375
  # Handle Data event
388
376
  data_event = event["Data"] || event["data"]
389
377
  if data_event
390
- stdout_chunk = decode_base64(data_event["stdout"])
391
- stderr_chunk = decode_base64(data_event["stderr"])
392
- pty_chunk = decode_base64(data_event["pty"])
378
+ stdout_chunk = EnvdBase64.decode_process_output(data_event["stdout"])
379
+ stderr_chunk = EnvdBase64.decode_process_output(data_event["stderr"])
380
+ pty_chunk = EnvdBase64.decode_process_output(data_event["pty"])
393
381
 
394
382
  if stdout_chunk && !stdout_chunk.empty?
395
383
  append_stdout(stdout_chunk)
@@ -467,18 +455,6 @@ module E2B
467
455
  nil
468
456
  end
469
457
 
470
- # Decode a base64-encoded string.
471
- #
472
- # @param data [String, nil] Base64-encoded data
473
- # @return [String, nil] Decoded string, or nil if input is nil/empty
474
- def decode_base64(data)
475
- return nil if data.nil? || data.empty?
476
-
477
- Base64.decode64(data).force_encoding("UTF-8")
478
- rescue StandardError
479
- data.to_s
480
- end
481
-
482
458
  # Parse an exit code from various envd response formats.
483
459
  #
484
460
  # Handles integer values, string integers, and "exit status N" strings.
@@ -25,6 +25,7 @@ module E2B
25
25
  # handle.kill
26
26
  class Commands < BaseService
27
27
  include LiveStreamable
28
+
28
29
  # Run a command in the sandbox
29
30
  #
30
31
  # @param cmd [String] Command to execute (run via /bin/bash -l -c)
@@ -36,11 +37,15 @@ module E2B
36
37
  # @param on_stderr [Proc, nil] Callback for stderr data
37
38
  # @param timeout [Integer] Command timeout in seconds (default: 60)
38
39
  # @param request_timeout [Integer, nil] HTTP request timeout in seconds
40
+ # @param stdin [Boolean] Allocate a stdin pipe for the process. Required
41
+ # when the caller plans to use {#send_stdin} or {CommandHandle#send_stdin}
42
+ # on a background handle. Defaults to +false+ to mirror TS/Python SDKs.
39
43
  # @return [CommandResult, CommandHandle] Result or handle for background commands
40
44
  #
41
45
  # @raise [CommandExitError] If exit code is non-zero (foreground only)
42
46
  def run(cmd, background: false, envs: nil, user: nil, cwd: nil,
43
- on_stdout: nil, on_stderr: nil, timeout: 60, request_timeout: nil, &block)
47
+ on_stdout: nil, on_stderr: nil, timeout: 60, request_timeout: nil,
48
+ stdin: false, &block)
44
49
  # Build the process spec - official SDK always uses /bin/bash -l -c
45
50
  process_spec = {
46
51
  cmd: "/bin/bash",
@@ -55,7 +60,7 @@ module E2B
55
60
 
56
61
  process_spec[:cwd] = cwd if cwd
57
62
 
58
- body = { process: process_spec }
63
+ body = { process: process_spec, stdin: stdin }
59
64
  headers = user_auth_headers(user)
60
65
 
61
66
  # Set up streaming callback
@@ -89,10 +94,10 @@ module E2B
89
94
  end
90
95
 
91
96
  response = envd_rpc("process.Process", "Start",
92
- body: body,
93
- timeout: effective_timeout,
94
- headers: headers,
95
- on_event: streaming_callback)
97
+ body: body,
98
+ timeout: effective_timeout,
99
+ headers: headers,
100
+ on_event: streaming_callback)
96
101
 
97
102
  # Return CommandResult for foreground processes
98
103
  result = build_result(response)
@@ -116,16 +121,15 @@ module E2B
116
121
  # @return [Array<Hash>] List of running processes with pid, config, tag
117
122
  def list(request_timeout: nil)
118
123
  response = envd_rpc("process.Process", "List",
119
- body: {},
120
- timeout: request_timeout || 30)
124
+ body: {},
125
+ timeout: request_timeout || 30)
121
126
 
122
127
  processes = []
123
128
  events = response[:events] || []
124
129
  events.each do |event|
125
130
  next unless event.is_a?(Hash)
126
- if event["processes"]
127
- processes.concat(Array(event["processes"]))
128
- end
131
+
132
+ processes.concat(Array(event["processes"])) if event["processes"]
129
133
  end
130
134
  processes
131
135
  end
@@ -137,12 +141,12 @@ module E2B
137
141
  # @return [Boolean] true if killed, false if not found
138
142
  def kill(pid, request_timeout: nil, headers: nil)
139
143
  envd_rpc("process.Process", "SendSignal",
140
- body: {
141
- process: { pid: pid },
142
- signal: 9 # SIGKILL
143
- },
144
- headers: headers,
145
- timeout: request_timeout || 30)
144
+ body: {
145
+ process: { pid: pid },
146
+ signal: 9 # SIGKILL
147
+ },
148
+ headers: headers,
149
+ timeout: request_timeout || 30)
146
150
  true
147
151
  rescue E2B::NotFoundError
148
152
  false
@@ -150,7 +154,11 @@ module E2B
150
154
  false
151
155
  end
152
156
 
153
- # Send stdin data to a running process
157
+ # Send stdin data to a running process.
158
+ #
159
+ # The target process must have been started with +stdin: true+ (see {#run})
160
+ # — otherwise envd silently drops the input and the call is a no-op on the
161
+ # process side.
154
162
  #
155
163
  # @param pid [Integer] Process ID
156
164
  # @param data [String] Data to send to stdin
@@ -158,12 +166,12 @@ module E2B
158
166
  def send_stdin(pid, data, request_timeout: nil, headers: nil)
159
167
  encoded = Base64.strict_encode64(data.to_s)
160
168
  envd_rpc("process.Process", "SendInput",
161
- body: {
162
- process: { pid: pid },
163
- input: { stdin: encoded }
164
- },
165
- headers: headers,
166
- timeout: request_timeout || 30)
169
+ body: {
170
+ process: { pid: pid },
171
+ input: { stdin: encoded }
172
+ },
173
+ headers: headers,
174
+ timeout: request_timeout || 30)
167
175
  end
168
176
 
169
177
  # Close the stdin of a running process.
@@ -177,9 +185,9 @@ module E2B
177
185
  # @raise [E2B::E2BError] if the process is not found
178
186
  def close_stdin(pid, request_timeout: nil, headers: nil)
179
187
  envd_rpc("process.Process", "CloseStdin",
180
- body: { process: { pid: pid } },
181
- headers: headers,
182
- timeout: request_timeout || 30)
188
+ body: { process: { pid: pid } },
189
+ headers: headers,
190
+ timeout: request_timeout || 30)
183
191
  end
184
192
 
185
193
  # Connect to a running process
@@ -214,10 +222,9 @@ module E2B
214
222
  events = response[:events] || []
215
223
  events.each do |event|
216
224
  next unless event.is_a?(Hash) && event["event"]
225
+
217
226
  end_event = event["event"]["End"] || event["event"]["end"]
218
- if end_event
219
- error = end_event["error"] if end_event["error"] && !end_event["error"].empty?
220
- end
227
+ error = end_event["error"] if end_event && end_event["error"] && !end_event["error"].empty?
221
228
  end
222
229
 
223
230
  CommandResult.new(
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module E2B
6
+ module Services
7
+ # Base64 payloads from envd wrap raw subprocess / PTY bytes.
8
+ module EnvdBase64
9
+ module_function
10
+
11
+ # @param data [String, nil] base64-encoded chunk from envd
12
+ # @return [String] UTF-8 string with invalid byte sequences scrubbed; "" if +data+ is nil or empty
13
+ def decode_process_output(data)
14
+ return "" if data.nil? || data.empty?
15
+
16
+ Base64.decode64(data).force_encoding(Encoding::UTF_8).scrub
17
+ rescue StandardError
18
+ data.to_s
19
+ end
20
+ end
21
+ end
22
+ end