e2b 0.2.0 → 0.3.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.
data/lib/e2b/sandbox.rb CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  require "time"
4
4
  require "securerandom"
5
+ require "base64"
6
+ require "digest"
7
+ require "json"
8
+ require "rubygems/version"
5
9
 
6
10
  module E2B
7
11
  # Represents an E2B Sandbox instance
@@ -29,6 +33,12 @@ module E2B
29
33
  # Default sandbox timeout in seconds
30
34
  DEFAULT_TIMEOUT = 300
31
35
 
36
+ # Default template used when enabling MCP without an explicit template.
37
+ DEFAULT_MCP_TEMPLATE = "mcp-gateway"
38
+
39
+ # MCP gateway port.
40
+ MCP_PORT = 50005
41
+
32
42
  # @return [String] Unique sandbox ID
33
43
  attr_reader :sandbox_id
34
44
 
@@ -56,9 +66,18 @@ module E2B
56
66
  # @return [Hash] Metadata
57
67
  attr_reader :metadata
58
68
 
69
+ # @return [String, nil] Current sandbox state
70
+ attr_reader :state
71
+
72
+ # @return [String, nil] Envd version reported by the control plane
73
+ attr_reader :envd_version
74
+
59
75
  # @return [String, nil] Access token for envd authentication
60
76
  attr_reader :envd_access_token
61
77
 
78
+ # @return [String, nil] Access token required for proxied public traffic
79
+ attr_reader :traffic_access_token
80
+
62
81
  # @return [Services::Commands] Command execution service
63
82
  attr_reader :commands
64
83
 
@@ -76,6 +95,8 @@ module E2B
76
95
  # -------------------------------------------------------------------
77
96
 
78
97
  class << self
98
+ include SandboxHelpers
99
+
79
100
  # Create a new sandbox
80
101
  #
81
102
  # @param template [String] Template ID or alias (default: "base")
@@ -91,26 +112,44 @@ module E2B
91
112
  # sandbox = E2B::Sandbox.create(template: "base")
92
113
  # sandbox = E2B::Sandbox.create(template: "python", timeout: 600)
93
114
  def create(template: "base", timeout: DEFAULT_TIMEOUT, metadata: nil,
94
- envs: nil, api_key: nil, domain: DEFAULT_DOMAIN,
115
+ envs: nil, secure: true, allow_internet_access: true,
116
+ network: nil, lifecycle: nil, auto_pause: nil, mcp: nil,
117
+ api_key: nil, access_token: nil, domain: nil,
95
118
  request_timeout: 120)
96
- api_key = resolve_api_key(api_key)
97
- http_client = build_http_client(api_key)
119
+ credentials = resolve_credentials(api_key: api_key, access_token: access_token)
120
+ domain = resolve_domain(domain)
121
+ http_client = build_http_client(**credentials, domain: domain)
122
+ template = resolved_template(template, mcp: mcp)
123
+ lifecycle = normalized_lifecycle(lifecycle: lifecycle, auto_pause: auto_pause)
98
124
 
99
125
  body = {
100
126
  templateID: template,
101
- timeout: timeout
127
+ timeout: timeout,
128
+ secure: secure,
129
+ allow_internet_access: allow_internet_access,
130
+ autoPause: lifecycle[:on_timeout] == "pause"
102
131
  }
103
132
  body[:metadata] = metadata if metadata
104
133
  body[:envVars] = envs if envs
134
+ body[:mcp] = mcp if mcp
135
+ body[:network] = network if network
136
+ if body[:autoPause]
137
+ body[:autoResume] = { enabled: lifecycle[:auto_resume] }
138
+ end
105
139
 
106
140
  response = http_client.post("/sandboxes", body: body, timeout: request_timeout)
141
+ ensure_supported_envd_version!(response, http_client)
107
142
 
