e2b 0.2.0 → 0.3.1

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.
@@ -5,6 +5,7 @@ require "net/http"
5
5
  require "openssl"
6
6
  require "uri"
7
7
  require "json"
8
+ require "stringio"
8
9
 
9
10
  module E2B
10
11
  module Services
@@ -24,8 +25,6 @@ module E2B
24
25
  # entries = sandbox.files.list("/home/user")
25
26
  class Filesystem < BaseService
26
27
  # Default username for file operations
27
- DEFAULT_USER = "user"
28
-
29
28
  # Read file content
30
29
  #
31
30
  # @param path [String] File path in the sandbox
@@ -36,10 +35,20 @@ module E2B
36
35
  #
37
36
  # @example
38
37
  # content = sandbox.files.read("/home/user/config.json")
39
- def read(path, format: "text", user: DEFAULT_USER, request_timeout: 120)
38
+ def read(path, format: "text", user: nil, request_timeout: 120)
40
39
  url = build_file_url("/files", path: path, user: user)
41
40
  response = rest_get(url, timeout: request_timeout)
42
- response
41
+
42
+ case format
43
+ when "text"
44
+ response.dup.force_encoding("UTF-8")
45
+ when "bytes"
46
+ response.b
47
+ when "stream"
48
+ StringIO.new(response.b)
49
+ else
50
+ raise ArgumentError, "Unsupported read format '#{format}'"
51
+ end
43
52
  end
44
53
 
45
54
  # Write content to a file using REST upload
@@ -48,14 +57,15 @@ module E2B
48
57
  # @param data [String, IO] Content to write (string or IO object)
49
58
  # @param user [String] Username context for the operation
50
59
  # @param request_timeout [Integer] Request timeout in seconds
51
- # @return [Models::EntryInfo, nil] Info about the written file
60
+ # @return [Models::WriteInfo] Info about the written file
52
61
  #
53
62
  # @example
54
63
  # sandbox.files.write("/home/user/output.txt", "Hello, World!")
55
- def write(path, data, user: DEFAULT_USER, request_timeout: 120)
64
+ def write(path, data, user: nil, request_timeout: 120)
56
65
  url = build_file_url("/files", path: path, user: user)
57
66
  content = data.is_a?(IO) || data.respond_to?(:read) ? data.read : data.to_s
58
- rest_upload(url, content, timeout: request_timeout)
67
+ result = rest_upload(url, content, timeout: request_timeout)
68
+ build_write_info(result, default_path: path)
59
69
  end
60
70
 
61
71
  # Write multiple files at once
@@ -70,7 +80,7 @@ module E2B
70
80
  # { path: "/home/user/a.txt", data: "Content A" },
71
81
  # { path: "/home/user/b.txt", data: "Content B" }
72
82
  # ])
73
- def write_files(files, user: DEFAULT_USER, request_timeout: 120)
83
+ def write_files(files, user: nil, request_timeout: 120)
74
84
  files.map do |file|
75
85
  write(file[:path], file[:data] || file[:content], user: user, request_timeout: request_timeout)
76
86
  end
@@ -87,10 +97,11 @@ module E2B
87
97
  # @example
88
98
  # entries = sandbox.files.list("/home/user")
89
99
  # entries.each { |e| puts "#{e.name} (#{e.type})" }
90
- def list(path, depth: 1, user: DEFAULT_USER, request_timeout: 60)
100
+ def list(path, depth: 1, user: nil, request_timeout: 60)
91
101
  response = envd_rpc("filesystem.Filesystem", "ListDir",
92
102
  body: { path: path, depth: depth },
93
- timeout: request_timeout)
103
+ timeout: request_timeout,
104
+ headers: user_auth_headers(user))
94
105
 
95
106
  entries = extract_entries(response)
96
107
  entries.map { |e| Models::EntryInfo.from_hash(e) }
@@ -102,7 +113,7 @@ module E2B
102
113
  # @param user [String] Username context
103
114
  # @param request_timeout [Integer] Request timeout in seconds
104
115
  # @return [Boolean]
