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,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+ require "json"
8
+
9
+ module E2B
10
+ module Services
11
+ # Filesystem operations for E2B sandbox
12
+ #
13
+ # Provides methods for reading, writing, and managing files in the sandbox.
14
+ # Uses envd RPC for filesystem operations and REST endpoints for file transfer.
15
+ #
16
+ # @example
17
+ # # Write a file
18
+ # sandbox.files.write("/home/user/hello.txt", "Hello, World!")
19
+ #
20
+ # # Read a file
21
+ # content = sandbox.files.read("/home/user/hello.txt")
22
+ #
23
+ # # List directory
24
+ # entries = sandbox.files.list("/home/user")
25
+ class Filesystem < BaseService
26
+ # Default username for file operations
27
+ DEFAULT_USER = "user"
28
+
29
+ # Read file content
30
+ #
31
+ # @param path [String] File path in the sandbox
32
+ # @param format [String] Return format: "text" (default), "bytes", or "stream"
33
+ # @param user [String] Username context for the operation
34
+ # @param request_timeout [Integer] Request timeout in seconds
35
+ # @return [String] File content
36
+ #
37
+ # @example
38
+ # content = sandbox.files.read("/home/user/config.json")
39
+ def read(path, format: "text", user: DEFAULT_USER, request_timeout: 120)
40
+ url = build_file_url("/files", path: path, user: user)
41
+ response = rest_get(url, timeout: request_timeout)
42
+ response
43
+ end
44
+
45
+ # Write content to a file using REST upload
46
+ #
47
+ # @param path [String] File path in the sandbox
48
+ # @param data [String, IO] Content to write (string or IO object)
49
+ # @param user [String] Username context for the operation
50
+ # @param request_timeout [Integer] Request timeout in seconds
51
+ # @return [Models::EntryInfo, nil] Info about the written file
52
+ #
53
+ # @example
54
+ # sandbox.files.write("/home/user/output.txt", "Hello, World!")
55
+ def write(path, data, user: DEFAULT_USER, request_timeout: 120)
56
+ url = build_file_url("/files", path: path, user: user)
57
+ content = data.is_a?(IO) || data.respond_to?(:read) ? data.read : data.to_s
58
+ rest_upload(url, content, timeout: request_timeout)
59
+ end
60
+
61
+ # Write multiple files at once
62
+ #
63
+ # @param files [Array<Hash>] Array of { path:, data: } hashes
64
+ # @param user [String] Username context
65
+ # @param request_timeout [Integer] Request timeout in seconds
66
+ # @return [Array] Results for each file
67
+ #
68
+ # @example
69
+ # sandbox.files.write_files([
70
+ # { path: "/home/user/a.txt", data: "Content A" },
71
+ # { path: "/home/user/b.txt", data: "Content B" }
72
+ # ])
73
+ def write_files(files, user: DEFAULT_USER, request_timeout: 120)
74
+ files.map do |file|
75
+ write(file[:path], file[:data] || file[:content], user: user, request_timeout: request_timeout)
76
+ end
77
+ end
78
+
79
+ # List directory contents using filesystem RPC
80
+ #
81
+ # @param path [String] Directory path
82
+ # @param depth [Integer] Recursion depth (default: 1, only immediate children)
83
+ # @param user [String] Username context
84
+ # @param request_timeout [Integer] Request timeout in seconds
85
+ # @return [Array<Models::EntryInfo>] List of entries
86
+ #
87
+ # @example
88
+ # entries = sandbox.files.list("/home/user")
89
+ # entries.each { |e| puts "#{e.name} (#{e.type})" }
90
+ def list(path, depth: 1, user: DEFAULT_USER, request_timeout: 60)
91
+ response = envd_rpc("filesystem.Filesystem", "ListDir",
92
+ body: { path: path, depth: depth },
93
+ timeout: request_timeout)
94
+
95
+ entries = extract_entries(response)
96
+ entries.map { |e| Models::EntryInfo.from_hash(e) }
97
+ end
98
+
99
+ # Check if a path exists
100
+ #
101
+ # @param path [String] Path to check
102
+ # @param user [String] Username context
103
+ # @param request_timeout [Integer] Request timeout in seconds
104
+ # @return [Boolean]
105
+ def exists?(path, user: DEFAULT_USER, request_timeout: 30)
106
+ get_info(path, user: user, request_timeout: request_timeout)
107
+ true
108
+ rescue E2B::NotFoundError, E2B::E2BError
109
+ false
110
+ end
111
+
112
+ # Get file/directory information using filesystem RPC
113
+ #
114
+ # @param path [String] Path to get info for
115
+ # @param user [String] Username context
116
+ # @param request_timeout [Integer] Request timeout in seconds
117
+ # @return [Models::EntryInfo] File/directory info
118
+ def get_info(path, user: DEFAULT_USER, request_timeout: 30)
119
+ response = envd_rpc("filesystem.Filesystem", "Stat",
120
+ body: { path: path },
121
+ timeout: request_timeout)
122
+
123
+ entry_data = extract_entry(response)
124
+ Models::EntryInfo.from_hash(entry_data)
125
+ end
126
+
127
+ # Remove a file or directory
128
+ #
129
+ # @param path [String] Path to remove
130
+ # @param user [String] Username context
131
+ # @param request_timeout [Integer] Request timeout in seconds
132
+ def remove(path, user: DEFAULT_USER, request_timeout: 30)
133
+ envd_rpc("filesystem.Filesystem", "Remove",
134
+ body: { path: path },
135
+ timeout: request_timeout)
136
+ end
137
+
138
+ # Rename/move a file or directory
139
+ #
140
+ # @param old_path [String] Source path
141
+ # @param new_path [String] Destination path
142
+ # @param user [String] Username context
143
+ # @param request_timeout [Integer] Request timeout in seconds
144
+ # @return [Models::EntryInfo] Info about the moved entry
145
+ def rename(old_path, new_path, user: DEFAULT_USER, request_timeout: 30)
146
+ response = envd_rpc("filesystem.Filesystem", "Move",
147
+ body: { source: old_path, destination: new_path },
148
+ timeout: request_timeout)
149
+
150
+ entry_data = extract_entry(response)
151
+ Models::EntryInfo.from_hash(entry_data)
152
+ end
153
+
154
+ # Create a directory
155
+ #
156
+ # @param path [String] Directory path to create
157
+ # @param user [String] Username context
158
+ # @param request_timeout [Integer] Request timeout in seconds
159
+ # @return [Boolean] true if created successfully
160
+ def make_dir(path, user: DEFAULT_USER, request_timeout: 30)
161
+ envd_rpc("filesystem.Filesystem", "MakeDir",
162
+ body: { path: path },
163
+ timeout: request_timeout)
164
+ true
165
+ end
166
+
167
+ # Watch a directory for filesystem changes
168
+ #
169
+ # Uses the polling-based CreateWatcher/GetWatcherEvents/RemoveWatcher RPCs.
170
+ #
171
+ # @param path [String] Directory path to watch
172
+ # @param recursive [Boolean] Watch subdirectories recursively
173
+ # @param user [String] Username context
174
+ # @param request_timeout [Integer] Request timeout in seconds
175
+ # @return [WatchHandle] Handle for polling events and stopping the watcher
176
+ #
177
+ # @example
178
+ # handle = sandbox.files.watch_dir("/home/user/project")
179
+ # # ... wait for changes ...
180
+ # events = handle.get_new_events
181
+ # events.each { |e| puts "#{e.type}: #{e.name}" }
182
+ # handle.stop
183
+ def watch_dir(path, recursive: false, user: DEFAULT_USER, request_timeout: 30)
184
+ response = envd_rpc("filesystem.Filesystem", "CreateWatcher",
185
+ body: { path: path, recursive: recursive },
186
+ timeout: request_timeout)
187
+
188
+ watcher_id = response[:events]&.first&.dig("watcherId") ||
189
+ response["watcherId"] ||
190
+ extract_watcher_id(response)
191
+
192
+ raise E2B::E2BError, "Failed to create watcher: no watcher_id returned" unless watcher_id
193
+
194
+ rpc_proc = method(:envd_rpc)
195
+ WatchHandle.new(watcher_id: watcher_id, envd_rpc_proc: rpc_proc)
196
+ end
197
+
198
+ # Backward-compatible aliases
199
+ alias read_file read
200
+ alias write_file write
201
+ alias list_files list
202
+ alias mkdir make_dir
203
+ alias move rename
204
+ alias create_folder make_dir
205
+ alias delete_file remove
206
+ alias move_files rename
207
+
208
+ private
209
+
210
+ # Build URL for file operations
211
+ def build_file_url(endpoint, path: nil, user: nil)
212
+ base = "https://#{ENVD_PORT}-#{@sandbox_id}.#{@sandbox_domain}"
213
+ url = "#{base}#{endpoint}"
214
+ params = []
215
+ params << "path=#{URI.encode_www_form_component(path)}" if path
216
+ params << "username=#{URI.encode_www_form_component(user)}" if user
217
+ url += "?#{params.join("&")}" unless params.empty?
218
+ url
219
+ end
220
+
221
+ # Perform REST GET request for file download
222
+ def rest_get(url_string, timeout: 120)
223
+ with_ssl_retry("GET #{url_string}") do
224
+ uri = URI.parse(url_string)
225
+ request = Net::HTTP::Get.new(uri.request_uri)
226
+ apply_request_headers(request)
227
+
228
+ response = execute_http_request(uri, request, timeout: timeout)
229
+ unless successful_response?(response)
230
+ if response.code.to_i == 404
231
+ raise E2B::NotFoundError.new("File not found", status_code: 404)
232
+ end
233
+ raise E2B::E2BError, "File read failed: HTTP #{response.code}"
234
+ end
235
+
236
+ response.body
237
+ end
238
+ end
239
+
240
+ # Perform REST POST for file upload (multipart form data)
241
+ def rest_upload(url_string, content, timeout: 120)
242
+ with_ssl_retry("POST #{url_string}") do
243
+ uri = URI.parse(url_string)
244
+
245
+ boundary = "----E2BRubySDK#{SecureRandom.hex(16)}"
246
+ body = build_multipart_body(boundary, content)
247
+
248
+ request = Net::HTTP::Post.new(uri.request_uri)
249
+ request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
250
+ request.body = body
251
+ apply_request_headers(request)
252
+
253
+ response = execute_http_request(uri, request, timeout: timeout)
254
+ unless successful_response?(response)
255
+ raise E2B::E2BError, "File upload failed: HTTP #{response.code}"
256
+ end
257
+
258
+ true
259
+ end
260
+ end
261
+
262
+ def build_multipart_body(boundary, content)
263
+ body = "".b
264
+ body << "--#{boundary}\r\n"
265
+ body << "Content-Disposition: form-data; name=\"file\"; filename=\"upload\"\r\n"
266
+ body << "Content-Type: application/octet-stream\r\n"
267
+ body << "\r\n"
268
+ body << content.b
269
+ body << "\r\n"
270
+ body << "--#{boundary}--\r\n"
271
+ body
272
+ end
273
+
274
+ def execute_http_request(uri, request, timeout: 120)
275
+ http = Net::HTTP.new(uri.host, uri.port)
276
+ http.use_ssl = true
277
+ http.open_timeout = 30
278
+ http.read_timeout = timeout
279
+ http.keep_alive_timeout = 30
280
+ http.verify_mode = ssl_verify_mode
281
+ http.request(request)
282
+ end
283
+
284
+ def apply_request_headers(request)
285
+ request["X-Access-Token"] = @access_token if @access_token
286
+ request["Connection"] = "keep-alive"
287
+ request["User-Agent"] = "e2b-ruby-sdk/#{E2B::VERSION}"
288
+ end
289
+
290
+ def ssl_verify_mode
291
+ ssl_verify = ENV.fetch("E2B_SSL_VERIFY", "true").downcase != "false"
292
+ ssl_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
293
+ end
294
+
295
+ def successful_response?(response)
296
+ code = response.code.to_i
297
+ code >= 200 && code < 300
298
+ end
299
+
300
+ # Retry wrapper for SSL/network errors
301
+ def with_ssl_retry(operation, max_retries: 3)
302
+ retry_count = 0
303
+
304
+ begin
305
+ yield
306
+ rescue OpenSSL::SSL::SSLError, Errno::ECONNRESET, EOFError, Net::OpenTimeout, Net::ReadTimeout => e
307
+ retry_count += 1
308
+
309
+ if retry_count <= max_retries
310
+ sleep_time = 2**retry_count
311
+ sleep(sleep_time)
312
+ retry
313
+ else
314
+ raise E2B::E2BError, "#{operation} failed after #{max_retries} retries: #{e.message}"
315
+ end
316
+ end
317
+ end
318
+
319
+ # Extract entries from RPC response
320
+ def extract_entries(response)
321
+ return [] unless response.is_a?(Hash)
322
+
323
+ # The response comes through the Connect envelope parser
324
+ # Check various possible locations for the entries array
325
+ events = response[:events] || []
326
+ entries = []
327
+
328
+ events.each do |event|
329
+ next unless event.is_a?(Hash)
330
+ # Direct entries field
331
+ if event["entries"]
332
+ entries.concat(Array(event["entries"]))
333
+ elsif event["result"] && event["result"]["entries"]
334
+ entries.concat(Array(event["result"]["entries"]))
335
+ end
336
+ end
337
+
338
+ # Also check top-level
339
+ entries = response["entries"] || [] if entries.empty?
340
+ entries
341
+ end
342
+
343
+ # Extract single entry from RPC response
344
+ def extract_entry(response)
345
+ return {} unless response.is_a?(Hash)
346
+
347
+ events = response[:events] || []
348
+ events.each do |event|
349
+ next unless event.is_a?(Hash)
350
+ return event["entry"] if event["entry"]
351
+ return event["result"]["entry"] if event.dig("result", "entry")
352
+ end
353
+
354
+ response["entry"] || {}
355
+ end
356
+
357
+ # Extract watcher_id from CreateWatcher response
358
+ def extract_watcher_id(response)
359
+ return nil unless response.is_a?(Hash)
360
+
361
+ events = response[:events] || []
362
+ events.each do |event|
363
+ next unless event.is_a?(Hash)
364
+ return event["watcherId"] || event["watcher_id"] if event["watcherId"] || event["watcher_id"]
365
+ result = event["result"]
366
+ return result["watcherId"] || result["watcher_id"] if result.is_a?(Hash) && (result["watcherId"] || result["watcher_id"])
367
+ end
368
+
369
+ nil
370
+ end
371
+ end
372
+ end
373
+ end