108
- new(
143
+ sandbox = new(
109
144
  sandbox_data: response,
110
145
  http_client: http_client,
111
- api_key: api_key,
146
+ api_key: credentials[:api_key],
112
147
  domain: domain
113
148
  )
149
+
150
+ start_mcp_gateway(sandbox, mcp) if mcp
151
+
152
+ sandbox
114
153
  end
115
154
 
116
155
  # Connect to an existing running sandbox
@@ -120,21 +159,18 @@ module E2B
120
159
  # @param api_key [String, nil] API key
121
160
  # @param domain [String] E2B domain
122
161
  # @return [Sandbox] The sandbox instance
123
- def connect(sandbox_id, timeout: nil, api_key: nil, domain: DEFAULT_DOMAIN)
124
- api_key = resolve_api_key(api_key)
125
- http_client = build_http_client(api_key)
126
-
127
- if timeout
128
- response = http_client.post("/sandboxes/#{sandbox_id}/connect",
129
- body: { timeout: timeout })
130
- else
131
- response = http_client.get("/sandboxes/#{sandbox_id}")
132
- end
162
+ def connect(sandbox_id, timeout: DEFAULT_TIMEOUT, api_key: nil, access_token: nil, domain: nil)
163
+ credentials = resolve_credentials(api_key: api_key, access_token: access_token)
164
+ domain = resolve_domain(domain)
165
+ http_client = build_http_client(**credentials, domain: domain)
166
+
167
+ response = http_client.post("/sandboxes/#{sandbox_id}/connect",
168
+ body: { timeout: timeout || DEFAULT_TIMEOUT })
133
169
 
134
170
  new(
135
171
  sandbox_data: response,
136
172
  http_client: http_client,
137
- api_key: api_key,
173
+ api_key: credentials[:api_key],
138
174
  domain: domain
139
175
  )
140
176
  end
@@ -146,54 +182,62 @@ module E2B
146
182
  # @param next_token [String, nil] Pagination token
147
183
  # @param api_key [String, nil] API key
148
184
  # @return [Array<Hash>] List of sandbox info hashes
149
- def list(query: nil, limit: 100, next_token: nil, api_key: nil)
150
- api_key = resolve_api_key(api_key)
151
- http_client = build_http_client(api_key)
152
-
153
- params = { limit: limit }
154
- params[:nextToken] = next_token if next_token
155
- if query
156
- params[:metadata] = query[:metadata].to_json if query[:metadata]
157
- params[:state] = query[:state] if query[:state]
158
- end
185
+ def list(query: nil, limit: 100, next_token: nil, api_key: nil, access_token: nil, domain: nil)
186
+ credentials = resolve_credentials(api_key: api_key, access_token: access_token)
187
+ http_client = build_http_client(**credentials, domain: resolve_domain(domain))
159
188
 
160
- response = http_client.get("/v2/sandboxes", params: params)
189
+ SandboxPaginator.new(
190
+ http_client: http_client,
191
+ query: query,
192
+ limit: limit,
193
+ next_token: next_token
194
+ )
195
+ end
161
196
 
162
- sandboxes = if response.is_a?(Array)
163
- response
164
- elsif response.is_a?(Hash)
165
- response["sandboxes"] || response[:sandboxes] || []
166
- else
167
- []
168
- end
169
- Array(sandboxes)
197
+ # List snapshots for the team, optionally filtered by source sandbox.
198
+ #
199
+ # @param sandbox_id [String, nil] Filter snapshots by source sandbox ID
200
+ # @param limit [Integer] Maximum results per page
201
+ # @param next_token [String, nil] Pagination token
202
+ # @return [SnapshotPaginator]
203
+ def list_snapshots(sandbox_id: nil, limit: 100, next_token: nil, api_key: nil, access_token: nil, domain: nil)
204
+ credentials = resolve_credentials(api_key: api_key, access_token: access_token)
205
+ http_client = build_http_client(**credentials, domain: resolve_domain(domain))
206
+
207
+ SnapshotPaginator.new(
208
+ http_client: http_client,
209
+ sandbox_id: sandbox_id,
210
+ limit: limit,
211
+ next_token: next_token
212
+ )
213
+ end
214
+
215
+ # Delete a snapshot template.
216
+ #
217
+ # @param snapshot_id [String] Snapshot identifier
218
+ # @return [Boolean] true if deleted, false if not found
219
+ def delete_snapshot(snapshot_id, api_key: nil, access_token: nil, domain: nil)
220
+ credentials = resolve_credentials(api_key: api_key, access_token: access_token)
221
+ http_client = build_http_client(**credentials, domain: resolve_domain(domain))
222
+ http_client.delete("/templates/#{snapshot_id}")
223
+ true
224
+ rescue E2B::NotFoundError
225
+ false
170
226
  end
171
227
 
172
228
  # Kill a sandbox by ID
173
229
  #
174
230
  # @param sandbox_id [String] Sandbox ID to kill
175
231
  # @param api_key [String, nil] API key
176
- def kill(sandbox_id, api_key: nil)
177
- api_key = resolve_api_key(api_key)
178
- http_client = build_http_client(api_key)
232
+ def kill(sandbox_id, api_key: nil, access_token: nil, domain: nil)
233
+ credentials = resolve_credentials(api_key: api_key, access_token: access_token)
234
+ http_client = build_http_client(**credentials, domain: resolve_domain(domain))
179
235
  http_client.delete("/sandboxes/#{sandbox_id}")
180
236
  true
181
237
  rescue E2B::NotFoundError
182
238
  true
183
239
  end
184
240
 
185
- private
186
-
187
- def resolve_api_key(api_key)
188
- key = api_key || E2B.configuration&.api_key || ENV["E2B_API_KEY"]
189
- raise ConfigurationError, "E2B API key is required. Set E2B_API_KEY or pass api_key:" unless key && !key.empty?
190
- key
191
- end
192
-
193
- def build_http_client(api_key)
194
- base_url = E2B.configuration&.api_url || Configuration::DEFAULT_API_URL
195
- API::HttpClient.new(base_url: base_url, api_key: api_key)
196
- end
197
241
  end
198
242
 
199
243
  # -------------------------------------------------------------------
@@ -232,7 +276,7 @@ module E2B
232
276
  return false if @end_at && Time.now >= @end_at
233
277
 
234
278
  get_info
235
- true
279
+ @state != "paused"
236
280
  rescue NotFoundError, E2BError
237
281
  false
238
282
  end
@@ -258,14 +302,22 @@ module E2B
258
302
  # Pause the sandbox (saves state for later resume)
259
303
  def pause
260
304
  @http_client.post("/sandboxes/#{@sandbox_id}/pause")
305
+ @state = "paused"
306
+ end
307
+
308
+ # Resume a paused sandbox
309
+ #
310
+ # @param timeout [Integer, nil] New timeout in seconds
311
+ def connect(timeout: nil)
312
+ resume(timeout: timeout)
313
+ self
261
314
  end
262
315
 
263
316
  # Resume a paused sandbox
264
317
  #
265
318
  # @param timeout [Integer, nil] New timeout in seconds
266
319
  def resume(timeout: nil)
267
- body = {}
268
- body[:timeout] = timeout if timeout
320
+ body = { timeout: timeout || DEFAULT_TIMEOUT }
269
321
 
270
322
  response = @http_client.post("/sandboxes/#{@sandbox_id}/connect", body: body)
271
323
  process_sandbox_data(response) if response.is_a?(Hash)
@@ -275,7 +327,37 @@ module E2B
275
327
  #
276
328
  # @return [Hash] Snapshot info with snapshot_id
277
329
  def create_snapshot
278
- @http_client.post("/sandboxes/#{@sandbox_id}/snapshots")
330
+ response = @http_client.post("/sandboxes/#{@sandbox_id}/snapshots")
331
+ Models::SnapshotInfo.from_hash(response)
332
+ end
333
+
334
+ # List snapshots that were created from this sandbox.
335
+ #
336
+ # @param limit [Integer] Maximum results per page
337
+ # @param next_token [String, nil] Pagination token
338
+ # @return [SnapshotPaginator]
339
+ def list_snapshots(limit: 100, next_token: nil)
340
+ self.class.list_snapshots(
341
+ sandbox_id: @sandbox_id,
342
+ limit: limit,
343
+ next_token: next_token,
344
+ api_key: @api_key,
345
+ domain: @domain
346
+ )
347
+ end
348
+
349
+ # Get the MCP URL for the sandbox.
350
+ #
351
+ # @return [String]
352
+ def get_mcp_url
353
+ "https://#{get_host(MCP_PORT)}/mcp"
354
+ end
355
+
356
+ # Get the MCP token for the sandbox.
357
+ #
358
+ # @return [String, nil]
359
+ def get_mcp_token
360
+ @mcp_token ||= @files.read("/etc/mcp-gateway/.token", user: "root")
279
361
  end
280
362
 
281
363
  # Get the host string for a port (without protocol)
@@ -299,12 +381,16 @@ module E2B
299
381
  # @param path [String] File path in the sandbox
300
382
  # @param user [String, nil] Username context
301
383
  # @return [String] Download URL
302
- def download_url(path, user: nil)
303
- encoded_path = URI.encode_www_form_component(path)
384
+ def download_url(path, user: nil, use_signature_expiration: nil)
385
+ user = resolve_legacy_file_user(user)
386
+ query = build_file_url_query(
387
+ path: path,
388
+ user: user,
389
+ operation: "read",
390
+ use_signature_expiration: use_signature_expiration
391
+ )
304
392
  base = "https://#{Services::BaseService::ENVD_PORT}-#{@sandbox_id}.#{@domain}/files"
305
- url = "#{base}?path=#{encoded_path}"
306
- url += "&username=#{URI.encode_www_form_component(user)}" if user
307
- url
393
+ query.empty? ? base : "#{base}?#{URI.encode_www_form(query)}"
308
394
  end
309
395
 
310
396
  # Get URL for uploading a file
@@ -312,12 +398,16 @@ module E2B
312
398
  # @param path [String, nil] Destination path
313
399
  # @param user [String, nil] Username context
314
400
  # @return [String] Upload URL
315
- def upload_url(path = nil, user: nil)
401
+ def upload_url(path = nil, user: nil, use_signature_expiration: nil)
402
+ user = resolve_legacy_file_user(user)
316
403
  base = "https://#{Services::BaseService::ENVD_PORT}-#{@sandbox_id}.#{@domain}/files"
317
- params = []
318
- params << "path=#{URI.encode_www_form_component(path)}" if path
319
- params << "username=#{URI.encode_www_form_component(user)}" if user
320
- params.empty? ? base : "#{base}?#{params.join("&")}"
404
+ query = build_file_url_query(
405
+ path: path,
406
+ user: user,
407
+ operation: "write",
408
+ use_signature_expiration: use_signature_expiration
409
+ )
410
+ query.empty? ? base : "#{base}?#{URI.encode_www_form(query)}"
321
411
  end
322
412
 
323
413
  # Get sandbox metrics (CPU, memory, disk usage)
@@ -371,8 +461,12 @@ module E2B
371
461
  @cpu_count = data["cpuCount"] || data["cpu_count"] || data[:cpuCount]
372
462
  @memory_mb = data["memoryMB"] || data["memory_mb"] || data[:memoryMB]
373
463
  @metadata = data["metadata"] || data[:metadata] || {}
464
+ @state = data["state"] || data[:state] || @state
465
+ @domain = data["domain"] || data[:domain] || @domain
374
466
 
467
+ @envd_version = data["envdVersion"] || data["envd_version"] || data[:envdVersion] || @envd_version
375
468
  @envd_access_token = data["envdAccessToken"] || data["envd_access_token"] || data[:envdAccessToken] || @envd_access_token
469
+ @traffic_access_token = data["trafficAccessToken"] || data["traffic_access_token"] || data[:trafficAccessToken] || @traffic_access_token
376
470
 
377
471
  @started_at = parse_time(data["startedAt"] || data["started_at"] || data[:startedAt])
378
472
  @end_at = parse_time(data["endAt"] || data["end_at"] || data[:endAt])
@@ -383,7 +477,8 @@ module E2B
383
477
  sandbox_id: @sandbox_id,
384
478
  sandbox_domain: @domain,
385
479
  api_key: @api_key,
386
- access_token: @envd_access_token
480
+ access_token: @envd_access_token,
481
+ envd_version: @envd_version
387
482
  }
