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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +181 -0
- data/lib/e2b/api/http_client.rb +164 -0
- data/lib/e2b/client.rb +201 -0
- data/lib/e2b/configuration.rb +119 -0
- data/lib/e2b/errors.rb +88 -0
- data/lib/e2b/models/entry_info.rb +243 -0
- data/lib/e2b/models/process_result.rb +127 -0
- data/lib/e2b/models/sandbox_info.rb +94 -0
- data/lib/e2b/sandbox.rb +407 -0
- data/lib/e2b/services/base_service.rb +485 -0
- data/lib/e2b/services/command_handle.rb +350 -0
- data/lib/e2b/services/commands.rb +229 -0
- data/lib/e2b/services/filesystem.rb +373 -0
- data/lib/e2b/services/git.rb +893 -0
- data/lib/e2b/services/pty.rb +297 -0
- data/lib/e2b/services/watch_handle.rb +110 -0
- data/lib/e2b/version.rb +5 -0
- data/lib/e2b.rb +87 -0
- metadata +142 -0
|
@@ -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
|