105
- def exists?(path, user: DEFAULT_USER, request_timeout: 30)
116
+ def exists?(path, user: nil, request_timeout: 30)
106
117
  get_info(path, user: user, request_timeout: request_timeout)
107
118
  true
108
119
  rescue E2B::NotFoundError, E2B::E2BError
@@ -115,10 +126,11 @@ module E2B
115
126
  # @param user [String] Username context
116
127
  # @param request_timeout [Integer] Request timeout in seconds
117
128
  # @return [Models::EntryInfo] File/directory info
118
- def get_info(path, user: DEFAULT_USER, request_timeout: 30)
129
+ def get_info(path, user: nil, request_timeout: 30)
119
130
  response = envd_rpc("filesystem.Filesystem", "Stat",
120
131
  body: { path: path },
121
- timeout: request_timeout)
132
+ timeout: request_timeout,
133
+ headers: user_auth_headers(user))
122
134
 
123
135
  entry_data = extract_entry(response)
124
136
  Models::EntryInfo.from_hash(entry_data)
@@ -129,10 +141,11 @@ module E2B
129
141
  # @param path [String] Path to remove
130
142
  # @param user [String] Username context
131
143
  # @param request_timeout [Integer] Request timeout in seconds
132
- def remove(path, user: DEFAULT_USER, request_timeout: 30)
144
+ def remove(path, user: nil, request_timeout: 30)
133
145
  envd_rpc("filesystem.Filesystem", "Remove",
134
146
  body: { path: path },
135
- timeout: request_timeout)
147
+ timeout: request_timeout,
148
+ headers: user_auth_headers(user))
136
149
  end
137
150
 
138
151
  # Rename/move a file or directory
@@ -142,10 +155,11 @@ module E2B
142
155
  # @param user [String] Username context
143
156
  # @param request_timeout [Integer] Request timeout in seconds
144
157
  # @return [Models::EntryInfo] Info about the moved entry
145
- def rename(old_path, new_path, user: DEFAULT_USER, request_timeout: 30)
158
+ def rename(old_path, new_path, user: nil, request_timeout: 30)
146
159
  response = envd_rpc("filesystem.Filesystem", "Move",
147
160
  body: { source: old_path, destination: new_path },
148
- timeout: request_timeout)
161
+ timeout: request_timeout,
162
+ headers: user_auth_headers(user))
149
163
 
150
164
  entry_data = extract_entry(response)
151
165
  Models::EntryInfo.from_hash(entry_data)
@@ -157,10 +171,11 @@ module E2B
157
171
  # @param user [String] Username context
158
172
  # @param request_timeout [Integer] Request timeout in seconds
159
173
  # @return [Boolean] true if created successfully
160
- def make_dir(path, user: DEFAULT_USER, request_timeout: 30)
174
+ def make_dir(path, user: nil, request_timeout: 30)
161
175
  envd_rpc("filesystem.Filesystem", "MakeDir",
162
176
  body: { path: path },
163
- timeout: request_timeout)
177
+ timeout: request_timeout,
178
+ headers: user_auth_headers(user))
164
179
  true
165
180
  end
166
181
 
@@ -180,10 +195,16 @@ module E2B
180
195
  # events = handle.get_new_events
181
196
  # events.each { |e| puts "#{e.type}: #{e.name}" }
182
197
  # handle.stop
183
- def watch_dir(path, recursive: false, user: DEFAULT_USER, request_timeout: 30)
198
+ def watch_dir(path, recursive: false, user: nil, request_timeout: 30)
199
+ if recursive && !supports_recursive_watch?
200
+ raise E2B::TemplateError,
201
+ "You need to update the template to use recursive watching. You can do this by running `e2b template build` in the directory with the template."
202
+ end
203
+
184
204
  response = envd_rpc("filesystem.Filesystem", "CreateWatcher",
185
205
  body: { path: path, recursive: recursive },
186
- timeout: request_timeout)
206
+ timeout: request_timeout,
207
+ headers: user_auth_headers(user))
187
208
 
188
209
  watcher_id = response[:events]&.first&.dig("watcherId") ||
189
210
  response["watcherId"] ||
@@ -192,7 +213,11 @@ module E2B
192
213
  raise E2B::E2BError, "Failed to create watcher: no watcher_id returned" unless watcher_id
