open_sandbox 0.1.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: af53de3b813634e21f527311725d8b56fcbdd801a7cdac96c1e6f6517d4cdefa
4
- data.tar.gz: d15cd430e00cb8f1ec697e9ba9b8d14fd650580b262dd14bf938aee8818e729e
3
+ metadata.gz: ad3786f0020200378180e62e770b0ca8463f666d9910e3d70257c3892734400a
4
+ data.tar.gz: cc0b2c9240f79f07e37a69b5e74c9ab400eebb1f969833f4d4f8fa679daa189e
5
5
  SHA512:
6
- metadata.gz: 100a358b7acdea8c18d22ef8790f0b602cac9c41fb16b9d9d7b80a7fbdf875b3d05d774e60a0791326af7cf8f719de5b09e04f8b60f8c3c8c5520bf00a701825
7
- data.tar.gz: 82eef41acf16e3547f0978b2fe35e2b31c82665c92d7fca48c38ba5c46dd233529d9295a4acb444999489ab92e506d8170a940bc75f5688ba78af59e72809243
6
+ metadata.gz: a244435b09be28039d5709d687510172618173a689bc4bfcfa83cf79840b9f05b32d97ccd3a0933e96488e3d77cf324181114a6562f7f01d97371a78b238a6de
7
+ data.tar.gz: 72d656009b9fc0e81faa683ffc67b44ced01731a8fc9ad41cfc0e03118084dfa7ceeec3bec2241fb15eb8a0b2f52e67467a9a360004a6365c13c183e4ba757fa
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenSandbox
6
+ class Execd
7
+ class CodeInterpreter
8
+ CodeResult = Data.define(:stdout, :stderr, :result, :error, :execution_count, :execution_time_ms)
9
+ Context = Data.define(:id, :language)
10
+
11
+ def initialize(execd)
12
+ @execd = execd
13
+ end
14
+
15
+ # Create a new code execution context.
16
+ def create_context(language: "python")
17
+ response = @execd.proxy(method: :post, path: "/code/context", body: { language: language })
18
+ data = JSON.parse(response.body)
19
+ Context.new(id: data["contextId"] || data["id"], language: language)
20
+ end
21
+
22
+ # List active contexts.
23
+ def list_contexts(language: nil)
24
+ query_path = "/code/contexts"
25
+ query_path += "?language=#{language}" if language
26
+ response = @execd.proxy(method: :get, path: query_path)
27
+ data = JSON.parse(response.body)
28
+ contexts = data.is_a?(Array) ? data : (data["contexts"] || [])
29
+ contexts.map { |c| Context.new(id: c["id"], language: c["language"]) }
30
+ end
31
+
32
+ # Delete a context.
33
+ def delete_context(context_id)
34
+ @execd.proxy(method: :delete, path: "/code/contexts/#{context_id}")
35
+ end
36
+
37
+ # Execute code in a context. Returns parsed result from streaming JSON/SSE response.
38
+ # If a block is given, yields each parsed event as it arrives.
39
+ def run(code, context_id: nil, language: "python", &block)
40
+ context = { language: language }
41
+ context[:id] = context_id if context_id
42
+ body = { code: code, context: context }
43
+
44
+ if block_given?
45
+ parse_streaming_response(block) do |chunk_handler|
46
+ @execd.proxy_stream(method: :post, path: "/code", body: body, &chunk_handler)
47
+ end
48
+ else
49
+ response = @execd.proxy(method: :post, path: "/code", body: body)
50
+ parse_sse_code_response(response.body)
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def parse_streaming_response(event_callback)
57
+ buffer = +""
58
+ events = []
59
+
60
+ handler = lambda do |chunk|
61
+ buffer << chunk
62
+ while (newline_index = buffer.index("\n"))
63
+ line = buffer.slice!(0..newline_index).strip
64
+ next if line.empty?
65
+
66
+ event = parse_event_line(line)
67
+ next unless event
68
+
69
+ events << event
70
+ event_callback.call(event)
71
+ end
72
+ end
73
+
74
+ yield handler
75
+ unless buffer.strip.empty?
76
+ event = parse_event_line(buffer.strip)
77
+ if event
78
+ events << event
79
+ event_callback.call(event)
80
+ end
81
+ end
82
+ build_result_from_events(events)
83
+ end
84
+
85
+ private
86
+
87
+ def parse_sse_code_response(body)
88
+ events = body.to_s.each_line.filter_map { |line| parse_event_line(line.strip) }
89
+ build_result_from_events(events)
90
+ end
91
+
92
+ def parse_event_line(line)
93
+ return nil if line.empty?
94
+
95
+ json_str = line.start_with?("data:") ? line.sub(/\Adata:\s*/, "") : line
96
+ return nil if json_str.empty?
97
+
98
+ JSON.parse(json_str)
99
+ rescue JSON::ParserError
100
+ nil
101
+ end
102
+
103
+ def build_result_from_events(events)
104
+ stdout_lines = []
105
+ stderr_lines = []
106
+ result_text = nil
107
+ error_text = nil
108
+ exec_count = nil
109
+ exec_time = nil
110
+
111
+ events.each do |data|
112
+ case data["type"]
113
+ when "stdout"
114
+ stdout_lines << data["text"]
115
+ when "stderr"
116
+ stderr_lines << data["text"]
117
+ when "result"
118
+ result_text = data["text"]
119
+ when "error"
120
+ error = data["error"]
121
+ error_text = data["text"] || data["message"] || error&.dig("evalue") || error&.dig("ename")
122
+ when "execution_complete"
123
+ exec_time = data["executionTimeInMillis"] || data["execution_time"]
124
+ when "execution_count"
125
+ exec_count = data["count"]
126
+ end
127
+ end
128
+
129
+ CodeResult.new(
130
+ stdout: stdout_lines.join,
131
+ stderr: stderr_lines.join,
132
+ result: result_text,
133
+ error: error_text,
134
+ execution_count: exec_count,
135
+ execution_time_ms: exec_time
136
+ )
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenSandbox
4
+ class Execd
5
+ class Commands
6
+ CommandResult = Data.define(:stdout, :stderr, :exit_code, :execution_time_ms)
7
+
8
+ def initialize(execd)
9
+ @execd = execd
10
+ end
11
+
12
+ # Run a shell command and wait for completion.
13
+ # The execd API returns SSE-formatted response.
14
+ def run(command, timeout: 60)
15
+ response = @execd.proxy(
16
+ method: :post,
17
+ path: "/command",
18
+ body: { command: command, timeout: timeout, background: false }
19
+ )
20
+ parse_sse_response(response.body)
21
+ end
22
+
23
+ private
24
+
25
+ def parse_sse_response(body)
26
+ stdout_lines = []
27
+ stderr_lines = []
28
+ exit_code = nil
29
+ exec_time = nil
30
+
31
+ body.to_s.each_line do |line|
32
+ line = line.strip
33
+ next if line.empty?
34
+ json_str = line.start_with?("data:") ? line.sub(/\Adata:\s*/, "") : line
35
+ next if json_str.empty?
36
+
37
+ data = JSON.parse(json_str)
38
+ case data["type"]
39
+ when "stdout"
40
+ stdout_lines << data["text"]
41
+ when "stderr"
42
+ stderr_lines << data["text"]
43
+ when "execution_complete"
44
+ exit_code = data["exitCode"] || data["exit_code"] || 0
45
+ exec_time = data["executionTimeInMillis"] || data["execution_time"]
46
+ end
47
+ rescue JSON::ParserError
48
+ next
49
+ end
50
+
51
+ CommandResult.new(
52
+ stdout: stdout_lines.join,
53
+ stderr: stderr_lines.join,
54
+ exit_code: exit_code || 0,
55
+ execution_time_ms: exec_time
56
+ )
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "cgi"
5
+ require "json"
6
+ require "securerandom"
7
+
8
+ module OpenSandbox
9
+ class Execd
10
+ class Files
11
+ FileInfo = Data.define(:path, :size, :mode, :is_dir, :mod_time)
12
+ WriteEntry = Data.define(:path, :data, :mode)
13
+
14
+ def initialize(execd)
15
+ @execd = execd
16
+ end
17
+
18
+ # Write one or more files into the sandbox.
19
+ def write_files(entries)
20
+ boundary = "----OpenSandboxRuby#{SecureRandom.hex(12)}"
21
+ body = build_multipart_body(entries, boundary)
22
+ response = @execd.proxy(
23
+ method: :post,
24
+ path: "/files/upload",
25
+ raw_body: body,
26
+ headers: { "Content-Type" => "multipart/form-data; boundary=#{boundary}" }
27
+ )
28
+ response.code >= 200 && response.code < 300
29
+ end
30
+
31
+ # Read a file from the sandbox.
32
+ def read_file(path)
33
+ response = @execd.proxy(
34
+ method: :get,
35
+ path: "/files/download?path=#{escape(path)}"
36
+ )
37
+ response.body
38
+ end
39
+
40
+ # Search for files matching a pattern.
41
+ def search(path:, pattern: "*")
42
+ response = @execd.proxy(
43
+ method: :get,
44
+ path: "/files/search?path=#{escape(path)}&pattern=#{escape(pattern)}"
45
+ )
46
+ data = JSON.parse(response.body)
47
+ files = data.is_a?(Array) ? data : (data["files"] || [])
48
+ files.map do |f|
49
+ FileInfo.new(
50
+ path: f["path"],
51
+ size: f["size"],
52
+ mode: f["mode"],
53
+ is_dir: f["isDir"],
54
+ mod_time: f["modTime"]
55
+ )
56
+ end
57
+ end
58
+
59
+ # Get file metadata.
60
+ def info(path)
61
+ response = @execd.proxy(
62
+ method: :get,
63
+ path: "/files/info?path=#{escape(path)}"
64
+ )
65
+ data = JSON.parse(response.body)
66
+ FileInfo.new(
67
+ path: data["path"],
68
+ size: data["size"],
69
+ mode: data["mode"],
70
+ is_dir: data["isDir"],
71
+ mod_time: data["modTime"]
72
+ )
73
+ end
74
+
75
+ # Delete files.
76
+ def delete(paths)
77
+ @execd.proxy(method: :delete, path: "/files", body: { paths: paths })
78
+ end
79
+
80
+ # Create directories (mkdir -p semantics).
81
+ def mkdir(paths, mode: 755)
82
+ body = paths.to_h { |path| [ path, { mode: mode } ] }
83
+ @execd.proxy(method: :post, path: "/directories", body: body)
84
+ end
85
+
86
+ private
87
+
88
+ def build_multipart_body(entries, boundary)
89
+ body = +"".b
90
+ entries.each do |entry|
91
+ metadata = { path: entry.path, mode: entry.mode || 644 }.to_json
92
+ data = entry.data.to_s.b
93
+
94
+ append_binary(body, "--#{boundary}\r\n")
95
+ append_binary(body, "Content-Disposition: form-data; name=\"metadata\"; filename=\"metadata\"\r\n")
96
+ append_binary(body, "Content-Type: application/json\r\n\r\n")
97
+ append_binary(body, metadata)
98
+ append_binary(body, "\r\n")
99
+
100
+ append_binary(body, "--#{boundary}\r\n")
101
+ append_binary(body, "Content-Disposition: form-data; name=\"file\"; filename=\"#{File.basename(entry.path)}\"\r\n")
102
+ append_binary(body, "Content-Type: application/octet-stream\r\n\r\n")
103
+ body << data
104
+ append_binary(body, "\r\n")
105
+ end
106
+ append_binary(body, "--#{boundary}--\r\n")
107
+ body
108
+ end
109
+
110
+ def append_binary(body, value)
111
+ body << value.to_s.b
112
+ end
113
+
114
+ def escape(value)
115
+ CGI.escape(value.to_s)
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "execd/commands"
4
+ require_relative "execd/files"
5
+ require_relative "execd/code_interpreter"
6
+
7
+ module OpenSandbox
8
+ class Execd
9
+ EXECD_PORT = 44772
10
+
11
+ attr_reader :sandbox_id
12
+
13
+ def initialize(sandbox_id:, client: OpenSandbox.client)
14
+ @sandbox_id = sandbox_id
15
+ @client = client
16
+ end
17
+
18
+ def commands
19
+ @commands ||= Execd::Commands.new(self)
20
+ end
21
+
22
+ def files
23
+ @files ||= Execd::Files.new(self)
24
+ end
25
+
26
+ def code_interpreter
27
+ @code_interpreter ||= Execd::CodeInterpreter.new(self)
28
+ end
29
+
30
+ # Low-level proxy helper — routes HTTP requests through the OpenSandbox server proxy
31
+ def proxy(method:, path:, body: nil, headers: {}, raw_body: nil)
32
+ @client.sandboxes.proxy(
33
+ @sandbox_id,
34
+ port: EXECD_PORT,
35
+ method: method,
36
+ path: path,
37
+ body: body,
38
+ headers: headers,
39
+ raw_body: raw_body
40
+ )
41
+ end
42
+
43
+ def proxy_stream(method:, path:, body: nil, headers: {}, raw_body: nil, &block)
44
+ @client.sandboxes.proxy_stream(
45
+ @sandbox_id,
46
+ port: EXECD_PORT,
47
+ method: method,
48
+ path: path,
49
+ body: body,
50
+ headers: headers,
51
+ raw_body: raw_body,
52
+ &block
53
+ )
54
+ end
55
+
56
+ def ping
57
+ response = proxy(method: :get, path: "/ping")
58
+ response.code == 200
59
+ rescue OpenSandbox::Error
60
+ false
61
+ end
62
+ end
63
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "json"
4
4
  require "httparty"
