withruntime 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.
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WithRuntime
4
+ # One authenticated WebSocket multiplexes connections to the sandbox's loopback.
5
+ class Tunnel
6
+ WINDOW = 1 << 20
7
+ def initialize(sandbox)
8
+ @lock, @credit, @send_lock = Mutex.new, ConditionVariable.new, Mutex.new
9
+ @streams, @next, @sent, @credited, @closed = {}, 1, 0, 0, false
10
+ @socket = WebSocket.new(sandbox.transport, sandbox.path("/tunnel"))
11
+ deadline = @socket.now + @socket.timeout
12
+ loop do
13
+ frame = @socket.read(deadline)
14
+ raise Error.new("Tunnel ended before ready.", code: "tunnel_refused") unless frame
15
+ next unless frame[0] == 1
16
+ reply = JSON.parse(frame[1])
17
+ raise Error.from(0, reply, nil) if reply["type"] == "error"
18
+ break if reply["type"] == "ready"
19
+ end
20
+ @reader = Thread.new do
21
+ begin
22
+ while (frame = @socket.read)
23
+ receive(*frame)
24
+ end
25
+ close
26
+ rescue StandardError => error
27
+ finish(error)
28
+ end
29
+ end
30
+ rescue StandardError
31
+ @socket&.close
32
+ raise
33
+ end
34
+ def connect(port)
35
+ raise ArgumentError, "port must be 1 to 65535" unless port.is_a?(Integer) && port.between?(1, 65535)
36
+ open_stream("tcp #{port}")
37
+ end
38
+ def ssh(public_key) = open_stream("ssh #{public_key}")
39
+ def open_stream(target)
40
+ stream = @lock.synchronize do
41
+ raise closed_error if @closed
42
+ raise Error.new("Tunnel stream IDs exhausted.", code: "tunnel_exhausted") if @next > 0xffffffff
43
+ id = @next
44
+ @next += 1
45
+ @streams[id] = Stream.new(self, id)
46
+ end
47
+ begin
48
+ control("o", stream.id, target)
49
+ stream.wait_open(@socket.timeout)
50
+ stream
51
+ rescue StandardError
52
+ stream.close
53
+ raise
54
+ end
55
+ end
56
+ def closed_error = @error || Error.new("The tunnel is closed.", code: "tunnel_closed")
57
+ def control(kind, id, data = "")
58
+ raise closed_error if @lock.synchronize { @closed }
59
+ @socket.send_frame(2, kind.b + [id].pack("N") + data.b)
60
+ end
61
+ def write(stream, bytes)
62
+ @send_lock.synchronize do
63
+ (0...bytes.bytesize).step(65536) do |at|
64
+ part = bytes.byteslice(at, 65536)
65
+ @lock.synchronize do
66
+ @credit.wait(@lock) while !@closed && !stream.closed? && @sent + part.bytesize - @credited > WINDOW
67
+ raise closed_error if @closed
68
+ raise IOError, "Stream closed" if stream.closed?
69
+ @sent += part.bytesize
70
+ end
71
+ control("d", stream.id, part)
72
+ end
73
+ end
74
+ bytes.bytesize
75
+ end
76
+ def forget(id)
77
+ @lock.synchronize { @streams.delete(id); @credit.broadcast }
78
+ end
79
+ def async_close(id)
80
+ Thread.new do
81
+ control("c", id)
82
+ rescue StandardError
83
+ nil
84
+ end
85
+ end
86
+ def receive(op, data)
87
+ if op == 1
88
+ reply = JSON.parse(data)
89
+ raise Error.from(0, reply, nil) if reply["type"] == "error"
90
+ return
91
+ end
92
+ raise Error.new("Malformed tunnel frame.", code: "tunnel_protocol") if data.bytesize < 5
93
+ kind, id, bytes = data.byteslice(0, 1), data.byteslice(1, 4).unpack1("N"), data.byteslice(5..)
94
+ if kind == "a"
95
+ raise Error.new("Malformed tunnel credit.", code: "tunnel_protocol") unless bytes.bytesize == 8
96
+ amount = bytes.unpack1("Q>")
97
+ @lock.synchronize do
98
+ return if @closed
99
+ raise Error.new("Invalid tunnel credit.", code: "tunnel_protocol") unless amount.between?(@credited, @sent)
100
+ @credited = amount
101
+ @credit.broadcast
102
+ end
103
+ return
104
+ end
105
+ stream = @lock.synchronize { @streams[id] }
106
+ return unless stream
107
+ case kind
108
+ when "o" then stream.opened
109
+ when "d"
110
+ unless stream.push(bytes)
111
+ stream.finish(Error.new("Read responses while writing; this stream's unread buffer is full.", code: "tunnel_receive_overflow"))
112
+ async_close(id)
113
+ end
114
+ when "e" then stream.eof
115
+ when "c"
116
+ code = { "closed" => "port_closed", "reserved" => "port_reserved", "no-sshd" => "sshd_missing" }.fetch(bytes, "tunnel_refused")
117
+ stream.finish(bytes == "done" ? nil : Error.new("Sandbox connection ended: #{bytes}", code: code))
118
+ end
119
+ end
120
+ def finish(error = nil)
121
+ streams = @lock.synchronize do
122
+ return if @closed
123
+ @closed, @error = true, error
124
+ @credit.broadcast
125
+ values = @streams.values
126
+ @streams = {}
127
+ values
128
+ end
129
+ @socket.close
130
+ streams.each { |stream| stream.finish(error) }
131
+ nil
132
+ end
133
+ def close = finish
134
+
135
+ class Stream
136
+ attr_reader :id
137
+ def initialize(tunnel, id)
138
+ @tunnel, @id, @buffer = tunnel, id, StreamBuffer.new
139
+ @lock, @opened, @write_lock = Mutex.new, ConditionVariable.new, Mutex.new
140
+ @closed = @ready = @eof = false
141
+ end
142
+ def closed? = @closed
143
+ def opened = @lock.synchronize { @ready = true; @opened.broadcast }
144
+ def wait_open(timeout)
145
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
146
+ @lock.synchronize do
147
+ until @ready || @closed
148
+ left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
149
+ raise Error.new("Opening the tunnel stream timed out.", code: "timeout") unless left.positive?
150
+ @opened.wait(@lock, left)
151
+ end
152
+ raise(@error || Error.new("The stream closed before opening.", code: "tunnel_refused")) unless @ready
153
+ end
154
+ end
155
+ def push(bytes) = @buffer.push(bytes)
156
+ def eof = @buffer.finish
157
+ def read(length = 65536) = @buffer.read(length)
158
+ def write(bytes)
159
+ @write_lock.synchronize do
160
+ raise IOError, "Stream input is closed" if @closed || @eof
161
+ @tunnel.write(self, bytes.to_s.b)
162
+ end
163
+ end
164
+ def close_write
165
+ @write_lock.synchronize do
166
+ return if @eof || @closed
167
+ @eof = true
168
+ @tunnel.control("e", @id)
169
+ end
170
+ nil
171
+ end
172
+ def finish(error = nil)
173
+ changed = @lock.synchronize do
174
+ next false if @closed
175
+ @closed, @error = true, error
176
+ @opened.broadcast
177
+ true
178
+ end
179
+ if changed
180
+ @buffer.finish(error)
181
+ @tunnel.forget(@id)
182
+ end
183
+ nil
184
+ end
185
+ def close
186
+ return if @closed
187
+ finish
188
+ @tunnel.async_close(@id)
189
+ nil
190
+ end
191
+ end
192
+ end
193
+
194
+ # Copies each accepted local TCP connection over a stream in one tunnel.
195
+ class PortForward
196
+ attr_reader :host, :port
197
+ def initialize(sandbox, remote_port, host:, local_port:)
198
+ raise ArgumentError, "port must be 1 to 65535" unless remote_port.is_a?(Integer) && remote_port.between?(1, 65535)
199
+ @lock, @clients, @closed = Mutex.new, [], false
200
+ @listener = TCPServer.new(host, local_port)
201
+ @host, @port = @listener.addr[3], @listener.addr[1]
202
+ @tunnel = sandbox.open_tunnel
203
+ @accept = Thread.new do
204
+ loop do
205
+ local = @listener.accept
206
+ @lock.synchronize { @clients << local }
207
+ if @closed
208
+ close_local(local)
209
+ break
210
+ end
211
+ Thread.new(local) { |client| forward(client, remote_port) }
212
+ end
213
+ rescue IOError, SystemCallError
214
+ close
215
+ end
216
+ rescue StandardError
217
+ @listener&.close
218
+ @tunnel&.close
219
+ raise
220
+ end
221
+ def forward(local, remote_port)
222
+ stream = @tunnel.connect(remote_port)
223
+ directions, lock = 2, Mutex.new
224
+ finish = lambda do
225
+ last = lock.synchronize { directions -= 1; directions.zero? }
226
+ if last
227
+ stream.close
228
+ close_local(local)
229
+ end
230
+ end
231
+ Thread.new do
232
+ begin
233
+ loop { stream.write(local.readpartial(65536)) }
234
+ rescue EOFError
235
+ stream.close_write
236
+ rescue StandardError
237
+ stream.close
238
+ close_local(local)
239
+ ensure
240
+ finish.call
241
+ end
242
+ end
243
+ begin
244
+ while (data = stream.read)
245
+ local.write(data)
246
+ end
247
+ local.close_write
248
+ rescue StandardError
249
+ stream.close
250
+ close_local(local)
251
+ ensure
252
+ finish.call
253
+ end
254
+ rescue StandardError
255
+ close_local(local)
256
+ end
257
+ def close_local(local)
258
+ @lock.synchronize { @clients.delete(local) }
259
+ local.close unless local.closed?
260
+ rescue IOError, SystemCallError
261
+ nil
262
+ end
263
+ def close
264
+ clients = @lock.synchronize do
265
+ return if @closed
266
+ @closed = true
267
+ @clients.dup
268
+ end
269
+ @listener.close unless @listener.closed?
270
+ clients.each { |client| close_local(client) }
271
+ @tunnel.close
272
+ nil
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WithRuntime
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "digest"
5
+
6
+ module WithRuntime
7
+ # One bounded RFC6455 connection, using the transport's origin, key and proxy rules.
8
+ class WebSocket
9
+ attr_reader :timeout
10
+ def initialize(transport, path, query = nil)
11
+ @transport = transport
12
+ @timeout = transport.timeout
13
+ @write_lock, @close_lock = Mutex.new, Mutex.new
14
+ @closed = false
15
+ uri = URI(transport.base_url)
16
+ proxy = Transport.send(:proxy_for, uri)
17
+ if proxy && proxy.scheme != "http"
18
+ raise Error.new("WebSocket proxy must use http CONNECT.", code: "proxy_unsupported")
19
+ end
20
+ @raw = Socket.tcp(proxy ? proxy.host : uri.host, proxy ? proxy.port : uri.port, connect_timeout: @timeout)
21
+ @io = @raw
22
+ deadline = now + @timeout
23
+ if proxy
24
+ authority = "#{uri.host}:#{uri.port}"
25
+ auth = proxy.user ? "Proxy-Authorization: Basic #{["#{URI.decode_www_form_component(proxy.user)}:#{URI.decode_www_form_component(proxy.password.to_s)}"].pack("m0")}\r\n" : ""
26
+ write_all("CONNECT #{authority} HTTP/1.1\r\nHost: #{authority}\r\n#{auth}\r\n", deadline)
27
+ raise Error.new("The proxy refused the WebSocket connection.", code: "proxy_refused") unless headers(deadline).first.match?(/\AHTTP\/1\.[01] 200 /)
28
+ end
29
+ if uri.scheme == "https"
30
+ context = OpenSSL::SSL::SSLContext.new
31
+ context.set_params
32
+ tls = OpenSSL::SSL::SSLSocket.new(@raw, context)
33
+ tls.hostname = uri.host
34
+ @io = tls
35
+ loop do
36
+ result = tls.connect_nonblock(exception: false)
37
+ break unless %i[wait_readable wait_writable].include?(result)
38
+ wait(result, deadline)
39
+ end
40
+ tls.post_connection_check(uri.host)
41
+ end
42
+ key = [SecureRandom.random_bytes(16)].pack("m0")
43
+ target = path + (query && !query.empty? ? "?#{URI.encode_www_form(query.reject { |_, value| value.nil? })}" : "")
44
+ host = uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}"
45
+ write_all("GET #{target} HTTP/1.1\r\nHost: #{host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: #{key}\r\nAuthorization: Bearer #{transport.api_key}\r\nX-Runtime-Client: sdk-ruby/#{VERSION}\r\n\r\n", deadline)
46
+ status, fields = headers(deadline)
47
+ accept = [Digest::SHA1.digest(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")].pack("m0")
48
+ unless status.match?(/\AHTTP\/1\.[01] 101 /) && fields["sec-websocket-accept"] == accept && fields["upgrade"].to_s.downcase == "websocket"
49
+ raise Error.new("The server refused the WebSocket upgrade.", code: "websocket_refused")
50
+ end
51
+ rescue *Transport::NETWORK_ERRORS => error
52
+ close
53
+ raise @transport.connection_error(error.is_a?(Timeout::Error), false, nil, error)
54
+ rescue StandardError
55
+ close
56
+ raise
57
+ end
58
+
59
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
60
+ def wait(direction, deadline)
61
+ left = deadline && deadline - now
62
+ raise Error.new("The stream deadline passed.", code: "timeout") if left && left <= 0
63
+ selected = direction == :wait_writable ? IO.select(nil, [@io], nil, left) : IO.select([@io], nil, nil, left)
64
+ raise Error.new("The stream deadline passed.", code: "timeout") unless selected
65
+ end
66
+ def exact(length, deadline = nil)
67
+ out = +"".b
68
+ while out.bytesize < length
69
+ part = @io.read_nonblock(length - out.bytesize, exception: false)
70
+ raise EOFError if part.nil?
71
+ if part.is_a?(Symbol)
72
+ wait(part, deadline)
73
+ else
74
+ out << part
75
+ end
76
+ end
77
+ out
78
+ end
79
+ def write_all(bytes, deadline)
80
+ at = 0
81
+ while at < bytes.bytesize
82
+ n = @io.write_nonblock(bytes.byteslice(at..), exception: false)
83
+ n.is_a?(Symbol) ? wait(n, deadline) : at += n
84
+ end
85
+ end
86
+ def headers(deadline)
87
+ text = +""
88
+ until text.end_with?("\r\n\r\n")
89
+ raise Error.new("WebSocket response headers are too large.", code: "websocket_protocol") if text.bytesize >= 65536
90
+ text << exact(1, deadline)
91
+ end
92
+ lines = text.split("\r\n")
93
+ [lines.shift, lines.to_h { |line| name, value = line.split(":", 2); [name.downcase, value.to_s.strip] }]
94
+ end
95
+ def send_frame(op, bytes)
96
+ bytes = bytes.b
97
+ @write_lock.synchronize do
98
+ raise IOError, "WebSocket closed" if @closed
99
+ mask = SecureRandom.random_bytes(4)
100
+ size = bytes.bytesize
101
+ header = [0x80 | op].pack("C")
102
+ header << if size < 126 then [0x80 | size].pack("C") elsif size <= 65535 then [0xfe, size].pack("Cn") else [0xff, size].pack("CQ>") end
103
+ masked = bytes.bytes.each_with_index.map { |value, index| value ^ mask.getbyte(index % 4) }.pack("C*")
104
+ write_all(header + mask + masked, now + @timeout)
105
+ end
106
+ rescue *Transport::NETWORK_ERRORS => error
107
+ raise @transport.connection_error(error.is_a?(Timeout::Error), false, nil, error)
108
+ end
109
+ def read(deadline = nil)
110
+ message = +"".b
111
+ kind = nil
112
+ loop do
113
+ first, second = exact(2, deadline).unpack("CC")
114
+ raise Error.new("Invalid WebSocket frame.", code: "websocket_protocol") unless (first & 0x70).zero? && (second & 0x80).zero?
115
+ op, final, length = first & 15, (first & 0x80) != 0, second & 127
116
+ length = exact(2, deadline).unpack1("n") if length == 126
117
+ length = exact(8, deadline).unpack1("Q>") if length == 127
118
+ if length > 1_048_581 || (op >= 8 && (!final || length > 125))
119
+ raise Error.new("WebSocket frame exceeds its bound.", code: "stream_overflow")
120
+ end
121
+ data = exact(length, deadline)
122
+ return nil if op == 8
123
+ if op == 9
124
+ send_frame(10, data)
125
+ next
126
+ end
127
+ next if op == 10
128
+ if op == 0
129
+ raise Error.new("Unexpected continuation.", code: "websocket_protocol") unless kind
130
+ else
131
+ raise Error.new("Invalid data frame.", code: "websocket_protocol") unless [1, 2].include?(op) && kind.nil?
132
+ kind = op
133
+ end
134
+ message << data
135
+ raise Error.new("WebSocket message exceeds its bound.", code: "stream_overflow") if message.bytesize > 1_048_581
136
+ return [kind, message] if final
137
+ end
138
+ rescue *Transport::NETWORK_ERRORS => error
139
+ raise @transport.connection_error(error.is_a?(Timeout::Error), false, nil, error)
140
+ end
141
+ def close
142
+ return unless @close_lock
143
+ @close_lock.synchronize do
144
+ return if @closed
145
+ @closed = true
146
+ @raw&.close unless @raw&.closed?
147
+ end
148
+ rescue IOError, SystemCallError
149
+ nil
150
+ end
151
+ end
152
+
153
+ # Producer never waits for a reader. The stream owner decides how overflow is reported.
154
+ class StreamBuffer
155
+ def initialize
156
+ @lock, @changed = Mutex.new, ConditionVariable.new
157
+ @queue, @bytes, @ended = [], 0, false
158
+ end
159
+ def push(bytes)
160
+ @lock.synchronize do
161
+ return true if @ended
162
+ return false if @bytes + bytes.bytesize > 1 << 20 || @queue.length >= 1024
163
+ unless bytes.empty?
164
+ @queue << bytes
165
+ @bytes += bytes.bytesize
166
+ @changed.broadcast
167
+ end
168
+ true
169
+ end
170
+ end
171
+ def finish(error = nil)
172
+ @lock.synchronize { unless @ended; @ended = true; @error = error; @changed.broadcast; end }
173
+ end
174
+ def read(length = 65536)
175
+ raise ArgumentError, "length must be positive" unless length.positive?
176
+ @lock.synchronize do
177
+ @changed.wait(@lock) while @queue.empty? && !@ended
178
+ if @queue.empty?
179
+ raise @error if @error
180
+ return nil
181
+ end
182
+ data = @queue.shift
183
+ if data.bytesize > length
184
+ @queue.unshift(data.byteslice(length..))
185
+ data = data.byteslice(0, length)
186
+ end
187
+ @bytes -= data.bytesize
188
+ data
189
+ end
190
+ end
191
+ end
192
+
193
+ class Terminal
194
+ attr_reader :process_id, :exit_code
195
+ def initialize(sandbox, **options)
196
+ @socket = WebSocket.new(sandbox.transport, sandbox.path("/terminal"), Fields.body(options))
197
+ @buffer = StreamBuffer.new
198
+ deadline = @socket.now + @socket.timeout
199
+ loop do
200
+ message = @socket.read(deadline)
201
+ raise Error.new("Terminal ended before ready.", code: "terminal_refused") unless message
202
+ next unless message[0] == 1
203
+ reply = JSON.parse(message[1])
204
+ raise Error.from(0, reply, nil) if reply["type"] == "error"
205
+ if reply["type"] == "ready"
206
+ @process_id = reply["processId"]
207
+ break
208
+ end
209
+ end
210
+ @reader = Thread.new do
211
+ begin
212
+ while (message = @socket.read)
213
+ if message[0] == 2
214
+ raise Error.new("Read terminal output while writing input; its unread buffer is full.", code: "terminal_receive_overflow") unless @buffer.push(message[1])
215
+ else
216
+ reply = JSON.parse(message[1])
217
+ if reply["type"] == "exit"
218
+ @exit_code = reply["exitCode"]
219
+ break
220
+ end
221
+ raise Error.from(0, reply, nil) if reply["type"] == "error"
222
+ end
223
+ end
224
+ @buffer.finish
225
+ rescue StandardError => error
226
+ @buffer.finish(@closed ? nil : error)
227
+ ensure
228
+ @socket.close
229
+ end
230
+ end
231
+ rescue StandardError
232
+ @socket&.close
233
+ raise
234
+ end
235
+ def read(length = 65536) = @buffer.read(length)
236
+ def write(bytes)
237
+ bytes = bytes.to_s.b
238
+ (0...bytes.bytesize).step(65536) { |at| @socket.send_frame(2, bytes.byteslice(at, 65536)) }
239
+ bytes.bytesize
240
+ end
241
+ def resize(cols:, rows:) = @socket.send_frame(1, JSON.generate(type: "resize", cols: cols, rows: rows))
242
+ def close
243
+ @closed = true
244
+ @socket.close
245
+ @buffer.finish
246
+ nil
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The Ruby client for Runtime Cloud: Linux sandboxes for agents, and every
4
+ # other Runtime product as it launches. Standard library only.
5
+ #
6
+ # require "withruntime"
7
+ #
8
+ # runtime = WithRuntime::Client.new # RUNTIME_API_KEY, or this machine's saved connection
9
+ # sbx = runtime.sandboxes.create(funding: "trial")
10
+ # puts sbx.exec("python3 -c 'print(6 * 7)'", check: true).stdout
11
+ # sbx.stop
12
+ #
13
+ # The guide is at https://withruntime.com/docs/ruby.
14
+ require "json"
15
+ require "openssl"
16
+
17
+ require_relative "withruntime/version"
18
+ require_relative "withruntime/errors"
19
+ require_relative "withruntime/record"
20
+ require_relative "withruntime/fields"
21
+ require_relative "withruntime/transport"
22
+ require_relative "withruntime/credentials"
23
+ require_relative "withruntime/tar"
24
+ require_relative "withruntime/sandbox"
25
+ require_relative "withruntime/products"
26
+ require_relative "withruntime/images"
27
+ require_relative "withruntime/network_products"
28
+ require_relative "withruntime/parity_products"
29
+ require_relative "withruntime/file_watch"
30
+ require_relative "withruntime/websocket"
31
+ require_relative "withruntime/tunnel"
32
+ require_relative "withruntime/client"
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: withruntime
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Runtime
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'Linux sandboxes for agents, and every other Runtime Cloud product: streaming
13
+ commands and terminals, files and watches, images, durable volumes and backups,
14
+ domains, TCP ports, private networking and secrets. Standard library only.'
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - GUIDE.md
20
+ - LICENSE
21
+ - lib/withruntime.rb
22
+ - lib/withruntime/client.rb
23
+ - lib/withruntime/credentials.rb
24
+ - lib/withruntime/errors.rb
25
+ - lib/withruntime/fields.rb
26
+ - lib/withruntime/file_watch.rb
27
+ - lib/withruntime/images.rb
28
+ - lib/withruntime/network_products.rb
29
+ - lib/withruntime/parity_products.rb
30
+ - lib/withruntime/products.rb
31
+ - lib/withruntime/record.rb
32
+ - lib/withruntime/sandbox.rb
33
+ - lib/withruntime/tar.rb
34
+ - lib/withruntime/transport.rb
35
+ - lib/withruntime/tunnel.rb
36
+ - lib/withruntime/version.rb
37
+ - lib/withruntime/websocket.rb
38
+ homepage: https://withruntime.com
39
+ licenses:
40
+ - Apache-2.0
41
+ metadata:
42
+ homepage_uri: https://withruntime.com
43
+ documentation_uri: https://withruntime.com/docs/ruby
44
+ source_code_uri: https://github.com/withruntime/runtime/tree/main/sdks/ruby
45
+ bug_tracker_uri: https://github.com/withruntime/runtime/issues
46
+ rubygems_mfa_required: 'true'
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '3.2'
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 4.0.16
62
+ specification_version: 4
63
+ summary: 'Runtime Cloud: one client for every Runtime product. Sandboxes first.'
64
+ test_files: []