193
214
 
194
215
  rpc_proc = method(:envd_rpc)
195
- WatchHandle.new(watcher_id: watcher_id, envd_rpc_proc: rpc_proc)
216
+ WatchHandle.new(
217
+ watcher_id: watcher_id,
218
+ envd_rpc_proc: rpc_proc,
219
+ headers: user_auth_headers(user)
220
+ )
196
221
  end
197
222
 
198
223
  # Backward-compatible aliases
@@ -209,6 +234,7 @@ module E2B
209
234
 
210
235
  # Build URL for file operations
211
236
  def build_file_url(endpoint, path: nil, user: nil)
237
+ user = resolve_username(user)
212
238
  base = "https://#{ENVD_PORT}-#{@sandbox_id}.#{@sandbox_domain}"
213
239
  url = "#{base}#{endpoint}"
214
240
  params = []
@@ -255,7 +281,7 @@ module E2B
255
281
  raise E2B::E2BError, "File upload failed: HTTP #{response.code}"
256
282
  end
257
283
 
258
- true
284
+ parse_upload_response(response.body)
259
285
  end
260
286
  end
261
287
 
@@ -287,6 +313,14 @@ module E2B
287
313
  request["User-Agent"] = "e2b-ruby-sdk/#{E2B::VERSION}"
288
314
  end
289
315
 
316
+ def parse_upload_response(body)
317
+ return [] if body.nil? || body.empty?
318
+
319
+ JSON.parse(body)
320
+ rescue JSON::ParserError
321
+ []
322
+ end
323
+
290
324
  def ssl_verify_mode
291
325
  ssl_verify = ENV.fetch("E2B_SSL_VERIFY", "true").downcase != "false"
292
326
  ssl_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
@@ -368,6 +402,19 @@ module E2B
368
402
 
369
403
  nil
370
404
  end
405
+
406
+ def build_write_info(result, default_path:)
407
+ case result
408
+ when Array
409
+ entry = result.first
410
+ return build_write_info(entry, default_path: default_path) if entry
411
+ when Hash
412
+ path = result["path"] || result[:path] || default_path
413
+ return Models::WriteInfo.new(path: path)
414
+ end
415
+
416
+ Models::WriteInfo.new(path: default_path)
417
+ end
371
418
  end
372
419
  end