5
+ require "net/http"
6
+ require "uri"
5
7
 
6
8
  module OpenSandbox
7
9
  # Low-level HTTP client for the OpenSandbox API.
@@ -38,13 +40,18 @@ module OpenSandbox
38
40
 
39
41
  # Proxy raw HTTP request to sandbox internal service
40
42
  # Returns the raw HTTParty response
41
- def proxy(method, path, body: nil, headers: {})
42
- request(method, path, body: body, headers: headers, raw: true)
43
+ def proxy(method, path, body: nil, headers: {}, raw_body: nil)
44
+ request(method, path, body: body, headers: headers, raw: true, raw_body: raw_body)
45
+ end
46
+
47
+ # Stream raw HTTP response body chunks. Intended for SSE/JSONL execd proxy endpoints.
48
+ def proxy_stream(method, path, body: nil, headers: {}, raw_body: nil, &block)
49
+ stream_request(method, path, body: body, headers: headers, raw_body: raw_body, &block)
43
50
  end
44
51
 
45
52
  private
46
53
 
47
- def request(method, path, query: {}, body: nil, headers: {}, raw: false)
54
+ def request(method, path, query: {}, body: nil, headers: {}, raw: false, raw_body: nil)
48
55
  url = "#{base_url}#{path}"
49
56
  options = {
50
57
  timeout: timeout,
@@ -53,7 +60,9 @@ module OpenSandbox
53
60
  }
