xshellz 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: fae387e6b5028c2064007a18564008e3e9ee33ecf740f38d038d6e9b37c4b707
4
+ data.tar.gz: f26c57baadbe0d628c8e9cd24442b3b846303e9747e2f079995f43f7048eaada
5
+ SHA512:
6
+ metadata.gz: 1356a6f340d78bc8267dea29fc6712680da93660bc5dd9b42f9119593fcb1d39e9b0b48aaa27130cba6a909e96d9dfbc7fe51867c6f00ff8bce84316a398fb93
7
+ data.tar.gz: 6fd6696f8eb7d23fe0035ae7f0381b0702b6d0dd4d0ce1ce6a31c29871f174450e229ce7b1c37582fdf46f9ce9b2431680cf1782660c15364ce05291194e8f25
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xShellz
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,166 @@
1
+ # xshellz
2
+
3
+ Official Ruby SDK for [xShellz](https://xshellz.com) sandboxes: throwaway,
4
+ gVisor-isolated Linux boxes you can spawn and run commands in from your own
5
+ program - in three lines.
6
+
7
+ ```bash
8
+ gem install xshellz
9
+ ```
10
+
11
+ ```ruby
12
+ require "xshellz"
13
+
14
+ Xshellz::Sandbox.create do |sbx|
15
+ result = sbx.run("ruby -e 'puts 6*7'")
16
+ puts result.stdout # 42
17
+ end
18
+ # the box is destroyed when the block exits
19
+ ```
20
+
21
+ Each sandbox is a real Linux box (root shell, package manager, network) running
22
+ under [gVisor](https://gvisor.dev) kernel isolation. Spawning is synchronous -
23
+ `Sandbox.create` returns once the box is running, typically in a few seconds.
24
+
25
+ ## Authentication
26
+
27
+ The SDK authenticates with an xShellz personal access token (PAT) carrying the
28
+ `read` and `write` scopes:
29
+
30
+ 1. Create a token from your [xShellz dashboard](https://app.xshellz.com)
31
+ (Settings -> API tokens), or via the API: `POST /v1/auth/tokens`.
32
+ 2. Export it:
33
+
34
+ ```bash
35
+ export XSHELLZ_API_KEY="your-token"
36
+ ```
37
+
38
+ or pass it explicitly: `Xshellz::Sandbox.create(api_key: "your-token")`.
39
+
40
+ Config precedence: explicit argument > `XSHELLZ_API_KEY` / `XSHELLZ_API_URL`
41
+ environment variables > default (`https://api.xshellz.com/v1`).
42
+
43
+ To target a staging or self-hosted control plane:
44
+
45
+ ```bash
46
+ export XSHELLZ_API_URL="https://api.staging.example.com/v1"
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ ### Run commands
52
+
53
+ ```ruby
54
+ Xshellz::Sandbox.create(name: "build-box") do |sbx|
55
+ r = sbx.run("apt-get update && apt-get install -y jq", timeout: 300)
56
+ puts r.exit_code, r.stdout, r.stderr
57
+
58
+ # A non-zero exit code does NOT raise - it's data:
59
+ r = sbx.run("false")
60
+ r.exit_code # => 1
61
+ r.ok? # => false
62
+
63
+ # cwd and env:
64
+ sbx.run("make test", cwd: "/srv/app", env: { "CI" => "1" })
65
+
66
+ # Stream long-running output as it arrives:
67
+ sbx.run("npm run build") do |stream, chunk|
68
+ warn(chunk) if stream == :stderr
69
+ print(chunk) if stream == :stdout
70
+ end
71
+ end
72
+ ```
73
+
74
+ ### Files (SFTP)
75
+
76
+ ```ruby
77
+ sbx.write_file("/tmp/config.json", '{"debug": true}')
78
+ data = sbx.read_file("/tmp/config.json") # => String
79
+
80
+ sbx.upload("local.txt", "/tmp/remote.txt")
81
+ sbx.download("/tmp/remote.txt", "out.txt")
82
+ ```
83
+
84
+ ### Lifecycle
85
+
86
+ ```ruby
87
+ sbx.uuid # sandbox id
88
+ sbx.ssh_host # e.g. "shellus1.xshellz.com"
89
+ sbx.ssh_port # e.g. 42001
90
+ sbx.ssh_command # ready-to-copy "ssh -p 42001 root@..."
91
+ sbx.status # "running", "stopped", ...
92
+
93
+ sbx.detach # keep the box alive after the create block
94
+ sbx.kill # destroy the box explicitly
95
+ sbx.start # resume an idle-stopped box
96
+
97
+ # Non-block form: you own the lifecycle.
98
+ sbx = Xshellz::Sandbox.create
99
+ sbx.run("uname -a")
100
+ sbx.kill
101
+
102
+ # Re-attach later (persist sbx.private_key_openssh + sbx.uuid for this):
103
+ sbx = Xshellz::Sandbox.connect(uuid, private_key: saved_private_key)
104
+
105
+ # Enumerate your sandboxes:
106
+ Xshellz::Sandbox.list.each do |info|
107
+ puts "#{info.uuid} #{info.status}"
108
+ end
109
+ ```
110
+
111
+ ### Typed errors
112
+
113
+ ```ruby
114
+ begin
115
+ sbx = Xshellz::Sandbox.create
116
+ rescue Xshellz::QuotaError
117
+ # plan limit reached - attach to the existing box instead
118
+ existing = Xshellz::Sandbox.list.first
119
+ sbx = Xshellz::Sandbox.connect(existing.uuid, private_key: saved_key)
120
+ rescue Xshellz::AuthError => e
121
+ puts e.message # missing/invalid token, scope, or account verification
122
+ end
123
+ ```
124
+
125
+ - `Xshellz::Error` - base class for everything the SDK raises
126
+ - `Xshellz::AuthError` - 401/403: bad or missing token, scopes, account gates
127
+ - `Xshellz::QuotaError` - plan sandbox limit reached / plan has no sandbox entitlement
128
+ - `Xshellz::SandboxNotRunningError` - operation needs a `running` box
129
+ - `Xshellz::CommandTimeoutError` - `run(timeout: ...)` exceeded
130
+ - `Xshellz::ApiError` - any other 4xx/5xx (carries `.status` and `.body`)
131
+
132
+ ## How it works
133
+
134
+ - **Control plane**: HTTPS to `api.xshellz.com/v1` (create / list / start /
135
+ destroy), authenticated by your PAT.
136
+ - **Data plane**: SSH directly to the box as `root` (net-ssh), files over
137
+ SFTP (net-sftp). `Sandbox.create` generates an in-memory ed25519 keypair
138
+ per sandbox; the private key never leaves your process and the server never
139
+ sees it - only the public half is installed in the box's `authorized_keys`.
140
+ - **Host keys are auto-accepted** (`verify_host_key: :never`). Sandbox host
141
+ keys are generated at spawn time, so there is no out-of-band fingerprint to
142
+ pin. If your threat model requires host-key verification, connect manually
143
+ with your own SSH tooling using `sbx.ssh_command`.
144
+
145
+ ## v0 limits
146
+
147
+ - **Free tier: 1 concurrent sandbox.** A second `Sandbox.create` raises
148
+ `Xshellz::QuotaError` while one exists - use `Sandbox.list` +
149
+ `Sandbox.connect` to attach to the existing box, or `kill` it first. Paid
150
+ plans raise the limit.
151
+ - **Free boxes idle-stop after ~30 minutes.** The box (its `/home` and your
152
+ key) is preserved; call `sbx.start` to resume it.
153
+ - Sandbox creation is throttled to 10 requests/minute per account.
154
+
155
+ ## Local development (Docker)
156
+
157
+ No local Ruby toolchain is required - the whole test suite runs inside a
158
+ `ruby:3.3` container (gems cached in a named volume):
159
+
160
+ ```bash
161
+ docker compose run --rm test
162
+ ```
163
+
164
+ ## License
165
+
166
+ MIT
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Xshellz
8
+ # Default HTTP adapter: one Net::HTTP request per call.
9
+ #
10
+ # Any object responding to +call(method, url, headers, body)+ and returning
11
+ # something with +status+ (Integer) and +body+ (String) can replace it -
12
+ # specs inject a fake so no test ever touches the network.
13
+ class NetHttpAdapter
14
+ def initialize(open_timeout: 10, read_timeout: 120)
15
+ @open_timeout = open_timeout
16
+ @read_timeout = read_timeout
17
+ end
18
+
19
+ def call(method, url, headers, body)
20
+ uri = URI.parse(url)
21
+ http = Net::HTTP.new(uri.host, uri.port)
22
+ http.use_ssl = uri.scheme == "https"
23
+ http.open_timeout = @open_timeout
24
+ http.read_timeout = @read_timeout
25
+
26
+ request = Net::HTTP.const_get(method.capitalize).new(uri.request_uri, headers)
27
+ request.body = body unless body.nil?
28
+
29
+ response = http.request(request)
30
+ Client::Response.new(status: response.code.to_i, body: response.body.to_s)
31
+ end
32
+ end
33
+
34
+ # Bearer-authenticated JSON client for the xShellz control plane
35
+ # (+https://api.xshellz.com/v1+) with typed error mapping.
36
+ class Client
37
+ Response = Struct.new(:status, :body, keyword_init: true)
38
+
39
+ DEFAULT_API_URL = "https://api.xshellz.com/v1"
40
+ API_KEY_ENV = "XSHELLZ_API_KEY"
41
+ API_URL_ENV = "XSHELLZ_API_URL"
42
+
43
+ # 403 message fragments emitted by the control plane's guard chain. All
44
+ # guards abort(403, message); quota/entitlement are distinguished by
45
+ # message text.
46
+ QUOTA_FRAGMENTS = [
47
+ "agent shell limit", # "You've reached your plan's agent shell limit (N)."
48
+ "plan does not include agent shells" # entitlement gate
49
+ ].freeze
50
+
51
+ attr_reader :api_url
52
+
53
+ def initialize(api_key: nil, api_url: nil, http: nil)
54
+ @api_key = self.class.resolve_api_key(api_key)
55
+ @api_url = self.class.resolve_api_url(api_url)
56
+ @http = http || NetHttpAdapter.new
57
+ end
58
+
59
+ # Resolve the API key (explicit argument > environment) or raise a
60
+ # helpful AuthError.
61
+ def self.resolve_api_key(api_key = nil)
62
+ key = (api_key || ENV[API_KEY_ENV] || "").strip
63
+ return key unless key.empty?
64
+
65
+ raise AuthError,
66
+ "No xShellz API key found. Pass api_key: ... or set the " \
67
+ "#{API_KEY_ENV} environment variable. Create a personal access " \
68
+ "token with `read` and `write` scopes from your xShellz dashboard " \
69
+ "(Settings -> API tokens) or via POST /v1/auth/tokens."
70
+ end
71
+
72
+ # Resolve the API base URL (explicit argument > environment > default),
73
+ # with no trailing slash.
74
+ def self.resolve_api_url(api_url = nil)
75
+ url = api_url || ENV[API_URL_ENV] || DEFAULT_API_URL
76
+ url = url.strip
77
+ url = DEFAULT_API_URL if url.empty?
78
+ url.sub(%r{/+\z}, "")
79
+ end
80
+
81
+ def get(path)
82
+ request("GET", path)
83
+ end
84
+
85
+ def post(path, body = nil)
86
+ request("POST", path, body)
87
+ end
88
+
89
+ def delete(path)
90
+ request("DELETE", path)
91
+ end
92
+
93
+ # Issue a request; return the parsed JSON body or raise a typed error.
94
+ def request(method, path, body = nil)
95
+ headers = {
96
+ "Authorization" => "Bearer #{@api_key}",
97
+ "Accept" => "application/json",
98
+ "User-Agent" => "xshellz-ruby/#{VERSION}"
99
+ }
100
+ headers["Content-Type"] = "application/json" unless body.nil?
101
+
102
+ response = @http.call(method, "#{@api_url}#{path}", headers, body.nil? ? nil : JSON.generate(body))
103
+ raise map_error(response) if response.status >= 400
104
+ return nil if response.body.nil? || response.body.empty?
105
+
106
+ JSON.parse(response.body)
107
+ end
108
+
109
+ private
110
+
111
+ def map_error(response)
112
+ status = response.status
113
+ body = parse_body(response.body)
114
+
115
+ message = ""
116
+ error_code = ""
117
+ if body.is_a?(Hash)
118
+ message = (body["message"] || "").to_s
119
+ error_code = (body["error"] || "").to_s
120
+ end
121
+ message = response.body.to_s if message.empty?
122
+ message = "HTTP #{status}" if message.empty?
123
+
124
+ case status
125
+ when 401
126
+ AuthError.new(
127
+ "Authentication failed (401): the API key is missing, invalid, " \
128
+ "expired, or revoked. Create a personal access token with `read` " \
129
+ "and `write` scopes from your xShellz dashboard (Settings -> API " \
130
+ "tokens) or via POST /v1/auth/tokens. Server said: #{message}"
131
+ )
132
+ when 403
133
+ map_forbidden(message, error_code)
134
+ when 429
135
+ ApiError.new(
136
+ "Rate limited (429): #{message} - sandbox creation is throttled " \
137
+ "to 10 requests/minute.",
138
+ status: status,
139
+ body: body
140
+ )
141
+ else
142
+ ApiError.new("HTTP #{status}: #{message}", status: status, body: body)
143
+ end
144
+ end
145
+
146
+ def map_forbidden(message, error_code)
147
+ lowered = message.downcase
148
+ if QUOTA_FRAGMENTS.any? { |fragment| lowered.include?(fragment) }
149
+ return QuotaError.new(
150
+ "#{message} Tip: on the free tier only one sandbox may exist at a " \
151
+ "time - use Sandbox.list and Sandbox.connect to attach to the " \
152
+ "existing box, or kill it first."
153
+ )
154
+ end
155
+ return AuthError.new("Account verification required (403): #{message}") if error_code == "verification_required"
156
+
157
+ AuthError.new("Forbidden (403): #{message}")
158
+ end
159
+
160
+ def parse_body(raw)
161
+ return nil if raw.nil? || raw.empty?
162
+
163
+ JSON.parse(raw)
164
+ rescue JSON::ParserError
165
+ raw
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Xshellz
4
+ # Base class for every error raised by the xShellz SDK.
5
+ class Error < StandardError; end
6
+
7
+ # Authentication or authorization failed (HTTP 401/403).
8
+ #
9
+ # Raised for a missing/invalid API key, insufficient token scopes, account
10
+ # verification requirements, and other access gates.
11
+ class AuthError < Error; end
12
+
13
+ # The account's sandbox quota or plan entitlement blocks the operation.
14
+ #
15
+ # The control plane returns HTTP 403 both when the plan's concurrent sandbox
16
+ # limit is reached ("agent shell limit") and when the plan does not include
17
+ # sandboxes at all. On the free tier the limit is 1 concurrent box - use
18
+ # +Sandbox.list+ + +Sandbox.connect+ to attach to the existing one instead
19
+ # of creating a new box.
20
+ class QuotaError < Error; end
21
+
22
+ # The sandbox is not in the +running+ state (or no longer exists).
23
+ class SandboxNotRunningError < Error; end
24
+
25
+ # A command executed with +run(timeout: ...)+ exceeded its deadline.
26
+ class CommandTimeoutError < Error; end
27
+
28
+ # Any other non-success API response (4xx/5xx).
29
+ #
30
+ # Carries the HTTP +status+ and the parsed JSON +body+ (or the raw text when
31
+ # the response was not JSON).
32
+ class ApiError < Error
33
+ attr_reader :status, :body
34
+
35
+ def initialize(message, status:, body: nil)
36
+ super(message)
37
+ @status = status
38
+ @body = body
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ed25519"
4
+ require "securerandom"
5
+
6
+ module Xshellz
7
+ # In-memory ed25519 SSH keypair generation.
8
+ #
9
+ # The keypair is generated per +Sandbox.create+ and never touches disk; the
10
+ # control plane only ever sees the public half. Both halves are encoded by
11
+ # hand (RFC 4253 public line, openssh-key-v1 private container) so the
12
+ # private key can be handed to net-ssh via +key_data+ without any file IO.
13
+ module Keys
14
+ # An in-memory ed25519 keypair.
15
+ #
16
+ # +private_key_openssh+ is the private key in OpenSSH format
17
+ # (-----BEGIN OPENSSH PRIVATE KEY-----). Persist it if you plan to
18
+ # +detach+ and +Sandbox.connect+ again later from another process.
19
+ # +public_key_line+ is the single-line OpenSSH public key
20
+ # (+ssh-ed25519 <base64> <comment>+) sent to the control plane.
21
+ KeyPair = Struct.new(:private_key_openssh, :public_key_line, keyword_init: true)
22
+
23
+ DEFAULT_KEY_COMMENT = "xshellz-sdk"
24
+ AUTH_MAGIC = "openssh-key-v1\0"
25
+ KEY_TYPE = "ssh-ed25519"
26
+ BLOCK_SIZE = 8
27
+
28
+ module_function
29
+
30
+ # Generate a fresh in-memory ed25519 keypair.
31
+ #
32
+ # @return [KeyPair]
33
+ def generate(comment: DEFAULT_KEY_COMMENT)
34
+ signing_key = Ed25519::SigningKey.generate
35
+ seed = signing_key.to_bytes
36
+ public_bytes = signing_key.verify_key.to_bytes
37
+
38
+ line = "#{KEY_TYPE} #{base64(public_key_blob(public_bytes))}"
39
+ line = "#{line} #{comment}" unless comment.to_s.empty?
40
+
41
+ KeyPair.new(
42
+ private_key_openssh: encode_openssh_private_key(seed, public_bytes, comment.to_s),
43
+ public_key_line: line
44
+ )
45
+ end
46
+
47
+ # The RFC 4253 public key blob: string "ssh-ed25519" + string key-bytes.
48
+ def public_key_blob(public_bytes)
49
+ ssh_string(KEY_TYPE) + ssh_string(public_bytes)
50
+ end
51
+
52
+ # A length-prefixed SSH wire string.
53
+ def ssh_string(value)
54
+ [value.bytesize].pack("N") + value.dup.force_encoding(Encoding::BINARY)
55
+ end
56
+
57
+ # Serialize the private key into the openssh-key-v1 container (unencrypted:
58
+ # cipher "none", kdf "none") - the format ssh-keygen writes and the one
59
+ # net-ssh's ED25519 support parses from +key_data+.
60
+ def encode_openssh_private_key(seed, public_bytes, comment)
61
+ check = SecureRandom.bytes(4)
62
+
63
+ section = "".b
64
+ section << check << check
65
+ section << ssh_string(KEY_TYPE)
66
+ section << ssh_string(public_bytes)
67
+ section << ssh_string(seed + public_bytes)
68
+ section << ssh_string(comment)
69
+ pad = 1
70
+ until (section.bytesize % BLOCK_SIZE).zero?
71
+ section << [pad].pack("C")
72
+ pad += 1
73
+ end
74
+
75
+ blob = "".b
76
+ blob << AUTH_MAGIC.b
77
+ blob << ssh_string("none") # ciphername
78
+ blob << ssh_string("none") # kdfname
79
+ blob << ssh_string("") # kdfoptions
80
+ blob << [1].pack("N") # number of keys
81
+ blob << ssh_string(public_key_blob(public_bytes))
82
+ blob << ssh_string(section)
83
+
84
+ body = base64(blob).scan(/.{1,70}/).join("\n")
85
+ "-----BEGIN OPENSSH PRIVATE KEY-----\n#{body}\n-----END OPENSSH PRIVATE KEY-----\n"
86
+ end
87
+
88
+ # Strict (unwrapped) base64 without pulling in the base64 gem.
89
+ def base64(data)
90
+ [data].pack("m0")
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Xshellz
4
+ # The outcome of a single +Sandbox#run+ invocation.
5
+ #
6
+ # A non-zero +exit_code+ does NOT raise - it is data, exactly like a local
7
+ # subprocess call.
8
+ CommandResult = Struct.new(:stdout, :stderr, :exit_code, keyword_init: true) do
9
+ # True when the command exited with status 0.
10
+ def ok?
11
+ exit_code == 0
12
+ end
13
+ end
14
+
15
+ # A sandbox as reported by the control plane (snake_case wire shape).
16
+ SandboxInfo = Struct.new(
17
+ :uuid,
18
+ :name,
19
+ :status,
20
+ :ssh_command,
21
+ :ssh_host,
22
+ :ssh_port,
23
+ :web_terminal_ready,
24
+ :always_on,
25
+ :trial_hours_remaining,
26
+ :spawned_at,
27
+ :created_at,
28
+ :isolation,
29
+ :gvisor,
30
+ keyword_init: true
31
+ ) do
32
+ # Build a +SandboxInfo+ from the API's JSON payload.
33
+ #
34
+ # Tolerant of missing keys so a newer/older API version never breaks
35
+ # deserialization.
36
+ def self.from_api(payload)
37
+ payload ||= {}
38
+ port = payload["ssh_port"]
39
+ new(
40
+ uuid: payload["uuid"].to_s,
41
+ name: payload["name"].to_s,
42
+ status: payload["status"].to_s,
43
+ ssh_command: payload["ssh_command"],
44
+ ssh_host: payload["ssh_host"],
45
+ ssh_port: port.nil? ? nil : Integer(port),
46
+ web_terminal_ready: !!payload["web_terminal_ready"],
47
+ always_on: !!payload["always_on"],
48
+ trial_hours_remaining: (payload["trial_hours_remaining"] || 0.0).to_f,
49
+ spawned_at: payload["spawned_at"],
50
+ created_at: payload["created_at"],
51
+ isolation: payload["isolation"],
52
+ gvisor: !!payload["gvisor"]
53
+ )
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Xshellz
4
+ # A remote sandbox: control plane over HTTPS, data plane over SSH.
5
+ #
6
+ # Create one with +Sandbox.create+, attach to an existing one with
7
+ # +Sandbox.connect+, or enumerate them with +Sandbox.list+.
8
+ #
9
+ # The block form destroys the sandbox when the block exits, unless
10
+ # +detach+ was called:
11
+ #
12
+ # Xshellz::Sandbox.create do |sbx|
13
+ # result = sbx.run("echo hello")
14
+ # end
15
+ class Sandbox
16
+ STATUS_RUNNING = "running"
17
+
18
+ # The last-known control-plane state (a SandboxInfo).
19
+ attr_reader :info
20
+
21
+ # OpenSSH serialization of the private key authenticating this sandbox's
22
+ # SSH (persist it to reconnect later via +Sandbox.connect+).
23
+ attr_reader :private_key_openssh
24
+
25
+ def initialize(info:, client:, private_key_openssh: nil, transport: nil)
26
+ @info = info
27
+ @client = client
28
+ @private_key_openssh = private_key_openssh
29
+ @transport = transport
30
+ @detached = false
31
+ @killed = false
32
+ end
33
+
34
+ # ------------------------------------------------------------------ #
35
+ # Constructors (control plane)
36
+ # ------------------------------------------------------------------ #
37
+
38
+ # Spawn a new sandbox and return it once it is RUNNING.
39
+ #
40
+ # An in-memory ed25519 keypair is generated for the box; the private key
41
+ # never leaves this process and the server never sees it. Spawning is
42
+ # synchronous - the box is reachable when this returns.
43
+ #
44
+ # With a block, the sandbox is yielded and destroyed when the block
45
+ # exits (unless +detach+ was called); the block's value is returned.
46
+ #
47
+ # Raises AuthError (missing/invalid API key, insufficient scope, or an
48
+ # account gate), QuotaError (the plan's concurrent sandbox limit is
49
+ # reached or the plan has no sandbox entitlement), or ApiError (other
50
+ # API failures: throttle 429, host capacity 503, ...).
51
+ def self.create(name: nil, api_key: nil, api_url: nil, http: nil)
52
+ keypair = Keys.generate
53
+ client = Client.new(api_key: api_key, api_url: api_url, http: http)
54
+
55
+ body = { "ssh_public_key" => keypair.public_key_line }
56
+ body["name"] = name unless name.nil?
57
+ payload = client.post("/shells/agent", body)
58
+
59
+ sandbox = new(
60
+ info: SandboxInfo.from_api(payload),
61
+ client: client,
62
+ private_key_openssh: keypair.private_key_openssh
63
+ )
64
+ return sandbox unless block_given?
65
+
66
+ begin
67
+ yield sandbox
68
+ ensure
69
+ sandbox.kill unless sandbox.detached?
70
+ sandbox.close
71
+ end
72
+ end
73
+
74
+ # Attach to an existing sandbox by UUID.
75
+ #
76
+ # +private_key+ is the OpenSSH-format private key whose public half the
77
+ # box was created with (the +private_key_openssh+ of the original
78
+ # sandbox).
79
+ def self.connect(uuid, private_key:, api_key: nil, api_url: nil, http: nil)
80
+ Ssh.load_private_key!(private_key)
81
+ client = Client.new(api_key: api_key, api_url: api_url, http: http)
82
+ info = find(client, uuid)
83
+ new(info: info, client: client, private_key_openssh: private_key)
84
+ end
85
+
86
+ # List the account's active sandboxes (a bare array on the wire).
87
+ #
88
+ # @return [Array<SandboxInfo>]
89
+ def self.list(api_key: nil, api_url: nil, http: nil)
90
+ client = Client.new(api_key: api_key, api_url: api_url, http: http)
91
+ client.get("/shells/agent").map { |item| SandboxInfo.from_api(item) }
92
+ end
93
+
94
+ # Resolve one sandbox via the list endpoint (there is no GET show route).
95
+ def self.find(client, uuid)
96
+ client.get("/shells/agent").each do |item|
97
+ info = SandboxInfo.from_api(item)
98
+ return info if info.uuid == uuid
99
+ end
100
+ raise SandboxNotRunningError,
101
+ "Sandbox #{uuid} was not found among this account's active sandboxes."
102
+ end
103
+
104
+ # ------------------------------------------------------------------ #
105
+ # Properties
106
+ # ------------------------------------------------------------------ #
107
+
108
+ def uuid
109
+ @info.uuid
110
+ end
111
+
112
+ def name
113
+ @info.name
114
+ end
115
+
116
+ def status
117
+ @info.status
118
+ end
119
+
120
+ def ssh_host
121
+ @info.ssh_host
122
+ end
123
+
124
+ def ssh_port
125
+ @info.ssh_port
126
+ end
127
+
128
+ def ssh_command
129
+ @info.ssh_command
130
+ end
131
+
132
+ def detached?
133
+ @detached
134
+ end
135
+
136
+ # ------------------------------------------------------------------ #
137
+ # Data plane (SSH/SFTP)
138
+ # ------------------------------------------------------------------ #
139
+
140
+ # Run a shell command in the sandbox and wait for it to finish.
141
+ #
142
+ # A non-zero exit code does NOT raise - inspect +result.exit_code+. An
143
+ # optional block receives (:stdout | :stderr, chunk) as output arrives
144
+ # (the full output is still returned in the result).
145
+ #
146
+ # Raises SandboxNotRunningError when the box is not +running+, and
147
+ # CommandTimeoutError when +timeout+ seconds elapse before exit.
148
+ #
149
+ # @return [CommandResult]
150
+ def run(command, cwd: nil, env: nil, timeout: nil, &block)
151
+ transport.exec(Ssh.build_shell_command(command, cwd: cwd, env: env), timeout: timeout, &block)
152
+ end
153
+
154
+ # Write +data+ to +path+ inside the sandbox (SFTP).
155
+ def write_file(path, data)
156
+ transport.write_file(path, data)
157
+ end
158
+
159
+ # Read and return the contents of +path+ inside the sandbox (SFTP).
160
+ def read_file(path)
161
+ transport.read_file(path)
162
+ end
163
+
164
+ # Upload a local file into the sandbox (SFTP).
165
+ def upload(local_path, remote_path)
166
+ transport.upload(local_path, remote_path)
167
+ end
168
+
169
+ # Download a file from the sandbox to a local path (SFTP).
170
+ def download(remote_path, local_path)
171
+ transport.download(remote_path, local_path)
172
+ end
173
+
174
+ # ------------------------------------------------------------------ #
175
+ # Lifecycle (control plane)
176
+ # ------------------------------------------------------------------ #
177
+
178
+ # Resume an idle-stopped box (+POST /shells/agent/{uuid}/start+).
179
+ #
180
+ # Free-tier boxes idle-stop after ~30 minutes; this brings the same box
181
+ # (same /home, same authorized key) back to +running+.
182
+ def start
183
+ begin
184
+ payload = @client.post("/shells/agent/#{uuid}/start")
185
+ rescue ApiError => e
186
+ if e.status == 404
187
+ raise SandboxNotRunningError,
188
+ "Sandbox #{uuid} has no stopped box to start - it may " \
189
+ "already be running, suspended, or deleted."
190
+ end
191
+ raise
192
+ end
193
+ @info = SandboxInfo.from_api(payload)
194
+ close_transport
195
+ self
196
+ end
197
+
198
+ # Destroy the sandbox (+DELETE /shells/agent/{uuid}+). Idempotent: a 404
199
+ # (already gone) is swallowed.
200
+ def kill
201
+ close_transport
202
+ return if @killed
203
+
204
+ begin
205
+ @client.delete("/shells/agent/#{uuid}")
206
+ rescue ApiError => e
207
+ raise unless e.status == 404
208
+ end
209
+ @killed = true
210
+ nil
211
+ end
212
+
213
+ # Keep the sandbox alive when the +create+ block exits.
214
+ #
215
+ # Persist +private_key_openssh+ and +uuid+ to re-attach later with
216
+ # +Sandbox.connect+.
217
+ def detach
218
+ @detached = true
219
+ self
220
+ end
221
+
222
+ # Re-fetch this sandbox's state from the control plane.
223
+ def refresh
224
+ @info = self.class.find(@client, uuid)
225
+ end
226
+
227
+ # Close the SSH connection (keeps the box alive).
228
+ def close
229
+ close_transport
230
+ nil
231
+ end
232
+
233
+ def inspect
234
+ "#<Xshellz::Sandbox uuid=#{uuid.inspect} status=#{status.inspect} ssh=#{ssh_host}:#{ssh_port}>"
235
+ end
236
+ alias to_s inspect
237
+
238
+ private
239
+
240
+ def transport
241
+ return @transport unless @transport.nil?
242
+
243
+ unless status == STATUS_RUNNING
244
+ raise SandboxNotRunningError,
245
+ "Sandbox #{uuid} is #{status.inspect}, not 'running'. " \
246
+ "Call start to resume an idle-stopped box."
247
+ end
248
+ if ssh_host.nil? || ssh_port.nil?
249
+ raise SandboxNotRunningError,
250
+ "Sandbox #{uuid} has no SSH endpoint yet (host/port unknown)."
251
+ end
252
+ if @private_key_openssh.nil?
253
+ raise Error,
254
+ "No private key available for this sandbox - attach with " \
255
+ "Sandbox.connect(uuid, private_key: ...)."
256
+ end
257
+
258
+ @transport = Ssh::NetSshTransport.new(
259
+ host: ssh_host,
260
+ port: ssh_port,
261
+ private_key_openssh: @private_key_openssh
262
+ )
263
+ end
264
+
265
+ def close_transport
266
+ @transport&.close
267
+ @transport = nil
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/ssh"
4
+ require "net/sftp"
5
+ require "shellwords"
6
+ require "stringio"
7
+
8
+ module Xshellz
9
+ # SSH/SFTP data plane, behind a small transport interface for testability.
10
+ #
11
+ # The real implementation (NetSshTransport) speaks SSH as +root+ to the
12
+ # sandbox. Host keys are auto-accepted (+verify_host_key: :never+):
13
+ # sandboxes are throwaway boxes whose host keys are generated at spawn
14
+ # time, so there is no out-of-band fingerprint to pin against.
15
+ module Ssh
16
+ ENV_NAME = /\A[A-Za-z_][A-Za-z0-9_]*\z/
17
+
18
+ # Wrap a command with optional +cd+ and environment exports.
19
+ #
20
+ # Environment variable names are validated (sshd rarely honours
21
+ # +AcceptEnv+, so variables are exported in the remote shell instead).
22
+ def self.build_shell_command(command, cwd: nil, env: nil)
23
+ parts = []
24
+ if env && !env.empty?
25
+ exports = env.map do |key, value|
26
+ raise Error, "Invalid environment variable name: #{key.inspect}" unless key.to_s.match?(ENV_NAME)
27
+
28
+ "#{key}=#{Shellwords.escape(value.to_s)}"
29
+ end
30
+ parts << "export #{exports.join(" ")}"
31
+ end
32
+ parts << "cd #{Shellwords.escape(cwd)}" if cwd
33
+ parts << command
34
+ parts.join(" && ")
35
+ end
36
+
37
+ # Load/validate an OpenSSH-format private key string; raises a helpful
38
+ # Xshellz::Error when it cannot be parsed.
39
+ def self.load_private_key!(private_key)
40
+ Net::SSH::KeyFactory.load_data_private_key(private_key.to_s, nil, false)
41
+ rescue StandardError => e
42
+ raise Error,
43
+ "Could not load the private key. Expected an unencrypted " \
44
+ "OpenSSH-format private key (e.g. Sandbox#private_key_openssh). " \
45
+ "Details: #{e.class}: #{e.message}"
46
+ end
47
+
48
+ # Real SSH transport: exec over a session channel, files over SFTP.
49
+ #
50
+ # The private key is handed to net-ssh in memory via +key_data+ - it
51
+ # never touches disk. Requires the ed25519 + bcrypt_pbkdf gems (declared
52
+ # as runtime dependencies) for net-ssh to accept ed25519 keys.
53
+ class NetSshTransport
54
+ POLL_INTERVAL = 0.02
55
+
56
+ def initialize(host:, port:, private_key_openssh:, username: "root", connect_timeout: 30)
57
+ @ssh = Net::SSH.start(
58
+ host,
59
+ username,
60
+ port: port,
61
+ key_data: [private_key_openssh],
62
+ keys: [],
63
+ keys_only: true,
64
+ auth_methods: ["publickey"],
65
+ non_interactive: true,
66
+ # Sandbox host keys are minted at spawn time; nothing to pin against.
67
+ verify_host_key: :never,
68
+ timeout: connect_timeout
69
+ )
70
+ @sftp = nil
71
+ end
72
+
73
+ # Execute a command; optional block receives (:stdout | :stderr, chunk)
74
+ # as output arrives. Returns a CommandResult; a non-zero exit code does
75
+ # NOT raise.
76
+ def exec(command, timeout: nil, &block)
77
+ stdout = +""
78
+ stderr = +""
79
+ exit_code = nil
80
+
81
+ channel = @ssh.open_channel do |ch|
82
+ ch.exec(command) do |_, success|
83
+ raise Error, "Failed to start command on the sandbox: #{command.inspect}" unless success
84
+
85
+ ch.on_data do |_, data|
86
+ stdout << data
87
+ block&.call(:stdout, data)
88
+ end
89
+ ch.on_extended_data do |_, _type, data|
90
+ stderr << data
91
+ block&.call(:stderr, data)
92
+ end
93
+ ch.on_request("exit-status") do |_, buffer|
94
+ exit_code = buffer.read_long
95
+ end
96
+ end
97
+ end
98
+
99
+ deadline = timeout && (monotonic + timeout)
100
+ timed_out = false
101
+ @ssh.loop(POLL_INTERVAL) do
102
+ timed_out = !deadline.nil? && monotonic > deadline
103
+ channel.active? && !timed_out
104
+ end
105
+
106
+ if timed_out && channel.active?
107
+ close_channel_quietly(channel)
108
+ raise CommandTimeoutError,
109
+ "Command did not finish within #{timeout} seconds: #{command.inspect}"
110
+ end
111
+
112
+ CommandResult.new(stdout: stdout, stderr: stderr, exit_code: exit_code || -1)
113
+ end
114
+
115
+ def read_file(path)
116
+ sftp.download!(path)
117
+ end
118
+
119
+ def write_file(path, data)
120
+ sftp.upload!(StringIO.new(data), path)
121
+ nil
122
+ end
123
+
124
+ def upload(local_path, remote_path)
125
+ sftp.upload!(local_path, remote_path)
126
+ nil
127
+ end
128
+
129
+ def download(remote_path, local_path)
130
+ sftp.download!(remote_path, local_path)
131
+ nil
132
+ end
133
+
134
+ def close
135
+ @sftp = nil
136
+ @ssh.close unless @ssh.closed?
137
+ end
138
+
139
+ private
140
+
141
+ def sftp
142
+ @sftp ||= Net::SFTP::Session.new(@ssh).connect!
143
+ end
144
+
145
+ def monotonic
146
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
147
+ end
148
+
149
+ def close_channel_quietly(channel)
150
+ channel.close
151
+ @ssh.loop(POLL_INTERVAL) { channel.active? }
152
+ rescue StandardError
153
+ nil
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Xshellz
4
+ VERSION = "0.1.0"
5
+ end
data/lib/xshellz.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "xshellz/version"
4
+ require_relative "xshellz/errors"
5
+ require_relative "xshellz/models"
6
+ require_relative "xshellz/keys"
7
+ require_relative "xshellz/client"
8
+ require_relative "xshellz/ssh"
9
+ require_relative "xshellz/sandbox"
10
+
11
+ # xShellz Ruby SDK - throwaway, gVisor-isolated Linux sandboxes.
12
+ #
13
+ # Quickstart:
14
+ #
15
+ # require "xshellz"
16
+ #
17
+ # Xshellz::Sandbox.create do |sbx|
18
+ # result = sbx.run("ruby -e 'puts 6*7'")
19
+ # puts result.stdout # "42"
20
+ # end
21
+ module Xshellz
22
+ end
metadata ADDED
@@ -0,0 +1,113 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: xshellz
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - xShellz
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bcrypt_pbkdf
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: ed25519
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.3'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.3'
41
+ - !ruby/object:Gem::Dependency
42
+ name: net-sftp
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '4.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '4.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: net-ssh
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '7.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '7.0'
69
+ description: Spawn throwaway, gVisor-isolated Linux sandboxes and run commands in
70
+ them from your own program - control plane over HTTPS, data plane over SSH/SFTP.
71
+ email:
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - LICENSE
77
+ - README.md
78
+ - lib/xshellz.rb
79
+ - lib/xshellz/client.rb
80
+ - lib/xshellz/errors.rb
81
+ - lib/xshellz/keys.rb
82
+ - lib/xshellz/models.rb
83
+ - lib/xshellz/sandbox.rb
84
+ - lib/xshellz/ssh.rb
85
+ - lib/xshellz/version.rb
86
+ homepage: https://github.com/xshellz/xshellz-ruby
87
+ licenses:
88
+ - MIT
89
+ metadata:
90
+ homepage_uri: https://github.com/xshellz/xshellz-ruby
91
+ source_code_uri: https://github.com/xshellz/xshellz-ruby
92
+ changelog_uri: https://github.com/xshellz/xshellz-ruby/releases
93
+ rubygems_mfa_required: 'true'
94
+ post_install_message:
95
+ rdoc_options: []
96
+ require_paths:
97
+ - lib
98
+ required_ruby_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '3.1'
103
+ required_rubygems_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ requirements: []
109
+ rubygems_version: 3.5.22
110
+ signing_key:
111
+ specification_version: 4
112
+ summary: Official Ruby SDK for xShellz sandboxes.
113
+ test_files: []