388
483
 
389
484
  @commands = Services::Commands.new(**service_opts)
@@ -403,5 +498,61 @@ module E2B
403
498
  rescue ArgumentError
404
499
  nil
405
500
  end
501
+
502
+ def resolve_legacy_file_user(user)
503
+ return user unless user.nil? || user.to_s.empty?
504
+
505
+ return Services::BaseService::DEFAULT_USERNAME if legacy_default_user?
506
+
507
+ nil
508
+ end
509
+
510
+ def legacy_default_user?
511
+ return false if @envd_version.nil? || @envd_version.to_s.empty?
512
+
513
+ Gem::Version.new(@envd_version) < Services::BaseService::ENVD_DEFAULT_USER_VERSION
514
+ rescue ArgumentError
515
+ false
516
+ end
517
+
518
+ def build_file_url_query(path:, user:, operation:, use_signature_expiration:)
519
+ if use_signature_expiration && !@envd_access_token
520
+ raise ArgumentError, "Signature expiration can be used only when the sandbox is secured"
521
+ end
522
+
523
+ query = []
524
+ query << ["path", path] if path
525
+ query << ["username", user] if user
526
+
527
+ signature = file_signature(
528
+ path: path || "",
529
+ operation: operation,
530
+ user: user,
531
+ expiration_in_seconds: use_signature_expiration
532
+ )
533
+
534
+ return query unless signature
535
+
536
+ query << ["signature", signature[:signature]]
537
+ query << ["signature_expiration", signature[:expiration].to_s] if signature[:expiration]
538
+ query
539
+ end
540
+
541
+ def file_signature(path:, operation:, user:, expiration_in_seconds:)
542
+ return nil unless @envd_access_token
543
+
544
+ expiration = expiration_in_seconds ? Time.now.to_i + expiration_in_seconds : nil
545
+ raw_user = user || ""
546
+ raw = if expiration
547
+ "#{path}:#{operation}:#{raw_user}:#{@envd_access_token}:#{expiration}"
548
+ else
549
+ "#{path}:#{operation}:#{raw_user}:#{@envd_access_token}"
550
+ end
551
+
552
+ digest = Digest::SHA256.digest(raw)
553
+ encoded = Base64.strict_encode64(digest).sub(/=+\z/, "")
554
+
555
+ { signature: "v1_#{encoded}", expiration: expiration }
556
+ end
406
557
  end