54
61
  options[:query] = query if query && !query.empty?
55
62
 
56
- if body
63
+ if raw_body
64
+ options[:body] = raw_body
65
+ elsif body
57
66
  options[:body] = body.to_json
58
67
  options[:headers] = (options[:headers] || {}).merge("Content-Type" => "application/json")
59
68
  end
@@ -74,6 +83,49 @@ module OpenSandbox
74
83
  headers.merge(extra)
75
84
  end
76
85
 
86
+ def stream_request(method, path, body: nil, headers: {}, raw_body: nil)
87
+ uri = URI("#{base_url}#{path}")
88
+ http = Net::HTTP.new(uri.host, uri.port)
89
+ http.use_ssl = uri.scheme == "https"
90
+ http.open_timeout = DEFAULT_OPEN_TIMEOUT
91
+ http.read_timeout = timeout
92
+
93
+ request_class = case method.to_sym
94
+ when :get then Net::HTTP::Get
95
+ when :post then Net::HTTP::Post
96
+ when :put then Net::HTTP::Put
97
+ when :delete then Net::HTTP::Delete
98
+ else
99
+ raise InvalidRequestError, "Unsupported streaming method: #{method}"
100
+ end
101
+
102
+ req = request_class.new(uri)
103
+ build_headers(headers).each { |key, value| req[key] = value }
104
+
105
+ if raw_body
106
+ req.body = raw_body
107
+ elsif body
108
+ req.body = body.to_json
109
+ req["Content-Type"] ||= "application/json"
110
+ end
111
+
112
+ http.request(req) do |response|
113
+ unless response.code.to_i.between?(200, 299)
114
+ error_body = +""
115
+ response.read_body { |chunk| error_body << chunk }
116
+ raise Error, "Unexpected status #{response.code}: #{error_body}"
117
+ end
118
+
119
+ response.read_body do |chunk|
120
+ yield chunk.force_encoding("UTF-8")
121
+ end
122
+ end
123
+ rescue ::Net::OpenTimeout, ::Net::ReadTimeout => e
124
+ raise ConnectionError, "Request timed out: #{e.message}"
125
+ rescue ::SocketError, ::Errno::ECONNREFUSED => e
126
+ raise ConnectionError, "Cannot connect to sandbox server at #{base_url}: #{e.message}"
127
+ end
128
+
77
129
  def handle_response(response)
