open_sandbox 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3ff9458ad74494093739cda38472437e30b4dd0a081b68bf76f91c998b7b032e
4
+ data.tar.gz: 829770a3369b58e83fe541745a0a1765f5c98dbdbf680be37a21a06db4b947b8
5
+ SHA512:
6
+ metadata.gz: 8f7bd0c0f24041a75a2ab5e6ef66ed7ac99d28a3c11317d6777c4dbeec9422a5465a48a75b9dde1042b3c26646e2c7d0f03e0835ded535a4209725c23f561751
7
+ data.tar.gz: 8e6740dd38a16f475b4d6a11a161259e01eebae6419ac06bb4d8905ea9de51213bcdd6c1d7b5570284087e33a8e04c780f27d72f6c7ec0db1c55ca9962024b74
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-04-29
4
+
5
+ ### Added
6
+ - Initial release
7
+ - `OpenSandbox::Client` with global `configure` block and singleton `OpenSandbox.client`
8
+ - `Sandboxes` resource: `list`, `get`, `create`, `delete`, `pause`, `resume`, `renew_expiration`, `endpoint`, `proxy`, `wait_until`
9
+ - `Sandboxes` diagnostics: `logs`, `inspect_container`, `events`, `diagnostics`
10
+ - `Pools` resource: `list`, `get`, `create`, `update`, `delete`
11
+ - Typed value objects: `Sandbox`, `SandboxStatus`, `Endpoint`, `Pool`, `PoolCapacitySpec`, `SandboxList`, `PaginationInfo`
12
+ - Full error hierarchy: `InvalidRequestError`, `AuthenticationError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `ValidationError`, `ServerError`, `ConnectionError`
13
+ - Configurable `Logger` (silent by default)
14
+ - Docker timestamp stripping utility (`OpenSandbox::LogUtils.strip_timestamps`)
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leonx AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,186 @@
1
+ # OpenSandbox
2
+
3
+ Ruby SDK for the [open-sandbox.ai](https://open-sandbox.ai) API — manage isolated container sandboxes for secure code execution.
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/open_sandbox.svg)](https://rubygems.org/gems/open_sandbox)
6
+ [![CI](https://github.com/graysonchen/open_sandbox-sdk-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/graysonchen/open_sandbox-sdk-ruby/actions)
7
+
8
+ ## Installation
9
+
10
+ Add to your Gemfile:
11
+
12
+ ```ruby
13
+ gem "open_sandbox", path: "../open_sandbox" # local development
14
+ # or, once published:
15
+ gem "open_sandbox", "~> 0.1"
16
+ ```
17
+
18
+ ## Rails
19
+
20
+ Generate the initializer in your Rails app:
21
+
22
+ ```bash
23
+ rails generate open_sandbox:install
24
+ ```
25
+
26
+ This creates `config/initializers/open_sandbox.rb`:
27
+
28
+ ```ruby
29
+ OpenSandbox.configure do |config|
30
+ # config.base_url = ENV.fetch("SANDBOX_DOMAIN", "https://api.open-sandbox.ai")
31
+ # config.api_key = ENV.fetch("SANDBOX_API_KEY")
32
+ # config.timeout = 300
33
+ # config.logger = Rails.logger
34
+ end
35
+ ```
36
+
37
+ Uncomment and adjust the options you need. By default the SDK reads `SANDBOX_DOMAIN` and `SANDBOX_API_KEY` from ENV, so the initializer can stay empty for most setups.
38
+
39
+ ## Quick Start
40
+
41
+ ```ruby
42
+ require "open_sandbox"
43
+
44
+ # Configure (picks up SANDBOX_DOMAIN / SANDBOX_API_KEY from ENV by default)
45
+ OpenSandbox.configure do |c|
46
+ c.base_url = "http://localhost:8787" # or "https://api.open-sandbox.ai"
47
+ c.api_key = "sk-..." # omit for local dev
48
+ c.logger = Logger.new($stdout, level: :debug)
49
+ end
50
+ ```
51
+
52
+
53
+ ## Runner — one-shot code execution
54
+
55
+ ```ruby
56
+ # Python
57
+ result = OpenSandbox::Runner.python("print(2 ** 10)")
58
+ result.output # => "1024\n"
59
+ result.success? # => true
60
+ result.elapsed # => 3.14
61
+
62
+ # Node.js
63
+ result = OpenSandbox::Runner.node("console.log('hi')")
64
+
65
+ # Shell
66
+ result = OpenSandbox::Runner.shell("echo $((6 * 7))")
67
+
68
+ # Custom image
69
+ result = OpenSandbox::Runner.call(
70
+ image: "ubuntu:24.04",
71
+ command: ["bash", "-c", "ls /"],
72
+ timeout: 60
73
+ )
74
+ ```
75
+
76
+ The sandbox is always deleted after the run (even on error).
77
+ `Runner` uses `OpenSandbox.logger` — set it to `Rails.logger` in your initializer to get structured logs.
78
+
79
+ Timeout resolution order: explicit `timeout:` argument → `SANDBOX_TIMEOUT` ENV → `300` s (default).
80
+
81
+ ## OpenSandbox client
82
+
83
+ ```
84
+ client = OpenSandbox.client
85
+
86
+ # Create a sandbox
87
+ sandbox = client.sandboxes.create(
88
+ image: "python:3.11-slim",
89
+ entrypoint: ["python", "-c", "print(2 ** 10)"],
90
+ timeout: 300,
91
+ resource_limits: { "cpu" => "500m", "memory" => "256Mi" }
92
+ )
93
+ puts sandbox.id # => "c9a03139-..."
94
+ puts sandbox.status.state # => "Running"
95
+
96
+ # Wait until Running
97
+ client.sandboxes.wait_until(sandbox.id, target_state: OpenSandbox::SandboxState::RUNNING)
98
+
99
+ # Get endpoint for a service on port 8080
100
+ ep = client.sandboxes.endpoint(sandbox.id, port: 8080)
101
+ puts ep.url # => "http://sb-xxx.sandbox.local:8080"
102
+
103
+ # Proxy an HTTP request directly to the sandbox
104
+ response = client.sandboxes.proxy(sandbox.id, port: 8080, method: :post,
105
+ path: "/run", body: { code: "print(1)" })
106
+
107
+ # Fetch logs (Docker timestamps stripped automatically)
108
+ puts client.sandboxes.logs(sandbox.id, tail: 100)
109
+
110
+ # Lifecycle management
111
+ client.sandboxes.pause(sandbox.id)
112
+ client.sandboxes.resume(sandbox.id)
113
+ client.sandboxes.renew_expiration(sandbox.id, expires_at: Time.now + 3600)
114
+ client.sandboxes.delete(sandbox.id)
115
+ ```
116
+
117
+ ## Resource Pools (pre-warm for low cold-start)
118
+
119
+ ```ruby
120
+ pool = client.pools.create(
121
+ name: "python-pool",
122
+ template: { spec: { containers: [{ name: "main", image: "python:3.11-slim" }] } },
123
+ capacity_spec: OpenSandbox::PoolCapacitySpec.new(
124
+ buffer_max: 5, buffer_min: 2, pool_max: 20, pool_min: 2
125
+ )
126
+ )
127
+
128
+ # Use the pool when creating a sandbox
129
+ client.sandboxes.create(
130
+ image: "python:3.11-slim",
131
+ entrypoint: ["python", "app.py"],
132
+ resource_limits: { "cpu" => "1", "memory" => "512Mi" },
133
+ extensions: { "poolRef" => "python-pool" }
134
+ )
135
+ ```
136
+
137
+ ## Diagnostics
138
+
139
+ ```ruby
140
+ puts client.sandboxes.logs(id, tail: 200, since: "10m")
141
+ puts client.sandboxes.events(id, limit: 50)
142
+ puts client.sandboxes.diagnostics(id) # inspect + events + logs combined
143
+ ```
144
+
145
+ ## Configuration
146
+
147
+ | Option | ENV var | Default |
148
+ |------------|-------------------|----------------------------|
149
+ | `base_url` | `SANDBOX_DOMAIN` | `http://localhost:8787` |
150
+ | `api_key` | `SANDBOX_API_KEY` | `nil` (optional for local) |
151
+ | `timeout` | `SANDBOX_TIMEOUT` | `300` (seconds) |
152
+ | `logger` | — | `Logger.new(nil)` (silent) |
153
+
154
+ ## Error Handling
155
+
156
+ ```ruby
157
+ rescue OpenSandbox::NotFoundError => e # 404
158
+ rescue OpenSandbox::AuthenticationError # 401
159
+ rescue OpenSandbox::ForbiddenError # 403
160
+ rescue OpenSandbox::ConflictError # 409
161
+ rescue OpenSandbox::ValidationError # 422
162
+ rescue OpenSandbox::ServerError # 5xx
163
+ rescue OpenSandbox::ConnectionError # network / timeout
164
+ rescue OpenSandbox::Error # catch-all
165
+ ```
166
+
167
+ ## Log Utility
168
+
169
+ Strip Docker-style timestamp prefixes from raw log strings:
170
+
171
+ ```ruby
172
+ raw = "2026-04-29T13:37:33.993Z 1024\n"
173
+ OpenSandbox::LogUtils.strip_timestamps(raw)
174
+ # => "1024\n"
175
+ ```
176
+
177
+ ## Development
178
+
179
+ ```bash
180
+ bundle install
181
+ bundle exec rake test # run all tests
182
+ ```
183
+
184
+ ## License
185
+
186
+ MIT
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module OpenSandbox
6
+ module Generators
7
+ # Generates config/initializers/open_sandbox.rb in the host Rails app.
8
+ #
9
+ # Usage:
10
+ # rails generate open_sandbox:install
11
+ #
12
+ class InstallGenerator < Rails::Generators::Base
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ desc "Creates an OpenSandbox initializer in config/initializers/"
16
+
17
+ def copy_initializer
18
+ template "initializer.rb", "config/initializers/open_sandbox.rb"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open_sandbox"
4
+
5
+ # Configuration reference:
6
+ # base_url — API endpoint (default: ENV["SANDBOX_DOMAIN"] || "http://localhost:8787")
7
+ # api_key — Authentication key (default: ENV["SANDBOX_API_KEY"])
8
+ # timeout — HTTP request timeout in seconds (default: 30)
9
+ # logger — Ruby Logger instance (default: silent)
10
+ #
11
+ OpenSandbox.configure do |config|
12
+ # Set via Rails credentials: Rails.application.credentials.sandbox_domain
13
+ config.base_url = ENV.fetch("SANDBOX_DOMAIN", "http://localhost:8080")
14
+
15
+ # API Key: optional for local dev, required for production
16
+ # Set via Rails credentials: Rails.application.credentials.sandbox_api_key
17
+ config.api_key = ENV.fetch("SANDBOX_API_KEY", nil)
18
+
19
+ # HTTP timeout in seconds
20
+ config.timeout = ENV.fetch("SANDBOX_TIMEOUT", 300).to_i
21
+
22
+ # config.logger = Logger.new($stdout, level: :debug)
23
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require_relative "errors"
5
+ require_relative "models"
6
+ require_relative "http_client"
7
+ require_relative "sandboxes"
8
+ require_relative "pools"
9
+
10
+ # OpenSandbox Ruby SDK
11
+ #
12
+ # Wraps the open-sandbox.ai REST API for managing isolated container sandboxes.
13
+ #
14
+ # == Quick Start
15
+ #
16
+ # client = OpenSandbox::Client.new
17
+ #
18
+ # sandbox = client.sandboxes.create(
19
+ # image: "python:3.11-slim",
20
+ # entrypoint: ["python", "-c", "print('hello')"],
21
+ # timeout: 300
22
+ # )
23
+ #
24
+ # client.sandboxes.wait_until(sandbox.id, target_state: OpenSandbox::SandboxState::RUNNING)
25
+ # endpoint = client.sandboxes.endpoint(sandbox.id, port: 8080)
26
+ # client.sandboxes.delete(sandbox.id)
27
+ #
28
+ # == Configuration
29
+ #
30
+ # OpenSandbox.configure do |c|
31
+ # c.base_url = "https://api.open-sandbox.ai"
32
+ # c.api_key = "sk-..."
33
+ # c.timeout = 300
34
+ # c.logger = Logger.new($stdout, level: :info)
35
+ # end
36
+ #
37
+ module OpenSandbox
38
+ # Global configuration object
39
+ class Configuration
40
+ attr_accessor :base_url, :api_key, :timeout, :logger
41
+
42
+ def initialize
43
+ @base_url = ENV.fetch("SANDBOX_DOMAIN", "http://localhost:8787")
44
+ @api_key = ENV.fetch("SANDBOX_API_KEY", nil)
45
+ @timeout = ENV.fetch("SANDBOX_TIMEOUT", 300).to_i
46
+ @logger = Logger.new(nil) # silent by default
47
+ end
48
+ end
49
+
50
+ class << self
51
+ def configuration
52
+ @configuration ||= Configuration.new
53
+ end
54
+
55
+ def configure
56
+ yield configuration
57
+ reset_client!
58
+ end
59
+
60
+ # Shortcut: OpenSandbox.client (singleton, reset after configure)
61
+ def client
62
+ @client ||= Client.new
63
+ end
64
+
65
+ def reset_client!
66
+ @client = nil
67
+ end
68
+
69
+ # Convenience delegators
70
+ def logger = configuration.logger
71
+ end
72
+
73
+ # Main entry point for the OpenSandbox SDK.
74
+ class Client
75
+ # @param base_url [String, nil] override global config
76
+ # @param api_key [String, nil] override global config
77
+ # @param timeout [Integer, nil] override global config
78
+ # @param logger [Logger, nil] override global config
79
+ def initialize(base_url: nil, api_key: nil, timeout: nil, logger: nil)
80
+ cfg = OpenSandbox.configuration
81
+ @base_url = base_url || cfg.base_url
82
+ @api_key = api_key || cfg.api_key
83
+ @timeout = timeout || cfg.timeout
84
+ @logger = logger || cfg.logger
85
+ end
86
+
87
+ # @return [Sandboxes]
88
+ def sandboxes
89
+ @sandboxes ||= Sandboxes.new(http, logger: @logger)
90
+ end
91
+
92
+ # @return [Pools]
93
+ def pools
94
+ @pools ||= Pools.new(http)
95
+ end
96
+
97
+ # Health check — returns true if the server responds successfully.
98
+ # @return [Boolean]
99
+ def healthy?
100
+ http.get("/health")
101
+ true
102
+ rescue Error
103
+ false
104
+ end
105
+
106
+ private
107
+
108
+ def http
109
+ @http ||= HttpClient.new(base_url: @base_url, api_key: @api_key, timeout: @timeout)
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenSandbox
4
+ # Base error for all OpenSandbox errors
5
+ class Error < StandardError; end
6
+
7
+ # 400 - Invalid request
8
+ class InvalidRequestError < Error; end
9
+
10
+ # 401 - Missing or invalid credentials
11
+ class AuthenticationError < Error; end
12
+
13
+ # 403 - Insufficient permissions
14
+ class ForbiddenError < Error; end
15
+
16
+ # 404 - Resource not found
17
+ class NotFoundError < Error; end
18
+
19
+ # 409 - Conflict (e.g., already exists, wrong state)
20
+ class ConflictError < Error; end
21
+
22
+ # 422 - Validation error
23
+ class ValidationError < Error; end
24
+
25
+ # 500 - Server error
26
+ class ServerError < Error; end
27
+
28
+ # Connection / timeout errors
29
+ class ConnectionError < Error; end
30
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "httparty"
5
+
6
+ module OpenSandbox
7
+ # Low-level HTTP client for the OpenSandbox API.
8
+ # Not intended to be used directly – use Client instead.
9
+ class HttpClient
10
+ include HTTParty
11
+
12
+ DEFAULT_TIMEOUT = 30
13
+ DEFAULT_OPEN_TIMEOUT = 10
14
+
15
+ attr_reader :base_url, :api_key, :timeout
16
+
17
+ def initialize(base_url:, api_key: nil, timeout: DEFAULT_TIMEOUT)
18
+ @base_url = base_url.chomp("/")
19
+ @api_key = api_key
20
+ @timeout = timeout
21
+ end
22
+
23
+ def get(path, query: {}, headers: {})
24
+ request(:get, path, query: query.compact, headers: headers)
25
+ end
26
+
27
+ def post(path, body: {}, headers: {})
28
+ request(:post, path, body: body, headers: headers)
29
+ end
30
+
31
+ def put(path, body: {}, headers: {})
32
+ request(:put, path, body: body, headers: headers)
33
+ end
34
+
35
+ def delete(path, headers: {})
36
+ request(:delete, path, headers: headers)
37
+ end
38
+
39
+ # Proxy raw HTTP request to sandbox internal service
40
+ # Returns the raw HTTParty response
41
+ def proxy(method, path, body: nil, headers: {})
42
+ request(method, path, body: body, headers: headers, raw: true)
43
+ end
44
+
45
+ private
46
+
47
+ def request(method, path, query: {}, body: nil, headers: {}, raw: false)
48
+ url = "#{base_url}#{path}"
49
+ options = {
50
+ timeout: timeout,
51
+ open_timeout: DEFAULT_OPEN_TIMEOUT,
52
+ headers: build_headers(headers)
53
+ }
54
+ options[:query] = query if query && !query.empty?
55
+
56
+ if body
57
+ options[:body] = body.to_json
58
+ options[:headers] = (options[:headers] || {}).merge("Content-Type" => "application/json")
59
+ end
60
+
61
+ response = self.class.send(method, url, options)
62
+
63
+ return response if raw
64
+ handle_response(response)
65
+ rescue ::Net::OpenTimeout, ::Net::ReadTimeout => e
66
+ raise ConnectionError, "Request timed out: #{e.message}"
67
+ rescue ::SocketError, ::Errno::ECONNREFUSED => e
68
+ raise ConnectionError, "Cannot connect to sandbox server at #{base_url}: #{e.message}"
69
+ end
70
+
71
+ def build_headers(extra = {})
72
+ headers = { "Accept" => "application/json" }
73
+ headers["Authorization"] = "Bearer #{api_key}" if api_key && !api_key.empty?
74
+ headers.merge(extra)
75
+ end
76
+
77
+ def handle_response(response)
78
+ case response.code
79
+ when 200, 201, 202
80
+ parse_json(response)
81
+ when 204
82
+ nil
83
+ when 400
84
+ raise InvalidRequestError, error_message(response)
85
+ when 401
86
+ raise AuthenticationError, error_message(response)
87
+ when 403
88
+ raise ForbiddenError, error_message(response)
89
+ when 404
90
+ raise NotFoundError, error_message(response)
91
+ when 409
92
+ raise ConflictError, error_message(response)
93
+ when 422
94
+ raise ValidationError, error_message(response)
95
+ when 500..599
96
+ raise ServerError, "Server error (#{response.code}): #{error_message(response)}"
97
+ else
98
+ raise Error, "Unexpected status #{response.code}: #{response.body}"
99
+ end
100
+ end
101
+
102
+ def parse_json(response)
103
+ body = response.body
104
+ return nil if body.nil? || body.empty?
105
+ JSON.parse(body)
106
+ rescue JSON::ParserError
107
+ response.body
108
+ end
109
+
110
+ def error_message(response)
111
+ data = parse_json(response)
112
+ if data.is_a?(Hash)
113
+ data["message"] || data["detail"] || response.body
114
+ else
115
+ response.body
116
+ end
117
+ rescue
118
+ response.body
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module OpenSandbox
6
+ # Sandbox lifecycle states
7
+ module SandboxState
8
+ PENDING = "Pending"
9
+ RUNNING = "Running"
10
+ PAUSING = "Pausing"
11
+ PAUSED = "Paused"
12
+ STOPPING = "Stopping"
13
+ TERMINATED = "Terminated"
14
+ FAILED = "Failed"
15
+ end
16
+
17
+ # Value object for sandbox status
18
+ SandboxStatus = Data.define(:state, :reason, :message, :last_transition_at) do
19
+ def running? = state == SandboxState::RUNNING
20
+ def pending? = state == SandboxState::PENDING
21
+ def paused? = state == SandboxState::PAUSED
22
+ def failed? = state == SandboxState::FAILED
23
+ def terminated? = state == SandboxState::TERMINATED
24
+
25
+ def self.from_hash(h)
26
+ return nil unless h
27
+ new(
28
+ state: h["state"],
29
+ reason: h["reason"],
30
+ message: h["message"],
31
+ last_transition_at: h["lastTransitionAt"] ? Time.parse(h["lastTransitionAt"]) : nil
32
+ )
33
+ end
34
+ end
35
+
36
+ # Value object for a Sandbox resource
37
+ Sandbox = Data.define(:id, :status, :image_uri, :entrypoint, :metadata, :expires_at, :created_at) do
38
+ def self.from_hash(h)
39
+ new(
40
+ id: h["id"],
41
+ status: SandboxStatus.from_hash(h["status"]),
42
+ image_uri: h.dig("image", "uri"),
43
+ entrypoint: h["entrypoint"] || [],
44
+ metadata: h["metadata"] || {},
45
+ expires_at: h["expiresAt"] ? Time.parse(h["expiresAt"]) : nil,
46
+ created_at: h["createdAt"] ? Time.parse(h["createdAt"]) : nil
47
+ )
48
+ end
49
+ end
50
+
51
+ # Value object for an Endpoint
52
+ Endpoint = Data.define(:endpoint, :headers) do
53
+ def self.from_hash(h)
54
+ new(endpoint: h["endpoint"], headers: h["headers"] || {})
55
+ end
56
+
57
+ def url = endpoint.start_with?("http") ? endpoint : "http://#{endpoint}"
58
+ end
59
+
60
+ # Value object for pagination
61
+ PaginationInfo = Data.define(:page, :page_size, :total_items, :total_pages, :has_next_page) do
62
+ def self.from_hash(h)
63
+ new(
64
+ page: h["page"],
65
+ page_size: h["pageSize"],
66
+ total_items: h["totalItems"],
67
+ total_pages: h["totalPages"],
68
+ has_next_page: h["hasNextPage"]
69
+ )
70
+ end
71
+ end
72
+
73
+ # Value object for list response
74
+ SandboxList = Data.define(:items, :pagination) do
75
+ def self.from_hash(h)
76
+ new(
77
+ items: (h["items"] || []).map { Sandbox.from_hash(_1) },
78
+ pagination: PaginationInfo.from_hash(h["pagination"])
79
+ )
80
+ end
81
+ end
82
+
83
+ # Value object for pool capacity
84
+ PoolCapacitySpec = Data.define(:buffer_max, :buffer_min, :pool_max, :pool_min) do
85
+ def self.from_hash(h)
86
+ new(
87
+ buffer_max: h["bufferMax"],
88
+ buffer_min: h["bufferMin"],
89
+ pool_max: h["poolMax"],
90
+ pool_min: h["poolMin"]
91
+ )
92
+ end
93
+
94
+ def to_api_hash
95
+ { "bufferMax" => buffer_max, "bufferMin" => buffer_min, "poolMax" => pool_max, "poolMin" => pool_min }
96
+ end
97
+ end
98
+
99
+ # Value object for a Pool resource
100
+ Pool = Data.define(:name, :capacity_spec, :status, :created_at) do
101
+ def self.from_hash(h)
102
+ new(
103
+ name: h["name"],
104
+ capacity_spec: PoolCapacitySpec.from_hash(h["capacitySpec"]),
105
+ status: h["status"],
106
+ created_at: h["createdAt"] ? Time.parse(h["createdAt"]) : nil
107
+ )
108
+ end
109
+ end
110
+
111
+ # Utility helpers for processing sandbox output.
112
+ module LogUtils
113
+ # Strip Docker-style RFC3339 nanosecond timestamp prefixes from each log line.
114
+ #
115
+ # "2026-04-29T13:37:33.993340334Z 1024\n" => "1024\n"
116
+ # "no-timestamp\n" => "no-timestamp\n"
117
+ #
118
+ TIMESTAMP_RE = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z /.freeze
119
+
120
+ def self.strip_timestamps(raw)
121
+ raw.to_s.lines.map { |line| line.sub(TIMESTAMP_RE, "") }.join
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenSandbox
4
+ # Manages pre-warmed resource pools for reduced sandbox cold-start latency.
5
+ class Pools
6
+ def initialize(http)
7
+ @http = http
8
+ end
9
+
10
+ # List all pools.
11
+ #
12
+ # @return [Array<Pool>]
13
+ def list
14
+ data = @http.get("/v1/pools")
15
+ (data["items"] || []).map { Pool.from_hash(_1) }
16
+ end
17
+
18
+ # Get a pool by name.
19
+ #
20
+ # @param pool_name [String]
21
+ # @return [Pool]
22
+ # @raise [NotFoundError]
23
+ def get(pool_name)
24
+ Pool.from_hash(@http.get("/v1/pools/#{pool_name}"))
25
+ end
26
+
27
+ # Create a new pre-warmed pool.
28
+ #
29
+ # @param name [String] unique pool name (lowercase alphanumeric + hyphens)
30
+ # @param template [Hash] Kubernetes PodTemplateSpec
31
+ # @param capacity_spec [PoolCapacitySpec, Hash] capacity configuration
32
+ # @return [Pool]
33
+ def create(name:, template:, capacity_spec:)
34
+ spec = capacity_spec.is_a?(PoolCapacitySpec) ? capacity_spec.to_api_hash : capacity_spec.transform_keys(&:to_s).then do |h|
35
+ {
36
+ "bufferMax" => h["buffer_max"] || h["bufferMax"],
37
+ "bufferMin" => h["buffer_min"] || h["bufferMin"],
38
+ "poolMax" => h["pool_max"] || h["poolMax"],
39
+ "poolMin" => h["pool_min"] || h["poolMin"]
40
+ }
41
+ end
42
+
43
+ body = { "name" => name.to_s, "template" => template, "capacitySpec" => spec }
44
+ Pool.from_hash(@http.post("/v1/pools", body: body))
45
+ end
46
+
47
+ # Update pool capacity configuration.
48
+ #
49
+ # @param pool_name [String]
50
+ # @param capacity_spec [PoolCapacitySpec, Hash]
51
+ # @return [Pool]
52
+ def update(pool_name, capacity_spec:)
53
+ spec = capacity_spec.is_a?(PoolCapacitySpec) ? capacity_spec.to_api_hash : capacity_spec
54
+ body = { "capacitySpec" => spec }
55
+ Pool.from_hash(@http.put("/v1/pools/#{pool_name}", body: body))
56
+ end
57
+
58
+ # Delete a pool.
59
+ #
60
+ # @param pool_name [String]
61
+ # @return [nil]
62
+ def delete(pool_name)
63
+ @http.delete("/v1/pools/#{pool_name}")
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenSandbox
4
+ # Runner: execute code or commands in an ephemeral sandbox and collect output.
5
+ #
6
+ # Opinionated wrapper around OpenSandbox::Client for short-lived,
7
+ # fire-and-collect code execution. The sandbox is always cleaned up after use.
8
+ #
9
+ # == Examples
10
+ # result = SandboxRunnerService.run_python(<<~CODE)
11
+ # import math
12
+ # print(math.sqrt(144))
13
+ # CODE
14
+ # result.output # => "12.0\n"
15
+ #
16
+ # result = OpenSandbox::Runner.python("print(2 ** 10)")
17
+ # result.output # => "1024\n"
18
+ # result.success? # => true
19
+ # result.elapsed # => 3.14
20
+ #
21
+ # result = OpenSandbox::Runner.node("console.log('hi')")
22
+ # result = OpenSandbox::Runner.shell("echo $((6 * 7))")
23
+ #
24
+ # result = OpenSandbox::Runner.call(
25
+ # image: "ubuntu:24.04",
26
+ # command: ["bash", "-c", "ls /"],
27
+ # timeout: 60
28
+ # )
29
+ #
30
+ class Runner
31
+ Result = Data.define(:success, :output, :sandbox_id, :elapsed) do
32
+ alias_method :success?, :success
33
+ def failure? = !success
34
+ end
35
+
36
+ DEFAULT_LIMITS = { "cpu" => "500m", "memory" => "256Mi" }.freeze
37
+ STARTUP_TIMEOUT = 60 # seconds to wait for sandbox to reach Running
38
+
39
+ # ── Convenience shortcuts ─────────────────────────────────────────────
40
+
41
+ def self.python(code, version: "3.11", timeout: nil)
42
+ call(image: "python:#{version}-slim", command: ["python", "-c", code], timeout: timeout)
43
+ end
44
+
45
+ def self.node(code, version: "20", timeout: nil)
46
+ call(image: "node:#{version}-slim", command: ["node", "-e", code], timeout: timeout)
47
+ end
48
+
49
+ def self.shell(command, timeout: nil)
50
+ call(image: "ubuntu:24.04", command: ["bash", "-c", command], timeout: timeout)
51
+ end
52
+
53
+ def self.call(**kwargs)
54
+ new(**kwargs).call
55
+ end
56
+
57
+ # ── Instance ──────────────────────────────────────────────────────────
58
+
59
+ def initialize(
60
+ image:,
61
+ command:,
62
+ env: {},
63
+ timeout: nil,
64
+ resource_limits: DEFAULT_LIMITS,
65
+ metadata: {}
66
+ )
67
+ @image = image
68
+ @command = command
69
+ @env = env
70
+ @timeout = timeout || OpenSandbox.configuration.timeout
71
+ @resource_limits = resource_limits
72
+ @metadata = metadata
73
+ @client = OpenSandbox.client
74
+ end
75
+
76
+ def call
77
+ started_at = Time.now
78
+ sandbox_id = nil
79
+
80
+ sandbox = @client.sandboxes.create(
81
+ image: @image,
82
+ entrypoint: @command,
83
+ env: @env,
84
+ timeout: @timeout,
85
+ resource_limits: @resource_limits,
86
+ metadata: @metadata
87
+ )
88
+ sandbox_id = sandbox.id
89
+
90
+ @client.sandboxes.wait_until(
91
+ sandbox_id,
92
+ target_state: SandboxState::TERMINATED,
93
+ timeout: STARTUP_TIMEOUT + @timeout
94
+ ) { |s| OpenSandbox.logger.debug("[Runner] #{sandbox_id} state=#{s.status.state}") }
95
+
96
+ raw = @client.sandboxes.logs(sandbox_id, tail: 1000)
97
+ output = LogUtils.strip_timestamps(raw.to_s)
98
+
99
+ Result.new(success: true, output: output, sandbox_id: sandbox_id, elapsed: Time.now - started_at)
100
+ rescue OpenSandbox::Error => e
101
+ OpenSandbox.logger.error("[Runner] #{sandbox_id}: #{e.message}")
102
+ Result.new(success: false, output: "Error: #{e.message}", sandbox_id: sandbox_id, elapsed: Time.now - started_at)
103
+ ensure
104
+ begin
105
+ @client.sandboxes.delete(sandbox_id) if sandbox_id
106
+ rescue OpenSandbox::Error => e
107
+ OpenSandbox.logger.warn("[Runner] cleanup failed for #{sandbox_id}: #{e.message}")
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenSandbox
4
+ # Manages sandbox lifecycle operations.
5
+ #
6
+ # All methods return typed value objects (Sandbox, Endpoint, etc.)
7
+ # or raise OpenSandbox::Error subclasses on failure.
8
+ class Sandboxes
9
+ def initialize(http, logger: Logger.new(nil))
10
+ @http = http
11
+ @logger = logger
12
+ end
13
+
14
+ # List all sandboxes with optional filters.
15
+ #
16
+ # @param page [Integer] page number (default 1)
17
+ # @param page_size [Integer] items per page (default 20)
18
+ # @param metadata [Hash] filter by metadata key-value pairs
19
+ # @return [SandboxList]
20
+ def list(page: 1, page_size: 20, metadata: {})
21
+ query = { page: page, pageSize: page_size }
22
+ metadata.each { |k, v| query["metadata.#{k}"] = v }
23
+ data = @http.get("/v1/sandboxes", query: query)
24
+ SandboxList.from_hash(data)
25
+ end
26
+
27
+ # Get a sandbox by ID.
28
+ #
29
+ # @param sandbox_id [String]
30
+ # @return [Sandbox]
31
+ # @raise [NotFoundError]
32
+ def get(sandbox_id)
33
+ data = @http.get("/v1/sandboxes/#{sandbox_id}")
34
+ Sandbox.from_hash(data)
35
+ end
36
+
37
+ # Create a new sandbox.
38
+ #
39
+ # @param image [String] container image URI, e.g. "python:3.11"
40
+ # @param entrypoint [Array<String>] command to run, e.g. ["python", "/app/main.py"]
41
+ # @param resource_limits [Hash] CPU/memory limits, e.g. { cpu: "500m", memory: "512Mi" }
42
+ # @param timeout [Integer, nil] seconds before auto-terminate (min 60); nil = no auto-terminate
43
+ # @param env [Hash] environment variables
44
+ # @param metadata [Hash] custom key-value labels
45
+ # @param network_policy [Hash, nil] egress policy
46
+ # @param volumes [Array<Hash>, nil] volume mounts
47
+ # @param image_auth [Hash, nil] registry credentials { username:, password: }
48
+ # @param extensions [Hash, nil] provider-specific parameters (e.g. poolRef)
49
+ # @param platform [Hash, nil] { os: "linux", arch: "amd64" }
50
+ # @return [Sandbox]
51
+ def create(
52
+ image:,
53
+ entrypoint:,
54
+ resource_limits: { "cpu" => "500m", "memory" => "512Mi" },
55
+ timeout: 300,
56
+ env: {},
57
+ metadata: {},
58
+ network_policy: nil,
59
+ volumes: nil,
60
+ image_auth: nil,
61
+ extensions: nil,
62
+ platform: nil
63
+ )
64
+ body = {
65
+ image: build_image_spec(image, image_auth),
66
+ entrypoint: entrypoint,
67
+ resourceLimits: resource_limits.transform_keys(&:to_s),
68
+ env: env.transform_keys(&:to_s)
69
+ }
70
+
71
+ body[:timeout] = timeout if timeout
72
+ body[:metadata] = metadata.transform_keys(&:to_s) if metadata && !metadata.empty?
73
+ body[:networkPolicy] = network_policy if network_policy
74
+ body[:volumes] = volumes if volumes
75
+ body[:extensions] = extensions.transform_keys(&:to_s) if extensions
76
+ body[:platform] = { "os" => platform[:os], "arch" => platform[:arch] } if platform
77
+
78
+ data = @http.post("/v1/sandboxes", body: body)
79
+ Sandbox.from_hash(data)
80
+ end
81
+
82
+ # Delete (terminate) a sandbox.
83
+ #
84
+ # @param sandbox_id [String]
85
+ # @return [nil]
86
+ def delete(sandbox_id)
87
+ @http.delete("/v1/sandboxes/#{sandbox_id}")
88
+ end
89
+
90
+ # Pause a running sandbox (preserves state).
91
+ #
92
+ # @param sandbox_id [String]
93
+ # @return [nil]
94
+ def pause(sandbox_id)
95
+ @http.post("/v1/sandboxes/#{sandbox_id}/pause")
96
+ end
97
+
98
+ # Resume a paused sandbox.
99
+ #
100
+ # @param sandbox_id [String]
101
+ # @return [nil]
102
+ def resume(sandbox_id)
103
+ @http.post("/v1/sandboxes/#{sandbox_id}/resume")
104
+ end
105
+
106
+ # Renew sandbox expiration time.
107
+ #
108
+ # @param sandbox_id [String]
109
+ # @param expires_at [Time] new expiration time (must be in the future)
110
+ # @return [Time] new expiration time
111
+ def renew_expiration(sandbox_id, expires_at:)
112
+ body = { "expiresAt" => expires_at.utc.iso8601 }
113
+ data = @http.post("/v1/sandboxes/#{sandbox_id}/renew-expiration", body: body)
114
+ Time.parse(data["expiresAt"])
115
+ end
116
+
117
+ # Get public endpoint URL for a service port inside the sandbox.
118
+ #
119
+ # @param sandbox_id [String]
120
+ # @param port [Integer] port number where the service listens
121
+ # @param use_server_proxy [Boolean] return server-proxied URL
122
+ # @return [Endpoint]
123
+ def endpoint(sandbox_id, port:, use_server_proxy: false)
124
+ data = @http.get(
125
+ "/v1/sandboxes/#{sandbox_id}/endpoints/#{port}",
126
+ query: { useServerProxy: use_server_proxy }
127
+ )
128
+ Endpoint.from_hash(data)
129
+ end
130
+
131
+ # Proxy an HTTP request to a service running inside the sandbox.
132
+ #
133
+ # @param sandbox_id [String]
134
+ # @param port [Integer]
135
+ # @param method [Symbol] :get, :post, :put, :patch, :delete
136
+ # @param path [String] path within the proxied service (default "/")
137
+ # @param body [Hash, nil] request body
138
+ # @param headers [Hash] additional headers
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('/')}"
144
+
145
+ @http.proxy(method, proxy_path, body: body, headers: headers)
146
+ end
147
+
148
+ # ── Diagnostics ─────────────────────────────────────────────────────────
149
+
150
+ # Get container logs for a sandbox.
151
+ #
152
+ # @param sandbox_id [String]
153
+ # @param tail [Integer] number of trailing lines
154
+ # @param since [String, nil] duration string, e.g. "10m", "1h"
155
+ # @return [String]
156
+ def logs(sandbox_id, tail: 100, since: nil)
157
+ query = { tail: tail }
158
+ query[:since] = since if since
159
+ @http.get("/v1/sandboxes/#{sandbox_id}/diagnostics/logs", query: query)
160
+ end
161
+
162
+ # Get detailed container inspection info.
163
+ #
164
+ # @param sandbox_id [String]
165
+ # @return [String]
166
+ def inspect_container(sandbox_id)
167
+ @http.get("/v1/sandboxes/#{sandbox_id}/diagnostics/inspect")
168
+ end
169
+
170
+ # Get events for a sandbox.
171
+ #
172
+ # @param sandbox_id [String]
173
+ # @param limit [Integer]
174
+ # @return [String]
175
+ def events(sandbox_id, limit: 50)
176
+ @http.get("/v1/sandboxes/#{sandbox_id}/diagnostics/events", query: { limit: limit })
177
+ end
178
+
179
+ # Get combined diagnostics summary (inspect + events + logs).
180
+ #
181
+ # @param sandbox_id [String]
182
+ # @param tail [Integer]
183
+ # @param event_limit [Integer]
184
+ # @return [String]
185
+ def diagnostics(sandbox_id, tail: 50, event_limit: 20)
186
+ @http.get(
187
+ "/v1/sandboxes/#{sandbox_id}/diagnostics/summary",
188
+ query: { tail: tail, eventLimit: event_limit }
189
+ )
190
+ end
191
+
192
+ # ── Polling helpers ──────────────────────────────────────────────────────
193
+
194
+ # Wait until sandbox reaches a target state (or fails/terminates).
195
+ #
196
+ # @param sandbox_id [String]
197
+ # @param target_state [String] e.g. SandboxState::RUNNING
198
+ # @param timeout [Integer] max seconds to wait
199
+ # @param interval [Numeric] polling interval in seconds
200
+ # @yield [Sandbox] called after each poll (optional)
201
+ # @return [Sandbox] when target state reached
202
+ # @raise [Error] if sandbox fails or timeout exceeded
203
+ def wait_until(sandbox_id, target_state:, timeout: 120, interval: 2)
204
+ deadline = Time.now + timeout
205
+ loop do
206
+ sandbox = get(sandbox_id)
207
+ yield sandbox if block_given?
208
+
209
+ return sandbox if sandbox.status.state == target_state
210
+
211
+ if sandbox.status.failed? || sandbox.status.terminated?
212
+ raise Error, "Sandbox #{sandbox_id} entered #{sandbox.status.state} state: #{sandbox.status.message}"
213
+ end
214
+
215
+ raise Error, "Timed out waiting for sandbox #{sandbox_id} to reach #{target_state}" if Time.now >= deadline
216
+
217
+ sleep interval
218
+ end
219
+ end
220
+
221
+ private
222
+
223
+ def build_image_spec(image, auth)
224
+ spec = { "uri" => image }
225
+ if auth
226
+ spec["auth"] = { "username" => auth[:username].to_s, "password" => auth[:password].to_s }
227
+ end
228
+ spec
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenSandbox
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "open_sandbox/version"
4
+ require_relative "open_sandbox/client"
5
+ require_relative "open_sandbox/runner"
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: open_sandbox
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Grayson Chen
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: httparty
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0.21'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '1'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '0.21'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '1'
33
+ description: A lightweight, idiomatic Ruby client for managing isolated container
34
+ sandboxes via the open-sandbox.ai REST API. Supports sandbox lifecycle (create,
35
+ pause, resume, delete), resource pools, diagnostics, HTTP proxying, and polling
36
+ helpers.
37
+ email:
38
+ - cgg5207@sina.com
39
+ executables: []
40
+ extensions: []
41
+ extra_rdoc_files: []
42
+ files:
43
+ - CHANGELOG.md
44
+ - LICENSE
45
+ - README.md
46
+ - lib/generators/open_sandbox/install_generator.rb
47
+ - lib/generators/open_sandbox/templates/initializer.rb
48
+ - lib/open_sandbox.rb
49
+ - lib/open_sandbox/client.rb
50
+ - lib/open_sandbox/errors.rb
51
+ - lib/open_sandbox/http_client.rb
52
+ - lib/open_sandbox/models.rb
53
+ - lib/open_sandbox/pools.rb
54
+ - lib/open_sandbox/runner.rb
55
+ - lib/open_sandbox/sandboxes.rb
56
+ - lib/open_sandbox/version.rb
57
+ homepage: https://github.com/graysonchen/open_sandbox-sdk-ruby
58
+ licenses:
59
+ - MIT
60
+ metadata:
61
+ homepage_uri: https://github.com/graysonchen/open_sandbox-sdk-ruby
62
+ source_code_uri: https://github.com/graysonchen/open_sandbox-sdk-ruby
63
+ changelog_uri: https://github.com/graysonchen/open_sandbox-sdk-ruby/blob/main/CHANGELOG.md
64
+ bug_tracker_uri: https://github.com/graysonchen/open_sandbox-sdk-ruby/issues
65
+ rubygems_mfa_required: 'true'
66
+ post_install_message:
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: 3.1.0
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 3.5.22
82
+ signing_key:
83
+ specification_version: 4
84
+ summary: Ruby SDK for the open-sandbox.ai API
85
+ test_files: []