open_sandbox 0.1.0 → 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 +4 -4
- data/README.md +37 -0
- data/lib/open_sandbox/execd/code_interpreter.rb +140 -0
- data/lib/open_sandbox/execd/commands.rb +60 -0
- data/lib/open_sandbox/execd/files.rb +119 -0
- data/lib/open_sandbox/execd.rb +63 -0
- data/lib/open_sandbox/http_client.rb +57 -5
- data/lib/open_sandbox/sandboxes.rb +11 -5
- data/lib/open_sandbox/version.rb +1 -1
- data/lib/open_sandbox.rb +1 -0
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ad3786f0020200378180e62e770b0ca8463f666d9910e3d70257c3892734400a
|
|
4
|
+
data.tar.gz: cc0b2c9240f79f07e37a69b5e74c9ab400eebb1f969833f4d4f8fa679daa189e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a244435b09be28039d5709d687510172618173a689bc4bfcfa83cf79840b9f05b32d97ccd3a0933e96488e3d77cf324181114a6562f7f01d97371a78b238a6de
|
|
7
|
+
data.tar.gz: 72d656009b9fc0e81faa683ffc67b44ced01731a8fc9ad41cfc0e03118084dfa7ceeec3bec2241fb15eb8a0b2f52e67467a9a360004a6365c13c183e4ba757fa
|
data/README.md
CHANGED
|
@@ -1,10 +1,43 @@
|
|
|
1
1
|
# OpenSandbox
|
|
2
2
|
|
|
3
|
+
[中文版](README.zh-CN.md)
|
|
4
|
+
|
|
3
5
|
Ruby SDK for the [open-sandbox.ai](https://open-sandbox.ai) API — manage isolated container sandboxes for secure code execution.
|
|
4
6
|
|
|
7
|
+
> ⚠️ **This is an unofficial Ruby gem**, not affiliated with or maintained by the OpenSandbox team.
|
|
8
|
+
> Official project: [alibaba/OpenSandbox](https://github.com/alibaba/OpenSandbox)
|
|
9
|
+
|
|
5
10
|
[](https://rubygems.org/gems/open_sandbox)
|
|
6
11
|
[](https://github.com/graysonchen/open_sandbox-sdk-ruby/actions)
|
|
7
12
|
|
|
13
|
+
## What is OpenSandbox?
|
|
14
|
+
|
|
15
|
+
[OpenSandbox](https://github.com/alibaba/OpenSandbox) is a **general-purpose sandbox platform for AI applications**, open-sourced by Alibaba and listed in the [CNCF Landscape](https://landscape.cncf.io/?item=orchestration-management--scheduling-orchestration--opensandbox).
|
|
16
|
+
|
|
17
|
+
### The Problem It Solves
|
|
18
|
+
|
|
19
|
+
Modern AI applications — coding agents, browser automation, RL training, AI code execution — need to **run untrusted or model-generated code safely**. Spinning up ephemeral containers manually, wiring up lifecycle management, handling networking, streaming logs, and tearing everything down reliably is complex and error-prone.
|
|
20
|
+
|
|
21
|
+
OpenSandbox solves this by providing:
|
|
22
|
+
|
|
23
|
+
- **Isolated runtime environments** — each sandbox runs in its own container, fully isolated from the host and other workloads. Supports secure runtimes like gVisor, Kata Containers, and Firecracker microVM.
|
|
24
|
+
- **Unified sandbox lifecycle API** — provision, monitor, pause, resume, renew, and terminate sandboxes via a single consistent API, backed by Docker or Kubernetes.
|
|
25
|
+
- **In-sandbox execution** — run shell commands, execute multi-language code (Python, Node.js, etc.), manage files, expose ports, and stream logs/metrics from inside the sandbox.
|
|
26
|
+
- **Resource Pools** — pre-warm sandbox pools to eliminate cold-start latency for high-throughput workloads.
|
|
27
|
+
- **Network policy** — per-sandbox ingress/egress controls with unified gateway routing.
|
|
28
|
+
|
|
29
|
+
### Typical Use Cases
|
|
30
|
+
|
|
31
|
+
| Scenario | Description |
|
|
32
|
+
|---|---|
|
|
33
|
+
| **Coding Agents** | Run Claude Code, Gemini CLI, Codex, and other agent tools in isolated sandboxes |
|
|
34
|
+
| **AI Code Execution** | Safely execute model-generated code, stream outputs, iterate with reproducible environments |
|
|
35
|
+
| **Browser Automation** | Run Chrome / Playwright workloads with controlled runtime and networking |
|
|
36
|
+
| **Remote Development** | Host VS Code Web and cloud desktop environments securely |
|
|
37
|
+
| **RL Training** | Launch reinforcement learning tasks with managed sandbox lifecycle and resource controls |
|
|
38
|
+
|
|
39
|
+
This Ruby gem wraps the OpenSandbox HTTP API so you can use all of the above from your Ruby or Rails application.
|
|
40
|
+
|
|
8
41
|
## Installation
|
|
9
42
|
|
|
10
43
|
Add to your Gemfile:
|
|
@@ -181,6 +214,10 @@ bundle install
|
|
|
181
214
|
bundle exec rake test # run all tests
|
|
182
215
|
```
|
|
183
216
|
|
|
217
|
+
## Other SDKs
|
|
218
|
+
|
|
219
|
+
OpenSandbox provides official SDKs for multiple languages. See the full list at [alibaba/OpenSandbox — SDKs](https://github.com/alibaba/OpenSandbox/tree/main#sdks).
|
|
220
|
+
|
|
184
221
|
## License
|
|
185
222
|
|
|
186
223
|
MIT
|
|
@@ -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
|
|
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
|
|
@@ -70,10 +79,53 @@ module OpenSandbox
|
|
|
70
79
|
|
|
71
80
|
def build_headers(extra = {})
|
|
72
81
|
headers = { "Accept" => "application/json" }
|
|
73
|
-
headers["
|
|
82
|
+
headers["OPEN-SANDBOX-API-KEY"] = api_key if api_key && !api_key.empty?
|
|
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
|
|
142
|
-
|
|
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
|
-
|
|
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
|
data/lib/open_sandbox/version.rb
CHANGED
data/lib/open_sandbox.rb
CHANGED
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.
|
|
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-
|
|
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.
|
|
78
|
+
version: 3.2.0
|
|
75
79
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
76
80
|
requirements:
|
|
77
81
|
- - ">="
|