78
130
  case response.code
79
131
  when 200, 201, 202
@@ -137,12 +137,12 @@ module OpenSandbox
137
137
  # @param body [Hash, nil] request body
138
138
  # @param headers [Hash] additional headers
139
139
  # @return [HTTParty::Response] raw response
140
- def proxy(sandbox_id, port:, method: :get, path: "/", body: nil, headers: {})
141
- proxy_path = path == "/" || path.empty? \
142
- ? "/v1/sandboxes/#{sandbox_id}/proxy/#{port}" \
143
- : "/v1/sandboxes/#{sandbox_id}/proxy/#{port}/#{path.delete_prefix('/')}"
140
+ def proxy(sandbox_id, port:, method: :get, path: "/", body: nil, headers: {}, raw_body: nil)
141
+ @http.proxy(method, proxy_path(sandbox_id, port, path), body: body, headers: headers, raw_body: raw_body)
142
+ end
144
143
 
145
- @http.proxy(method, proxy_path, body: body, headers: headers)
144
+ def proxy_stream(sandbox_id, port:, method: :get, path: "/", body: nil, headers: {}, raw_body: nil, &block)
145
+ @http.proxy_stream(method, proxy_path(sandbox_id, port, path), body: body, headers: headers, raw_body: raw_body, &block)
146
146
  end