373
420
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module E2B
4
+ module Services
5
+ # Shared live-stream handle builder for Commands and Pty.
6
+ #
7
+ # Both services need to start a background thread that runs an envd RPC,
8
+ # extract the PID from the first Start event, and return a CommandHandle
9
+ # wired to a LiveEventStream. This module keeps that logic in one place.
10
+ module LiveStreamable
11
+ private
12
+
13
+ def build_live_handle(rpc_method:, body:, timeout:, headers: nil, on_stdout: nil, on_stderr: nil, &block)
14
+ stream = LiveEventStream.new
15
+ start_queue = Queue.new
16
+ start_signal_sent = false
17
+ pid = nil
18
+ stream_block = block
19
+
20
+ stream_thread = Thread.new do
21
+ Thread.current.report_on_exception = false if Thread.current.respond_to?(:report_on_exception=)
22
+
23
+ begin
24
+ envd_rpc("process.Process", rpc_method,
25
+ body: body,
26
+ timeout: timeout,
27
+ headers: headers,
28
+ on_event: lambda { |event_data|
29
+ stream_event = event_data[:event]
30
+ stream.push(stream_event) if stream_event
31
+
32
+ stdout_chunk = event_data[:stdout]
33
+ stderr_chunk = event_data[:stderr]
34
+
35
+ on_stdout&.call(stdout_chunk) if stdout_chunk && !stdout_chunk.empty?
36
+ on_stderr&.call(stderr_chunk) if stderr_chunk && !stderr_chunk.empty?
37
+
38
+ stream_block&.call(:stdout, stdout_chunk) if stdout_chunk && !stdout_chunk.empty?
39
+ stream_block&.call(:stderr, stderr_chunk) if stderr_chunk && !stderr_chunk.empty?
40
+
41
+ unless start_signal_sent
42
+ extracted_pid = extract_pid_from_event(stream_event)
43
+ if extracted_pid
44
+ pid = extracted_pid
45
+ start_signal_sent = true
46
+ start_queue << [:pid, pid]
47
+ end
48
+ end
49
+ })
50
+
51
+ unless start_signal_sent
52
+ start_signal_sent = true
53
+ start_queue << [:error, E2BError.new("Failed to start process: expected start event")]
54
+ end
55
+ rescue StandardError => e
56
+ unless start_signal_sent
57
+ start_signal_sent = true
58
+ start_queue << [:error, e]
59
+ end
60
+
61
+ stream.fail(e)
62
+ ensure
63
+ stream.close
64
+ end
65
+ end
66
+
67
+ start_state, start_value = start_queue.pop
68
+ raise start_value if start_state == :error
69
+
70
+ CommandHandle.new(
71
+ pid: pid,
72
+ handle_kill: -> { kill(pid, headers: headers) },
73
+ handle_send_stdin: ->(data) { send_stdin(pid, data, headers: headers) },
74
+ handle_disconnect: -> { disconnect_live_stream(stream_thread, stream) },
75
+ events_proc: ->(&events_block) { stream.each(&events_block) }
76
+ )
77
+ end
78
+
79
+ def extract_pid_from_event(event)
80
+ return nil unless event.is_a?(Hash) && event["event"].is_a?(Hash)
81
+
82
+ start_event = event["event"]["Start"] || event["event"]["start"]
83
+ return nil unless start_event && start_event["pid"]
84
+
85
+ start_event["pid"].to_i
86
+ end
87
+
88
+ def disconnect_live_stream(stream_thread, stream)
89
+ stream.close(discard_pending: true)
90
+ stream_thread.kill if stream_thread&.alive?
91
+ end
92
+ end
93
+ end
94
+ end
@@ -53,6 +53,7 @@ module E2B
53
53
  # @example Connect to an existing PTY
54
54
  # handle = pty.connect(pid)
55
55
  class Pty < BaseService
56
+ include LiveStreamable
56
57
  # Default shell to use for PTY sessions
57
58
  DEFAULT_SHELL = "/bin/bash"
58
59
 
@@ -84,6 +85,7 @@ module E2B
84
85
  def create(size: PtySize.new, user: nil, cwd: nil, envs: nil,
85
86
  cmd: DEFAULT_SHELL, args: DEFAULT_SHELL_ARGS, timeout: 60)
86
87
  envs = build_pty_envs(envs)
88
+ headers = user_auth_headers(user)
87
89
 
88
90
  process_spec = {
89
91
  cmd: cmd,
@@ -99,34 +101,11 @@ module E2B
99
101
  }
100
102
  }
101
103
 
102
- pid = nil
103
-
104
- # Use streaming RPC to capture the StartEvent and extract the PID
105
- on_event = ->(event_data) {
106
- event = event_data[:event]
107
- if event.is_a?(Hash) && event["event"]
108
- start_event = event["event"]["Start"] || event["event"]["start"]
109
- if start_event && start_event["pid"]
110
- pid = start_event["pid"]
111
- end
112
- end
113
- }
114
-
115
- response = envd_rpc(
116
- "process.Process", "Start",
104
+ build_live_handle(
105
+ rpc_method: "Start",
117
106
  body: body,
118
- timeout: timeout + 30,
119
- on_event: on_event
120
- )
121
-
122
- # If PID was not captured from streaming, try the accumulated result
123
- pid ||= extract_pid_from_result(response)
124
-
125
- CommandHandle.new(
126
- pid: pid,
127
- handle_kill: -> { kill(pid) },
128
- handle_send_stdin: ->(data) { send_stdin(pid, data) },
129
- result: response
107
+ headers: headers,
108
+ timeout: timeout + 30
130
109
  )
131
110
  end
132
111
 
@@ -148,18 +127,12 @@ module E2B
148
127
  process: { pid: pid }
149
128
  }
150
129
 
