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,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module WithRuntime
6
+ # The pieces every product shares.
7
+ class Product
8
+ def initialize(transport)
9
+ @t = transport
10
+ end
11
+
12
+ private
13
+
14
+ def seg(value) = Transport.segment(value)
15
+
16
+ def page(path, query)
17
+ body = @t.json("GET", path, query: query)
18
+ Page.new(body["data"].map { |item| Record.new(item) }, body["nextCursor"]) do |cursor|
19
+ page(path, query.merge("cursor" => cursor))
20
+ end
21
+ end
22
+ end
23
+
24
+ # +runtime.snapshots+. Take one with +sbx.snapshot+, start from one with
25
+ # +runtime.sandboxes.create(snapshot: id)+, or do both with +sbx.fork+.
26
+ class Snapshots < Product
27
+ # Snapshots a sandbox by id, as it is: the API answers how the sandbox must be.
28
+ def create(sandbox_id, name: nil, labels: nil, retention_days: nil, idempotency_key: nil)
29
+ body = Fields.body(name: name, labels: labels, retention_days: retention_days)
30
+ Record.new(@t.json("POST", "/v1/sandboxes/#{seg(sandbox_id)}:snapshot", body: body, wait: 10, key: idempotency_key))
31
+ end
32
+
33
+ def get(id) = Record.new(@t.json("GET", "/v1/snapshots/#{seg(id)}"))
34
+
35
+ # state: capturing, ready, failed or deleting.
36
+ def list(sandbox_id: nil, name: nil, state: nil, limit: nil)
37
+ page("/v1/snapshots", { "sandboxId" => sandbox_id, "name" => name, "state" => state, "limit" => limit })
38
+ end
39
+
40
+ def delete(id)
41
+ @t.json("POST", "/v1/snapshots/#{seg(id)}:delete", body: {})
42
+ nil
43
+ end
44
+
45
+ # Keeps a snapshot for +retention_days+ from now.
46
+ def extend_retention(id, retention_days)
47
+ Record.new(@t.json("POST", "/v1/snapshots/#{seg(id)}:extend", body: { "retentionDays" => retention_days }))
48
+ end
49
+ end
50
+
51
+ # +runtime.volumes+: persistent disks. Create one, then attach it when
52
+ # creating a sandbox: +volumes: [{ volume_id: v.id, path: "/data" }]+. A
53
+ # volume lives on one host; its backups can restore onto another host.
54
+ class Volumes < Product
55
+ # Creates a volume and waits (up to 10 seconds) until it is ready.
56
+ def create(size_mib: nil, name: nil, labels: nil, region: nil, from_backup: nil, idempotency_key: nil)
57
+ body = Fields.body(size_mib: size_mib, name: name, labels: labels, region: region, from_backup: from_backup)
58
+ Record.new(@t.json("POST", "/v1/volumes", body: body, wait: 10, key: idempotency_key))
59
+ end
60
+
61
+ def backup(id, name: nil, labels: nil, retention_days: nil, wait: 60, idempotency_key: nil)
62
+ Record.new(@t.json("POST", "/v1/volumes/#{seg(id)}:backup", body: Fields.body(name: name, labels: labels, retention_days: retention_days), wait: wait, key: idempotency_key))
63
+ end
64
+ def set_backup_policy(id, daily: nil, retention_days: nil) = Record.new(@t.json("POST", "/v1/volumes/#{seg(id)}:backup-policy", body: Fields.body(daily: daily, retention_days: retention_days)))
65
+ def backups(volume_id: nil, state: nil, limit: nil) = page("/v1/volume-backups", { "volumeId" => volume_id, "state" => state, "limit" => limit })
66
+ def get_backup(id) = Record.new(@t.json("GET", "/v1/volume-backups/#{seg(id)}"))
67
+ def delete_backup(id) = Record.new(@t.json("POST", "/v1/volume-backups/#{seg(id)}:delete", body: {}))
68
+ def restore(id, **options) = create(**options.merge(from_backup: id))
69
+
70
+ def get(id) = Record.new(@t.json("GET", "/v1/volumes/#{seg(id)}"))
71
+
72
+ def list(state: nil, name: nil, limit: nil) = page("/v1/volumes", { "state" => state, "name" => name, "limit" => limit })
73
+
74
+ # Deletes a volume and everything on it.
75
+ def delete(id) = Record.new(@t.json("POST", "/v1/volumes/#{seg(id)}:delete", body: {}))
76
+ end
77
+
78
+ # +runtime.secrets+: a value your sandboxes use without seeing it. Every
79
+ # sandbox of the organization has an environment variable of the secret's
80
+ # name holding a placeholder; the egress proxy puts the value into HTTPS
81
+ # requests to its hosts. The value is never returned.
82
+ class Secrets < Product
83
+ # Stores or replaces a secret for +hosts+ ("api.openai.com", "*.github.com").
84
+ # With +header:+, that header is set on every request to them, +format:+'s
85
+ # {value} replaced.
86
+ def set(name, value:, hosts:, header: nil, format: nil)
87
+ body = { "value" => value, "hosts" => hosts, "header" => header, "format" => format }.compact
88
+ Record.new(@t.json("PUT", "/v1/egress-secrets/#{seg(name)}", body: body))
89
+ end
90
+
91
+ # Names, hosts and placeholders. Never values.
92
+ def list = @t.json("GET", "/v1/egress-secrets")["secrets"].map { |secret| Record.new(secret) }
93
+
94
+ # Erases a secret; its placeholder stops working.
95
+ def delete(name)
96
+ @t.json("DELETE", "/v1/egress-secrets/#{seg(name)}")
97
+ nil
98
+ end
99
+ end
100
+
101
+ # +runtime.limits+: whether this key is read-only, and what its agent may still spend today.
102
+ class Limits < Product
103
+ def get = Record.new(@t.json("GET", "/v1/limits"))
104
+ end
105
+
106
+ # +runtime.referrals+: the account's referral link and what it earned.
107
+ class Referrals < Product
108
+ def get = Record.new(@t.json("GET", "/v1/referrals"))
109
+ end
110
+
111
+ # +runtime.switching+: compare usage with a rival's published rates, and record a switch.
112
+ class Switching < Product
113
+ # Your settled usage over the last +days+ (30 by default, at most 90) priced
114
+ # on Runtime and at +provider+ (e2b, daytona, vercel, modal, cloudflare, fly, fly-machines).
115
+ def compare(provider, days: nil) = Record.new(@t.json("GET", "/v1/usage/compare", query: { "provider" => provider, "days" => days }))
116
+
117
+ def get = Record.new(@t.json("GET", "/v1/switching"))
118
+
119
+ # Once per organization, before its first top-up.
120
+ def record(provider) = Record.new(@t.json("POST", "/v1/switching", body: { "provider" => provider }))
121
+ end
122
+
123
+ # +runtime.events+: lifecycle events, newest first.
124
+ class Events < Product
125
+ def list(resource_id: nil, type: nil, limit: nil)
126
+ page("/v1/events", { "resourceId" => resource_id, "type" => type, "limit" => limit })
127
+ end
128
+ end
129
+
130
+ # +runtime.webhooks+: signed lifecycle events POSTed to your URL.
131
+ class Webhooks < Product
132
+ # Returns the webhook with its +secret+, shown this once. +events+ nil is every event.
133
+ def create(url:, events: nil, description: nil)
134
+ Record.new(@t.json("POST", "/v1/webhooks", body: { "url" => url, "events" => events, "description" => description }.compact))
135
+ end
136
+
137
+ def list = @t.json("GET", "/v1/webhooks")["data"].map { |hook| Record.new(hook) }
138
+
139
+ def get(id) = Record.new(@t.json("GET", "/v1/webhooks/#{seg(id)}"))
140
+
141
+ # Changes url, events, description or enabled; fields left out stay as they are.
142
+ def update(id, **patch) = Record.new(@t.json("POST", "/v1/webhooks/#{seg(id)}:update", body: Fields.body(patch)))
143
+
144
+ # A new secret, returned once. The old one keeps signing for
145
+ # +keep_previous_seconds+ (a day by default, a week at most; 0 ends it now).
146
+ def rotate_secret(id, keep_previous_seconds: nil)
147
+ body = { "keepPreviousSeconds" => keep_previous_seconds }.compact
148
+ Record.new(@t.json("POST", "/v1/webhooks/#{seg(id)}:rotate-secret", body: body))
149
+ end
150
+
151
+ def delete(id)
152
+ @t.json("POST", "/v1/webhooks/#{seg(id)}:delete", body: {})
153
+ nil
154
+ end
155
+
156
+ # Sends a signed webhook.test now and says how your endpoint answered.
157
+ def test(id) = Record.new(@t.json("POST", "/v1/webhooks/#{seg(id)}:test", body: {}, wait: 10))
158
+
159
+ # state: pending, succeeded, failed or cancelled.
160
+ def deliveries(id, state: nil) = page("/v1/webhooks/#{seg(id)}/deliveries", { "state" => state })
161
+
162
+ # Sends one delivery again, once, now.
163
+ def retry(delivery_id) = Record.new(@t.json("POST", "/v1/webhook-deliveries/#{seg(delivery_id)}:retry", body: {}))
164
+
165
+ # Checks a delivery's Runtime-Signature header against the raw body and
166
+ # your secret (or secrets, during a rotation), and returns the event.
167
+ # Raises WebhookVerificationError when the signature does not match or is
168
+ # older than +tolerance+ seconds (300), which stops a captured delivery
169
+ # being replayed. Pass the body exactly as received.
170
+ def self.verify(body, header, secret, tolerance: 300, now: Time.now.to_i)
171
+ raise WebhookVerificationError, "Missing Runtime-Signature header." if header.nil? || header.empty?
172
+
173
+ parts = header.split(",").map { |part| part.strip.split("=", 2) }
174
+ stamp = parts.find { |key, _| key == "t" }&.last
175
+ given = parts.select { |key, value| key == "v1" && value && !value.empty? }.map(&:last)
176
+ raise WebhookVerificationError, "Malformed Runtime-Signature header." unless stamp&.match?(/\A\d+\z/) && !given.empty?
177
+ raise WebhookVerificationError, "The signature is too old; the delivery may be a replay." if (now - stamp.to_i).abs > tolerance
178
+
179
+ Array(secret).each do |key|
180
+ expected = OpenSSL::HMAC.hexdigest("SHA256", key, "#{stamp}.#{body}")
181
+ next unless given.any? { |value| value.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(value, expected) }
182
+
183
+ begin
184
+ return Record.new(JSON.parse(body))
185
+ rescue JSON::ParserError
186
+ raise WebhookVerificationError, "The body is not JSON."
187
+ end
188
+ end
189
+ raise WebhookVerificationError, "No signature matches the secret."
190
+ end
191
+ end
192
+
193
+ # +runtime.otel+: events (as logs) and CPU and memory (as metrics) pushed to
194
+ # an OpenTelemetry endpoint over OTLP/HTTP.
195
+ class Otel < Product
196
+ # +headers+ authenticate to the endpoint and are never shown back.
197
+ def create(endpoint:, headers: nil, signals: nil)
198
+ Record.new(@t.json("POST", "/v1/otel-exports", body: { "endpoint" => endpoint, "headers" => headers, "signals" => signals }.compact))
199
+ end
200
+
201
+ def list = @t.json("GET", "/v1/otel-exports")["data"].map { |export| Record.new(export) }
202
+ def get(id) = Record.new(@t.json("GET", "/v1/otel-exports/#{seg(id)}"))
203
+ def update(id, **patch) = Record.new(@t.json("POST", "/v1/otel-exports/#{seg(id)}:update", body: Fields.body(patch)))
204
+
205
+ # Pushes now instead of at the next interval.
206
+ def flush(id) = Record.new(@t.json("POST", "/v1/otel-exports/#{seg(id)}:flush", body: {}))
207
+
208
+ def delete(id)
209
+ @t.json("POST", "/v1/otel-exports/#{seg(id)}:delete", body: {})
210
+ nil
211
+ end
212
+ end
213
+
214
+ # +runtime.audit+: the account's audit log, newest first.
215
+ class Audit < Product
216
+ # +events+ and +next+ (pass it as +before:+). +action:+ is an action
217
+ # (key.created) or a group ending in a dot (member.).
218
+ def list(action: nil, limit: nil, before: nil)
219
+ Record.new(@t.json("GET", "/v1/audit", query: { "action" => action, "limit" => limit, "before" => before }))
220
+ end
221
+ end
222
+
223
+ # +runtime.feedback+: tell the Runtime team something.
224
+ class Feedback < Product
225
+ # +kind+ is bug, missing_feature, competitor_gap, migration_blocker, docs, pricing, praise or other.
226
+ def submit(kind:, summary:, detail: nil, competitor: nil, resource_id: nil, request_id: nil, context: nil)
227
+ body = Fields.body(kind: kind, summary: summary, detail: detail, competitor: competitor,
228
+ resource_id: resource_id, request_id: request_id, context: context)
229
+ Record.new(@t.json("POST", "/v1/feedback", body: body))
230
+ end
231
+
232
+ # This account's reports and where each stands.
233
+ def list(limit: nil) = @t.json("GET", "/v1/feedback", query: { "limit" => limit })["data"].map { |item| Record.new(item) }
234
+ end
235
+
236
+ # +runtime.support+: ask Runtime support.
237
+ class Support < Product
238
+ # When the status is "working", +read+ it again in a minute. Not retried:
239
+ # support cannot deduplicate a message.
240
+ def message(message = nil, conversation_id: nil, approve_action_id: nil, approve_input_hash: nil, deny_action_id: nil)
241
+ body = Fields.body(message: message, conversation_id: conversation_id, approve_action_id: approve_action_id,
242
+ approve_input_hash: approve_input_hash, deny_action_id: deny_action_id)
243
+ Record.new(@t.json("POST", "/v1/support/messages", body: body, no_retry: true, timeout: 120))
244
+ end
245
+
246
+ def read(conversation_id) = Record.new(@t.json("GET", "/v1/support/conversations/#{seg(conversation_id)}"))
247
+ end
248
+
249
+ # +sbx.network+: turn the sandbox's internet off or on, narrow it to a list,
250
+ # refuse destinations, or open host:port pairs. Changes apply at once.
251
+ class Network
252
+ def initialize(sandbox)
253
+ @sandbox = sandbox
254
+ end
255
+
256
+ def get = Record.new(@sandbox.transport.json("GET", @sandbox.path("/network")))
257
+
258
+ # Replaces the rules: +internet:+, +allow:+ (example.com, *.example.com, an
259
+ # address or a CIDR range), +deny:+, and +connect:+ (host:port pairs beyond
260
+ # 80 and 443; paid accounts only).
261
+ def set(internet:, allow: nil, deny: nil, connect: nil)
262
+ body = { "internet" => internet, "allow" => allow, "deny" => deny, "connect" => connect }.compact
263
+ Record.new(@sandbox.transport.json("PUT", @sandbox.path("/network"), body: body))
264
+ end
265
+
266
+ def off = set(internet: false)
267
+ def on = set(internet: true)
268
+ end
269
+
270
+ # +sbx.interpreter+: stateful Python and JavaScript cells, like a notebook.
271
+ class Interpreter
272
+ RESULT = %r{\A/workspace/\.runtime/interpreter/([a-z0-9][a-z0-9-]*)/out/([A-Za-z0-9][A-Za-z0-9_.-]*)\z}
273
+
274
+ def initialize(sandbox)
275
+ @sandbox = sandbox
276
+ end
277
+
278
+ # Runs a cell. +language:+ is "python" (the default) or "javascript";
279
+ # +context:+ is a context id. With +on_stdout+, +on_stderr+ or +on_result+,
280
+ # output streams as it happens. Returns the execution: status (ok, error,
281
+ # interrupted, timeout, lost), stdout, stderr, results and error.
282
+ def run(code, language: nil, context: nil, timeout: nil, on_stdout: nil, on_stderr: nil, on_result: nil)
283
+ body = { "code" => code, "language" => language, "context" => context,
284
+ "timeoutMs" => timeout && (timeout * 1000).round }.compact
285
+ return Record.new(t.json("POST", path(":run"), body: body)) unless on_stdout || on_stderr || on_result
286
+
287
+ t.events("POST", path(":run"), body: body.merge("stream" => true)) do |event|
288
+ case event["k"]
289
+ when "stdout" then on_stdout&.call(event["text"])
290
+ when "stderr" then on_stderr&.call(event["text"])
291
+ when "result" then on_result&.call(Record.new(event))
292
+ when "execution" then return Record.new(event["execution"])
293
+ when "failure" then raise Error.new(event["message"], code: event["code"], hint: event["hint"])
294
+ end
295
+ end
296
+ raise Error.new("The interpreter stream ended without a result.", code: "stream_failed")
297
+ end
298
+
299
+ def contexts = t.json("GET", path("/contexts"))["data"].map { |context| Record.new(context) }
300
+
301
+ def create_context(id: nil, language: nil, cwd: nil, env: nil)
302
+ Record.new(t.json("POST", path("/contexts"), body: Fields.body(id: id, language: language, cwd: cwd, env: env)))
303
+ end
304
+
305
+ def restart_context(id) = Record.new(t.json("POST", path("/contexts/#{Transport.segment(id)}:restart"), body: {}))
306
+ def interrupt_context(id) = t.json("POST", path("/contexts/#{Transport.segment(id)}:interrupt"), body: {})["interrupted"] == true
307
+ def remove_context(id) = t.json("DELETE", path("/contexts/#{Transport.segment(id)}"))["deleted"] == true
308
+
309
+ # The bytes of a result too large to travel inline (a refs entry's path).
310
+ def result(result_path)
311
+ match = RESULT.match(result_path) or raise ArgumentError, "Not an interpreter result path."
312
+ t.bytes("GET", path("/contexts/#{match[1]}/results/#{match[2]}"), accept: "application/octet-stream")
313
+ end
314
+
315
+ private
316
+
317
+ def t = @sandbox.transport
318
+ def path(suffix) = @sandbox.path("/interpreter#{suffix}")
319
+ end
320
+
321
+ # +sbx.desktop+: a Linux desktop in the sandbox, driven like a person would.
322
+ # Coordinates are pixels from the top left of the screen.
323
+ class Desktop
324
+ def initialize(sandbox)
325
+ @sandbox = sandbox
326
+ end
327
+
328
+ def recordings = Recordings.new(@sandbox)
329
+
330
+ # Starts the desktop; +stream_url+ opens it live in a browser.
331
+ def start(width: nil, height: nil)
332
+ Record.new(t.json("POST", @sandbox.path("/desktop:start"), body: { "width" => width, "height" => height }.compact))
333
+ end
334
+
335
+ def stop = t.json("POST", @sandbox.path("/desktop:stop"), body: {}) && nil
336
+
337
+ # PNG bytes, or JPEG with +format: "jpeg"+.
338
+ def screenshot(format: nil, quality: nil)
339
+ t.bytes("GET", @sandbox.path("/desktop/screenshot"), query: { "format" => format, "quality" => quality })
340
+ end
341
+
342
+ def move(x, y) = act("action" => "move", "x" => x, "y" => y)
343
+ def click(x = nil, y = nil, button: nil, double: nil) = act({ "action" => "click", "x" => x, "y" => y, "button" => button, "double" => double }.compact)
344
+ def double_click(x = nil, y = nil) = click(x, y, double: true)
345
+ def right_click(x = nil, y = nil) = click(x, y, button: "right")
346
+ def mouse_down(button = "left") = act("action" => "mouseDown", "button" => button)
347
+ def mouse_up(button = "left") = act("action" => "mouseUp", "button" => button)
348
+ def drag(from, to) = act("action" => "drag", "from" => from, "to" => to)
349
+
350
+ # Wheel clicks: positive +dy+ scrolls down, positive +dx+ right.
351
+ def scroll(dy, dx: nil, x: nil, y: nil) = act({ "action" => "scroll", "dy" => dy, "dx" => dx, "x" => x, "y" => y }.compact)
352
+
353
+ def type(text, delay_ms: nil) = act({ "action" => "type", "text" => text, "delayMs" => delay_ms }.compact)
354
+
355
+ # xdotool key names, space separated: "ctrl+l", "Return", "alt+Tab".
356
+ def press(keys) = act("action" => "key", "keys" => keys)
357
+
358
+ def cursor = act("action" => "cursor")
359
+ def windows = act("action" => "windows")["windows"]
360
+ def focus(window_id) = act("action" => "focus", "windowId" => window_id)
361
+
362
+ # Opens +url+ in Firefox on the desktop.
363
+ def open(url) = act("action" => "open", "url" => url)
364
+
365
+ # Starts a program on the desktop, detached.
366
+ def launch(*argv) = act("action" => "launch", "argv" => argv)
367
+
368
+ private
369
+
370
+ def t = @sandbox.transport
371
+ def act(body) = Record.new(t.json("POST", @sandbox.path("/desktop:act"), body: body))
372
+ end
373
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WithRuntime
4
+ # An answer from the API: the whole JSON object, read by snake_case name
5
+ # (+info.expires_at+, +info.memory_mib+) or by the API's own key
6
+ # (+info["expiresAt"]+). A field newer than this SDK is always there.
7
+ class Record
8
+ attr_reader :to_h
9
+
10
+ def initialize(hash)
11
+ @to_h = (hash || {}).freeze
12
+ @index = @to_h.keys.to_h { |key| [key.to_s.delete("_").downcase, key] }
13
+ end
14
+
15
+ def [](key) = @to_h[key.to_s]
16
+
17
+ def key?(name) = @index.key?(name.to_s.delete("_").downcase)
18
+
19
+ # A field by snake_case name; nil when the answer does not carry it, as a
20
+ # missing key reads in the other SDKs.
21
+ def method_missing(name, *args)
22
+ return super unless args.empty? && name.match?(/\A[a-z][a-z0-9_]*\??\z/) && !name.start_with?("to_")
23
+
24
+ key = @index[name.to_s.delete_suffix("?").delete("_").downcase]
25
+ value = key && wrap(@to_h[key])
26
+ name.end_with?("?") ? !!value : value
27
+ end
28
+
29
+ def respond_to_missing?(name, include_private = false)
30
+ @index.key?(name.to_s.delete_suffix("?").delete("_").downcase) || super
31
+ end
32
+
33
+ def ==(other) = other.is_a?(Record) && other.to_h == to_h
34
+
35
+ def to_s = JSON.generate(to_h)
36
+
37
+ def inspect = "#<#{self.class.name} #{to_s}>"
38
+
39
+ private
40
+
41
+ def wrap(value)
42
+ case value
43
+ when Hash then Record.new(value)
44
+ when Array then value.map { |item| wrap(item) }
45
+ else value
46
+ end
47
+ end
48
+ end
49
+
50
+ # A finished command. A timeout is a result (+timed_out+), not an exception.
51
+ class CommandResult < Record
52
+ def exit_code = self["exitCode"]
53
+ def stdout = self["stdout"] || ""
54
+ def stderr = self["stderr"] || ""
55
+ def timed_out = self["timedOut"] == true
56
+ def ok? = !timed_out && exit_code == 0
57
+ end
58
+
59
+ # One page of a list. +next_page+ reads the one after it; +each+ walks every
60
+ # item on this page and on every page after it.
61
+ class Page
62
+ include Enumerable
63
+ attr_reader :data, :next_cursor
64
+
65
+ def initialize(data, next_cursor, &fetch)
66
+ @data = data
67
+ @next_cursor = next_cursor
68
+ @fetch = fetch
69
+ end
70
+
71
+ def more? = !(next_cursor.nil? || next_cursor.empty?)
72
+
73
+ def next_page = more? ? @fetch.call(next_cursor) : nil
74
+
75
+ def each(&block)
76
+ return enum_for(:each) unless block
77
+
78
+ page = self
79
+ while page
80
+ page.data.each(&block)
81
+ page = page.next_page
82
+ end
83
+ end
84
+ end
85
+ end