147
147
 
148
148
  # ── Diagnostics ─────────────────────────────────────────────────────────
@@ -220,6 +220,12 @@ module OpenSandbox
220
220
 
221
221
  private
222
222
 
223
+ def proxy_path(sandbox_id, port, path)
224
+ path == "/" || path.empty? \
225
+ ? "/v1/sandboxes/#{sandbox_id}/proxy/#{port}" \
226
+ : "/v1/sandboxes/#{sandbox_id}/proxy/#{port}/#{path.delete_prefix('/')}"
227
+ end
228
+
223
229
  def build_image_spec(image, auth)
224
230
  spec = { "uri" => image }
225
231
  if auth
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenSandbox
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/open_sandbox.rb CHANGED
@@ -3,3 +3,4 @@
3
3
  require_relative "open_sandbox/version"
4
4
  require_relative "open_sandbox/client"
5
5
  require_relative "open_sandbox/runner"
6
+ require_relative "open_sandbox/execd"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: open_sandbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Grayson Chen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-30 00:00:00.000000000 Z
11
+ date: 2026-05-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: httparty
@@ -48,6 +48,10 @@ files:
48
48
  - lib/open_sandbox.rb
49
49
  - lib/open_sandbox/client.rb
50
50
  - lib/open_sandbox/errors.rb
51
+ - lib/open_sandbox/execd.rb
52
+ - lib/open_sandbox/execd/code_interpreter.rb
53
+ - lib/open_sandbox/execd/commands.rb
54
+ - lib/open_sandbox/execd/files.rb
51
55
  - lib/open_sandbox/http_client.rb
52
56
  - lib/open_sandbox/models.rb
53
57
  - lib/open_sandbox/pools.rb
@@ -71,7 +75,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
71
75
  requirements:
72
76
  - - ">="
73
77
  - !ruby/object:Gem::Version
74
- version: 3.1.0
78
+ version: 3.2.0
75
79
  required_rubygems_version: !ruby/object:Gem::Requirement
76
80
  requirements:
77
81
  - - ">="