407
558
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module E2B
6
+ # Shared helpers used by both Sandbox class methods and Client.
7
+ #
8
+ # These methods are duplicated across both entry points to preserve
9
+ # the dual API surface (Sandbox.create vs Client#create). This module
10
+ # keeps them in a single place.
11
+ module SandboxHelpers
12
+ private
13
+
14
+ def resolved_template(template, mcp:)
15
+ return template unless template.nil? || template.empty?
16
+
17
+ return Sandbox::DEFAULT_MCP_TEMPLATE if mcp
18
+
19
+ E2B.configuration&.default_template || "base"
20
+ end
21
+
22
+ def normalized_lifecycle(lifecycle:, auto_pause:)
23
+ raw_lifecycle = lifecycle || {
24
+ on_timeout: auto_pause ? "pause" : "kill",
25
+ auto_resume: false
26
+ }
27
+
28
+ on_timeout = raw_lifecycle[:on_timeout] || raw_lifecycle["on_timeout"] || "kill"
29
+ unless %w[kill pause].include?(on_timeout)
30
+ raise ArgumentError, "Lifecycle on_timeout must be 'kill' or 'pause'"
31
+ end
32
+
33
+ auto_resume = if raw_lifecycle.key?(:auto_resume)
34
+ raw_lifecycle[:auto_resume]
35
+ else
36
+ raw_lifecycle["auto_resume"]
37
+ end
38
+
39
+ {
40
+ on_timeout: on_timeout,
41
+ auto_resume: on_timeout == "pause" ? !!auto_resume : false
42
+ }
43
+ end
44
+
45
+ def start_mcp_gateway(sandbox, mcp)
46
+ token = SecureRandom.uuid
47
+ sandbox.instance_variable_set(:@mcp_token, token)
48
+ sandbox.commands.run(
49
+ "mcp-gateway --config #{Shellwords.shellescape(JSON.generate(mcp))}",
50
+ user: "root",
51
+ envs: { "GATEWAY_ACCESS_TOKEN" => token }
52
+ )
53
+ end
54
+
55
+ def ensure_supported_envd_version!(response, http_client)
56
+ envd_version = response["envdVersion"] || response["envd_version"] || response[:envdVersion]
57
+ return if envd_version.nil?
58
+ return unless Gem::Version.new(envd_version) < Gem::Version.new("0.1.0")
59
+
60
+ sandbox_id = response["sandboxID"] || response["sandbox_id"] || response[:sandboxID]
61
+ begin
62
+ http_client.delete("/sandboxes/#{sandbox_id}") if sandbox_id
63
+ rescue NotFoundError
64
+ nil
65
+ end
66
+
67
+ raise TemplateError,
68
+ "You need to update the template to use the new SDK. You can do this by running `e2b template build` in the directory with the template."
69
+ rescue ArgumentError
70
+ nil
71
+ end
72
+
73
+ def resolve_credentials(api_key:, access_token:)
74
+ resolved_api_key = api_key || E2B.configuration&.api_key || ENV["E2B_API_KEY"]
75
+ resolved_access_token = access_token || E2B.configuration&.access_token || ENV["E2B_ACCESS_TOKEN"]
76
+
77
+ unless (resolved_api_key && !resolved_api_key.empty?) || (resolved_access_token && !resolved_access_token.empty?)
78
+ raise ConfigurationError,
79
+ "E2B credentials are required. Set E2B_API_KEY or E2B_ACCESS_TOKEN, or pass api_key:/access_token:."
80
+ end
81
+
82
+ { api_key: resolved_api_key, access_token: resolved_access_token }
83
+ end
84
+
85
+ def resolve_domain(domain)
86
+ domain || E2B.configuration&.domain || ENV["E2B_DOMAIN"] || Configuration::DEFAULT_DOMAIN
87
+ end
88
+
89
+ def build_http_client(api_key:, access_token:, domain:)
90
+ config = E2B.configuration
91
+ base_url = config&.api_url || ENV["E2B_API_URL"] || Configuration.default_api_url(domain)
92
+ API::HttpClient.new(
93
+ base_url: base_url,
94
+ api_key: api_key,
95
+ access_token: access_token,
96
+ logger: config&.logger
97
+ )
98
+ end
99
+ end
100
+ end