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,350 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module E2B
6
+ module Services
7
+ # Result of a command execution in the sandbox.
8
+ #
9
+ # Returned by {CommandHandle#wait} when the command finishes successfully.
10
+ #
11
+ # @example
12
+ # result = handle.wait
13
+ # puts result.stdout
14
+ # puts result.exit_code # => 0
15
+ class CommandResult
16
+ # @return [String] Standard output accumulated from the command
17
+ attr_reader :stdout
18
+
19
+ # @return [String] Standard error accumulated from the command
20
+ attr_reader :stderr
21
+
22
+ # @return [Integer] Exit code of the command (0 indicates success)
23
+ attr_reader :exit_code
24
+
25
+ # @return [String, nil] Error message from command execution, if any
26
+ attr_reader :error
27
+
28
+ # @param stdout [String] Standard output
29
+ # @param stderr [String] Standard error
30
+ # @param exit_code [Integer] Exit code
31
+ # @param error [String, nil] Error message
32
+ def initialize(stdout: "", stderr: "", exit_code: 0, error: nil)
33
+ @stdout = stdout
34
+ @stderr = stderr
35
+ @exit_code = exit_code
36
+ @error = error
37
+ end
38
+
39
+ # Check if the command finished successfully.
40
+ #
41
+ # @return [Boolean] true if exit code is 0 and no error is present
42
+ def success?
43
+ @exit_code == 0 && @error.nil?
44
+ end
45
+
46
+ # Combined output (stdout + stderr).
47
+ #
48
+ # @return [String]
49
+ def output
50
+ "#{@stdout}#{@stderr}"
51
+ end
52
+
53
+ # @return [String]
54
+ def to_s
55
+ "#<#{self.class.name} exit_code=#{@exit_code} stdout=#{@stdout.bytesize}B stderr=#{@stderr.bytesize}B>"
56
+ end
57
+
58
+ alias inspect to_s
59
+ end
60
+
61
+ # Handle for an executing command in the sandbox.
62
+ #
63
+ # Provides methods for waiting on the command, sending stdin input,
64
+ # killing the process, disconnecting from the event stream, and
65
+ # iterating over stdout/stderr/pty output as it arrives.
66
+ #
67
+ # Instances are returned by {Commands#run} (background mode),
68
+ # {Pty#create}, and {Pty#connect}.
69
+ #
70
+ # @example Wait for a command and get output
71
+ # handle = sandbox.pty.create
72
+ # result = handle.wait(on_pty: ->(data) { print data })
73
+ #
74
+ # @example Iterate over output
75
+ # handle.each do |stdout, stderr, pty|
76
+ # print stdout if stdout
77
+ # end
78
+ #
79
+ # @example Kill a long-running process
80
+ # handle.kill
81
+ class CommandHandle
82
+ include Enumerable
83
+
84
+ # @return [Integer, nil] Process ID of the running command
85
+ attr_reader :pid
86
+
87
+ # Initialize a new CommandHandle.
88
+ #
89
+ # Callers should not construct this directly; it is created by service
90
+ # methods such as {Pty#create} or {Commands#run}.
91
+ #
92
+ # @param pid [Integer, nil] Process ID
93
+ # @param handle_kill [Proc] Proc that sends SIGKILL to the process
94
+ # @param handle_send_stdin [Proc] Proc that sends data to stdin/pty
95
+ # @param events_proc [Proc, nil] Proc that accepts a block and yields
96
+ # parsed events as they arrive. Each event is a Hash with keys like
97
+ # "event" => { "Start" => ..., "Data" => ..., "End" => ... }.
98
+ # May be nil if the result is already materialized.
99
+ # @param result [Hash, nil] Pre-materialized result from a synchronous
100
+ # RPC call. Expected keys: :events, :stdout, :stderr, :exit_code.
101
+ def initialize(pid:, handle_kill:, handle_send_stdin:, events_proc: nil, result: nil)
102
+ @pid = pid
103
+ @handle_kill = handle_kill
104
+ @handle_send_stdin = handle_send_stdin
105
+ @events_proc = events_proc
106
+ @result = result
107
+ @disconnected = false
108
+ @finished = false
109
+ @stdout = ""
110
+ @stderr = ""
111
+ @exit_code = nil
112
+ @error = nil
113
+ @mutex = Mutex.new
114
+ end
115
+
116
+ # Wait for the command to finish and return the result.
117
+ #
118
+ # If the command exits with a non-zero exit code, raises
119
+ # {CommandExitError}. Callbacks are invoked as output
120
+ # arrives (or immediately if the result is already materialized).
121
+ #
122
+ # @param on_stdout [Proc, nil] Called with each chunk of stdout (String)
123
+ # @param on_stderr [Proc, nil] Called with each chunk of stderr (String)
124
+ # @param on_pty [Proc, nil] Called with each chunk of PTY output (String)
125
+ # @return [CommandResult]
126
+ # @raise [CommandExitError] if exit code is non-zero
127
+ def wait(on_stdout: nil, on_stderr: nil, on_pty: nil)
128
+ consume_events(on_stdout: on_stdout, on_stderr: on_stderr, on_pty: on_pty)
129
+ build_result.tap do |cmd_result|
130
+ unless cmd_result.success?
131
+ raise CommandExitError.new(
132
+ stdout: cmd_result.stdout,
133
+ stderr: cmd_result.stderr,
134
+ exit_code: cmd_result.exit_code,
135
+ error: cmd_result.error
136
+ )
137
+ end
138
+ end
139
+ end
140
+
141
+ # Kill the running command with SIGKILL.
142
+ #
143
+ # @return [Boolean] true if the signal was sent successfully, false on error
144
+ def kill
145
+ @handle_kill.call
146
+ end
147
+
148
+ # Send data to the command's stdin (or PTY input).
149
+ #
150
+ # @param data [String] Data to write
151
+ # @return [void]
152
+ def send_stdin(data)
153
+ @handle_send_stdin.call(data)
154
+ end
155
+
156
+ # Disconnect from the command event stream without killing the process.
157
+ #
158
+ # After disconnecting, {#wait} and {#each} will return immediately
159
+ # with whatever output has been accumulated so far. The underlying
160
+ # process continues running and can be reconnected to via
161
+ # {Commands#connect} or {Pty#connect}.
162
+ #
163
+ # @return [void]
164
+ def disconnect
165
+ @disconnected = true
166
+ end
167
+
168
+ # Iterate over command output as it arrives.
169
+ #
170
+ # Yields a triple of [stdout, stderr, pty] for each chunk of output.
171
+ # Exactly one of the three will be non-nil per iteration.
172
+ #
173
+ # @yield [stdout, stderr, pty] Output chunks
174
+ # @yieldparam stdout [String, nil] Stdout data, or nil
175
+ # @yieldparam stderr [String, nil] Stderr data, or nil
176
+ # @yieldparam pty [String, nil] PTY data, or nil
177
+ # @return [void]
178
+ def each(&block)
179
+ return enum_for(:each) unless block_given?
180
+
181
+ if @result
182
+ # Iterate over pre-materialized events
183
+ iterate_materialized_events(&block)
184
+ elsif @events_proc
185
+ # Iterate over streaming events
186
+ iterate_streaming_events(&block)
187
+ end
188
+ end
189
+
190
+ private
191
+
192
+ # Consume all remaining events, invoking callbacks along the way.
193
+ #
194
+ # @param on_stdout [Proc, nil]
195
+ # @param on_stderr [Proc, nil]
196
+ # @param on_pty [Proc, nil]
197
+ # @return [void]
198
+ def consume_events(on_stdout: nil, on_stderr: nil, on_pty: nil)
199
+ return if @finished
200
+
201
+ each do |stdout_chunk, stderr_chunk, pty_chunk|
202
+ if stdout_chunk
203
+ on_stdout&.call(stdout_chunk)
204
+ @mutex.synchronize { @stdout += stdout_chunk }
205
+ end
206
+ if stderr_chunk
207
+ on_stderr&.call(stderr_chunk)
208
+ @mutex.synchronize { @stderr += stderr_chunk }
209
+ end
210
+ if pty_chunk
211
+ on_pty&.call(pty_chunk)
212
+ end
213
+ end
214
+
215
+ @finished = true
216
+ end
217
+
218
+ # Build a {CommandResult} from accumulated state.
219
+ #
220
+ # If we have a pre-materialized result and haven't iterated events,
221
+ # use its values directly.
222
+ #
223
+ # @return [CommandResult]
224
+ def build_result
225
+ if @result && !@finished
226
+ # Use the pre-materialized result from the synchronous RPC call
227
+ CommandResult.new(
228
+ stdout: @result[:stdout] || "",
229
+ stderr: @result[:stderr] || "",
230
+ exit_code: @result[:exit_code] || 0,
231
+ error: @result[:error]
232
+ )
233
+ else
234
+ CommandResult.new(
235
+ stdout: @stdout,
236
+ stderr: @stderr,
237
+ exit_code: @exit_code || 0,
238
+ error: @error
239
+ )
240
+ end
241
+ end
242
+
243
+ # Iterate over events from a pre-materialized result hash.
244
+ #
245
+ # The result hash is produced by {EnvdHttpClient#handle_streaming_rpc}
246
+ # or {EnvdHttpClient#handle_rpc_response} and contains an :events array
247
+ # with parsed JSON hashes.
248
+ #
249
+ # @yield [stdout, stderr, pty]
250
+ # @return [void]
251
+ def iterate_materialized_events
252
+ events = @result[:events] || []
253
+ events.each do |event_hash|
254
+ break if @disconnected
255
+
256
+ next unless event_hash.is_a?(Hash) && event_hash["event"]
257
+
258
+ event = event_hash["event"]
259
+ process_event(event) do |stdout_chunk, stderr_chunk, pty_chunk|
260
+ yield stdout_chunk, stderr_chunk, pty_chunk
261
+ end
262
+ end
263
+ end
264
+
265
+ # Iterate over events from a streaming proc.
266
+ #
267
+ # The events_proc is called with a block; it yields parsed event
268
+ # hashes as they arrive from the RPC stream.
269
+ #
270
+ # @yield [stdout, stderr, pty]
271
+ # @return [void]
272
+ def iterate_streaming_events
273
+ @events_proc.call do |event_hash|
274
+ break if @disconnected
275
+
276
+ next unless event_hash.is_a?(Hash) && event_hash["event"]
277
+
278
+ event = event_hash["event"]
279
+ process_event(event) do |stdout_chunk, stderr_chunk, pty_chunk|
280
+ yield stdout_chunk, stderr_chunk, pty_chunk
281
+ end
282
+ end
283
+ end
284
+
285
+ # Process a single event from the stream, extracting output data.
286
+ #
287
+ # Event shapes from the envd process service:
288
+ # - Start: { "Start" => { "pid" => 123 } }
289
+ # - Data: { "Data" => { "stdout" => "base64", "stderr" => "base64", "pty" => "base64" } }
290
+ # - End: { "End" => { "exitCode" => 0, "error" => "...", "status" => "..." } }
291
+ #
292
+ # @param event [Hash] The event sub-hash (value of "event" key)
293
+ # @yield [stdout, stderr, pty]
294
+ # @return [void]
295
+ def process_event(event)
296
+ # Handle Data event
297
+ data_event = event["Data"] || event["data"]
298
+ if data_event
299
+ stdout_chunk = decode_base64(data_event["stdout"])
300
+ stderr_chunk = decode_base64(data_event["stderr"])
301
+ pty_chunk = decode_base64(data_event["pty"])
302
+
303
+ yield(stdout_chunk, nil, nil) if stdout_chunk && !stdout_chunk.empty?
304
+ yield(nil, stderr_chunk, nil) if stderr_chunk && !stderr_chunk.empty?
305
+ yield(nil, nil, pty_chunk) if pty_chunk && !pty_chunk.empty?
306
+ end
307
+
308
+ # Handle End event
309
+ end_event = event["End"] || event["end"]
310
+ return unless end_event
311
+
312
+ exit_value = end_event["exitCode"] || end_event["exit_code"] || end_event["status"]
313
+ @exit_code = parse_exit_code(exit_value)
314
+ @error = end_event["error"] if end_event["error"] && !end_event["error"].empty?
315
+ end
316
+
317
+ # Decode a base64-encoded string.
318
+ #
319
+ # @param data [String, nil] Base64-encoded data
320
+ # @return [String, nil] Decoded string, or nil if input is nil/empty
321
+ def decode_base64(data)
322
+ return nil if data.nil? || data.empty?
323
+
324
+ Base64.decode64(data).force_encoding("UTF-8")
325
+ rescue StandardError
326
+ data.to_s
327
+ end
328
+
329
+ # Parse an exit code from various envd response formats.
330
+ #
331
+ # Handles integer values, string integers, and "exit status N" strings.
332
+ #
333
+ # @param value [Integer, String, nil] Raw exit code value
334
+ # @return [Integer]
335
+ def parse_exit_code(value)
336
+ return 0 if value.nil?
337
+ return value if value.is_a?(Integer)
338
+
339
+ str = value.to_s
340
+ if str =~ /exit status (\d+)/i
341
+ ::Regexp.last_match(1).to_i
342
+ elsif str =~ /^(\d+)$/
343
+ ::Regexp.last_match(1).to_i
344
+ else
345
+ 1
346
+ end
347
+ end
348
+ end
349
+ end
350
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "base64"
5
+
6
+ module E2B
7
+ module Services
8
+ # Command execution service for E2B sandbox
9
+ #
10
+ # Provides methods to run terminal commands inside the sandbox.
11
+ # Uses Connect RPC protocol to communicate with the envd process service.
12
+ #
13
+ # @example Basic usage
14
+ # result = sandbox.commands.run("ls -la")
15
+ # puts result.stdout
16
+ #
17
+ # @example With streaming callbacks
18
+ # sandbox.commands.run("npm install",
19
+ # on_stdout: ->(data) { print data },
20
+ # on_stderr: ->(data) { warn data }
21
+ # )
22
+ #
23
+ # @example Background command
24
+ # handle = sandbox.commands.run("sleep 10", background: true)
25
+ # handle.kill
26
+ class Commands < BaseService
27
+ # Run a command in the sandbox
28
+ #
29
+ # @param cmd [String] Command to execute (run via /bin/bash -l -c)
30
+ # @param background [Boolean] Run in background, returns CommandHandle
31
+ # @param envs [Hash{String => String}, nil] Environment variables
32
+ # @param user [String, nil] User to run the command as
33
+ # @param cwd [String, nil] Working directory
34
+ # @param on_stdout [Proc, nil] Callback for stdout data
35
+ # @param on_stderr [Proc, nil] Callback for stderr data
36
+ # @param timeout [Integer] Command timeout in seconds (default: 60)
37
+ # @param request_timeout [Integer, nil] HTTP request timeout in seconds
38
+ # @return [CommandResult, CommandHandle] Result or handle for background commands
39
+ #
40
+ # @raise [CommandExitError] If exit code is non-zero (foreground only)
41
+ def run(cmd, background: false, envs: nil, user: nil, cwd: nil,
42
+ on_stdout: nil, on_stderr: nil, timeout: 60, request_timeout: nil, &block)
43
+ # Build the process spec - official SDK always uses /bin/bash -l -c
44
+ process_spec = {
45
+ cmd: "/bin/bash",
46
+ args: ["-l", "-c", cmd.to_s]
47
+ }
48
+
49
+ if envs && !envs.empty?
50
+ env_map = {}
51
+ envs.each { |k, v| env_map[k.to_s] = v.to_s }
52
+ process_spec[:envs] = env_map
53
+ end
54
+
55
+ process_spec[:cwd] = cwd if cwd
56
+
57
+ body = { process: process_spec }
58
+
59
+ # Set up streaming callback
60
+ streaming_callback = nil
61
+ if on_stdout || on_stderr || block_given?
62
+ streaming_callback = lambda { |event_data|
63
+ stdout_chunk = event_data[:stdout]
64
+ stderr_chunk = event_data[:stderr]
65
+
66
+ on_stdout&.call(stdout_chunk) if stdout_chunk && !stdout_chunk.empty?
67
+ on_stderr&.call(stderr_chunk) if stderr_chunk && !stderr_chunk.empty?
68
+
69
+ if block_given?
70
+ yield(:stdout, stdout_chunk) if stdout_chunk && !stdout_chunk.empty?
71
+ yield(:stderr, stderr_chunk) if stderr_chunk && !stderr_chunk.empty?
72
+ end
73
+ }
74
+ end
75
+
76
+ effective_timeout = request_timeout || (timeout + 30)
77
+
78
+ response = envd_rpc("process.Process", "Start",
79
+ body: body,
80
+ timeout: effective_timeout,
81
+ on_event: streaming_callback)
82
+
83
+ pid = extract_pid(response)
84
+
85
+ if background
86
+ # Return a CommandHandle for background processes
87
+ CommandHandle.new(
88
+ pid: pid,
89
+ handle_kill: -> { kill(pid) },
90
+ handle_send_stdin: ->(data) { send_stdin(pid, data) },
91
+ result: response
92
+ )
93
+ else
94
+ # Return CommandResult for foreground processes
95
+ result = build_result(response)
96
+
97
+ # Raise on non-zero exit code (matching official SDK behavior)
98
+ if result.exit_code != 0
99
+ raise CommandExitError.new(
100
+ stdout: result.stdout,
101
+ stderr: result.stderr,
102
+ exit_code: result.exit_code,
103
+ error: result.error
104
+ )
105
+ end
106
+
107
+ result
108
+ end
109
+ end
110
+
111
+ # List running processes
112
+ #
113
+ # @param request_timeout [Integer, nil] Request timeout in seconds
114
+ # @return [Array<Hash>] List of running processes with pid, config, tag
115
+ def list(request_timeout: nil)
116
+ response = envd_rpc("process.Process", "List",
117
+ body: {},
118
+ timeout: request_timeout || 30)
119
+
120
+ processes = []
121
+ events = response[:events] || []
122
+ events.each do |event|
123
+ next unless event.is_a?(Hash)
124
+ if event["processes"]
125
+ processes.concat(Array(event["processes"]))
126
+ end
127
+ end
128
+ processes
129
+ end
130
+
131
+ # Kill a running process
132
+ #
133
+ # @param pid [Integer] Process ID to kill
134
+ # @param request_timeout [Integer, nil] Request timeout in seconds
135
+ # @return [Boolean] true if killed, false if not found
136
+ def kill(pid, request_timeout: nil)
137
+ envd_rpc("process.Process", "SendSignal",
138
+ body: {
139
+ process: { pid: pid },
140
+ signal: 9 # SIGKILL
141
+ },
142
+ timeout: request_timeout || 30)
143
+ true
144
+ rescue E2B::NotFoundError
145
+ false
146
+ rescue E2B::E2BError
147
+ false
148
+ end
149
+
150
+ # Send stdin data to a running process
151
+ #
152
+ # @param pid [Integer] Process ID
153
+ # @param data [String] Data to send to stdin
154
+ # @param request_timeout [Integer, nil] Request timeout in seconds
155
+ def send_stdin(pid, data, request_timeout: nil)
156
+ encoded = Base64.strict_encode64(data.to_s)
157
+ envd_rpc("process.Process", "SendInput",
158
+ body: {
159
+ process: { pid: pid },
160
+ input: { stdin: encoded }
161
+ },
162
+ timeout: request_timeout || 30)
163
+ end
164
+
165
+ # Connect to a running process
166
+ #
167
+ # @param pid [Integer] Process ID to connect to
168
+ # @param timeout [Integer] Connection timeout in seconds
169
+ # @param request_timeout [Integer, nil] Request timeout in seconds
170
+ # @return [CommandHandle] Handle for the connected process
171
+ def connect(pid, timeout: 60, request_timeout: nil)
172
+ response = envd_rpc("process.Process", "Connect",
173
+ body: { process: { pid: pid } },
174
+ timeout: request_timeout || (timeout + 30))
175
+
176
+ CommandHandle.new(
177
+ pid: pid,
178
+ handle_kill: -> { kill(pid) },
179
+ handle_send_stdin: ->(data) { send_stdin(pid, data) },
180
+ result: response
181
+ )
182
+ end
183
+
184
+ private
185
+
186
+ # Extract PID from streaming response events
187
+ def extract_pid(response)
188
+ events = response[:events] || []
189
+ events.each do |event|
190
+ next unless event.is_a?(Hash) && event["event"]
191
+ start_event = event["event"]["Start"] || event["event"]["start"]
192
+ if start_event && start_event["pid"]
193
+ return start_event["pid"].to_i
194
+ end
195
+ end
196
+ nil
197
+ end
198
+
199
+ # Build CommandResult from response
200
+ def build_result(response)
201
+ stdout = response[:stdout] || ""
202
+ stderr = response[:stderr] || ""
203
+ exit_code = response[:exit_code]
204
+ error = nil
205
+
206
+ # Parse exit code
207
+ exit_code = exit_code.to_i if exit_code.is_a?(String) && exit_code.match?(/^\d+$/)
208
+ exit_code ||= 0
209
+
210
+ # Check events for error info
211
+ events = response[:events] || []
212
+ events.each do |event|
213
+ next unless event.is_a?(Hash) && event["event"]
214
+ end_event = event["event"]["End"] || event["event"]["end"]
215
+ if end_event
216
+ error = end_event["error"] if end_event["error"] && !end_event["error"].empty?
217
+ end
218
+ end
219
+
220
+ CommandResult.new(
221
+ stdout: stdout,
222
+ stderr: stderr,
223
+ exit_code: exit_code,
224
+ error: error
225
+ )
226
+ end
227
+ end
228
+ end
229
+ end