151
- response = envd_rpc(
152
- "process.Process", "Connect",
130
+ build_live_handle(
131
+ rpc_method: "Connect",
153
132
  body: body,
133
+ headers: user_auth_headers(nil),
154
134
  timeout: timeout + 30
155
135
  )
156
-
157
- CommandHandle.new(
158
- pid: pid,
159
- handle_kill: -> { kill(pid) },
160
- handle_send_stdin: ->(data) { send_stdin(pid, data) },
161
- result: response
162
- )
163
136
  end
164
137
 
165
138
  # Send input data to a PTY.
@@ -178,12 +151,12 @@ module E2B
178
151
  #
179
152
  # @example Send Ctrl+C
180
153
  # sandbox.pty.send_stdin(pid, "\x03")
181
- def send_stdin(pid, data)
154
+ def send_stdin(pid, data, headers: nil)
182
155
  encoded = Base64.strict_encode64(data.is_a?(String) ? data : data.to_s)
183
156
  envd_rpc("process.Process", "SendInput", body: {
184
157
  process: { pid: pid },
185
158
  input: { pty: encoded }
186
- })
159
+ }, headers: headers)
187
160
  end
188
161
 
189
162
  # Kill a PTY process with SIGKILL.
@@ -193,11 +166,11 @@ module E2B
193
166
  #
194
167
  # @example
195
168
  # sandbox.pty.kill(12345)
196
- def kill(pid)
169
+ def kill(pid, headers: nil)
197
170
  envd_rpc("process.Process", "SendSignal", body: {
198
171
  process: { pid: pid },
199
172
  signal: 9 # SIGKILL
200
- })
173
+ }, headers: headers)
201
174
  true
202
175
  rescue E2B::E2BError
203
176
  false
@@ -268,30 +241,6 @@ module E2B
268
241
  result
269
242
  end
270
243
 
271
- # Extract PID from the StartEvent in a pre-materialized RPC result.
272
- #
273
- # The result hash from {EnvdHttpClient#handle_streaming_rpc} or
274
- # {EnvdHttpClient#handle_rpc_response} contains an :events array.
275
- # The first event with a Start sub-event carries the PID.
276
- #
277
- # @param response [Hash] RPC response hash with :events key
278
- # @return [Integer, nil] Process ID, or nil if not found
279
- def extract_pid_from_result(response)
280
- return nil unless response.is_a?(Hash)
281
-
282
- events = response[:events] || []
283
- events.each do |event_hash|
284
- next unless event_hash.is_a?(Hash) && event_hash["event"]
285
-
286
- event = event_hash["event"]
287
- start_event = event["Start"] || event["start"]
288
- if start_event && start_event["pid"]
289
- return start_event["pid"]
290
- end
291
- end
292
-
293
- nil
294
- end
295
244
  end
296
245
  end
297
246
  end
@@ -38,9 +38,10 @@ module E2B
38
38
  # @param envd_rpc_proc [Proc] A callable that performs RPC calls. It must accept
39
39
  # three positional arguments (service, method) and keyword arguments (body:, timeout:).
40
40
  # Typically a lambda wrapping {BaseService#envd_rpc}.
41
- def initialize(watcher_id:, envd_rpc_proc:)
41
+ def initialize(watcher_id:, envd_rpc_proc:, headers: nil)
42
42
  @watcher_id = watcher_id
43
43
  @envd_rpc_proc = envd_rpc_proc
44
+ @headers = headers
44
45
  @stopped = false
45
46
  end
46
47
 
@@ -56,7 +57,8 @@ module E2B
56
57
 
57
58
  response = @envd_rpc_proc.call(
58
59
  "filesystem.Filesystem", "GetWatcherEvents",
59
- body: { watcherId: @watcher_id }
60
+ body: { watcherId: @watcher_id },
61
+ headers: @headers
60
62
  )
61
63
 
62
64
  events = extract_events(response)
@@ -75,7 +77,8 @@ module E2B
75
77
 
76
78
  @envd_rpc_proc.call(
77
79
  "filesystem.Filesystem", "RemoveWatcher",
78
- body: { watcherId: @watcher_id }
80
+ body: { watcherId: @watcher_id },
81
+ headers: @headers
79
82
  )
80
83
  @stopped = true
81
84
  rescue StandardError