withruntime 0.3.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53b4e63218b0587cacbd8e0ad8ec583ba4ce9b32ec0b792208b8f70750b5acd5
4
- data.tar.gz: 3461a8000fc4817de3f06c6f6f40b6e5d29b49312a4e1f9c9dc94a649e91b8ae
3
+ metadata.gz: 32515cd23a35b91a06324a7af01623f6dd75015c116e041c499e7a9b9548609c
4
+ data.tar.gz: 67454492f534d850643392417477f3f3d2782e7564bebb43555f76c6a1c9adfa
5
5
  SHA512:
6
- metadata.gz: b1bb2aea12c2959b3afcd694ad90124d1c04fcf60201fdece3f5f1264b40bfa3b3f8deec865c5265d806b6e8c389ebacfd4da833648133fdba93b9ece3d7352f
7
- data.tar.gz: d0238775905bea2408a2d930a402f49a4001bf900c3a8c13acbd511aaf5c486b7b01da8b5f93dfc188e50b696e135b8b92dfd75a7f9c74f5d4dcbdfcce63b668
6
+ metadata.gz: 0ce1fea1e0289719efc5d1ece2a48181e8238a400c4abd25d3ed79c12661b6c28465acb33c43681f32dad71428c86a2bd94c11eae6692fbcc2d97e29d31c95c3
7
+ data.tar.gz: 07a2ef09506dd306e8843f1add75482ec75be67e696dcb3b9c66edec9d6b1540208914be83b2f165f89e92f9a54d3c4f27554d435b3ab74c8b5e7f2d67e0d866
@@ -0,0 +1,423 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WithRuntime
4
+ # An agent: +id+, +name+, +version+ (one more on each update), +definition+,
5
+ # +labels+, +created_at+, +updated_at+, and +warnings+, such as
6
+ # "subscription_for_customers" for an agent that serves your customers on a
7
+ # subscription login (an empty list when there are none).
8
+ class Agent < Record
9
+ def warnings = self["warnings"] || []
10
+ end
11
+
12
+ # One entry of a session's log: +seq+, +kind+ (input, answer, reply, ask,
13
+ # status, result, error or note), +actor+, +body+ and +created_at+. +text+
14
+ # is its words, +body.text+.
15
+ class AgentEvent < Record
16
+ def text = self["body"].is_a?(Hash) ? self["body"]["text"] : nil
17
+ end
18
+
19
+ # +runtime.agents+: agents that run in their own sandbox per session, started
20
+ # by your own app through the API, Slack, GitHub, a schedule or a webhook. A private
21
+ # beta: an account outside it is refused +product_in_beta+
22
+ # (PermissionDeniedError).
23
+ #
24
+ # definition = runtime.agents.templates.definition("claude-code", use: "team")
25
+ # runtime.agents.create("reviewer", definition)
26
+ # session = runtime.agents.start("reviewer", "Review the open pull requests.")
27
+ # session.events.each { |event| puts event.text if event.kind == "reply" }
28
+ #
29
+ # A definition is a Hash, by snake_case keyword (+memory_mib:+) or by the
30
+ # API's own key ("memoryMiB"): +command+ (required), +from+ ({ image: } or
31
+ # { snapshot: }), +vcpu+ and +memory_mib+ (4 vCPU and 8 GiB unless set),
32
+ # +instructions+, +env+, +secrets+ (names of egress secrets: model keys go
33
+ # here), +mcp+ ([{ name:, url: }]), +volumes+ ([{ volume:, path: }]),
34
+ # +idle_pause_seconds+, +max_session_seconds+, +max_concurrent+,
35
+ # +max_session_dollars+ (5 unless set: a session at it stops, reason
36
+ # "budget"), +max_day_dollars+ (100 unless set: an agent at it starts nothing
37
+ # new, ConflictError +budget_reached+, until the next day) and +template+
38
+ # ("support" or "team").
39
+ class Agents < Product
40
+ attr_reader :sessions, :triggers, :templates
41
+
42
+ def initialize(transport)
43
+ super
44
+ @sessions = AgentSessions.new(transport)
45
+ @triggers = AgentTriggers.new(transport)
46
+ @templates = AgentTemplates.new
47
+ # Approvals come later, here: agents.approvals.list / answer.
48
+ end
49
+
50
+ # Makes an agent at version 1. A name already used is a ConflictError
51
+ # (name_taken); the names sessions, triggers, templates and approvals are
52
+ # refused, since they are paths beside the agents.
53
+ def create(name, definition, labels: nil, idempotency_key: nil)
54
+ body = Fields.body(name: name, definition: AgentTemplates.plain(definition), labels: labels)
55
+ Agent.new(@t.json("POST", "/v1/agents", body: body, key: idempotency_key))
56
+ end
57
+
58
+ # By id or by name.
59
+ def get(agent) = Agent.new(@t.json("GET", "/v1/agents/#{seg(agent)}"))
60
+
61
+ # The first page of agents, at most +limit+ a page; +each+ walks every page.
62
+ def list(cursor: nil, limit: nil) = agents_page(cursor, limit)
63
+
64
+ # Replaces the whole definition: a new version. Sessions already running
65
+ # keep the version they started with.
66
+ def update(agent, definition)
67
+ body = { "definition" => Fields.convert(AgentTemplates.plain(definition)) }
68
+ Agent.new(@t.json("PATCH", "/v1/agents/#{seg(agent)}", body: body))
69
+ end
70
+
71
+ # Deletes an agent: its triggers go and its open sessions are asked to stop.
72
+ def delete(agent) = @t.json("DELETE", "/v1/agents/#{seg(agent)}") && nil
73
+
74
+ # Starts a session with +input+, its first words. With +key:+, your own
75
+ # name for the conversation (a Slack thread, a pull request, one of your
76
+ # customers such as "customer:42"), words with the same key join the open
77
+ # session instead of starting a second; +session.joined+ says so. An agent
78
+ # at its day's budget is refused +budget_reached+ (ConflictError).
79
+ def start(agent, input, key: nil, labels: nil, idempotency_key: nil)
80
+ body = Fields.body(input: input, key: key, labels: labels)
81
+ answer = @t.json("POST", "/v1/agents/#{seg(agent)}/sessions", body: body, key: idempotency_key)
82
+ AgentSession.new(@t, answer["session"], joined: answer["joined"] == true, seq: answer["seq"])
83
+ end
84
+
85
+ private
86
+
87
+ def agents_page(cursor, limit)
88
+ body = @t.json("GET", "/v1/agents", query: { "cursor" => cursor, "limit" => limit })
89
+ Page.new(body["data"].map { |item| Agent.new(item) }, body["nextCursor"]) { |after| agents_page(after, limit) }
90
+ end
91
+ end
92
+
93
+ # +runtime.agents.sessions+: find sessions. A session is one piece of work
94
+ # or one conversation; it has its own sandbox while it runs.
95
+ class AgentSessions < Product
96
+ # A session to follow, send to, stop or wait for.
97
+ def get(id)
98
+ info = @t.json("GET", "/v1/agents/sessions/#{seg(id)}")
99
+ AgentSession.new(@t, info, joined: false, seq: info["lastSeq"])
100
+ end
101
+
102
+ # Deletes an ended session and its history; until then it is kept,
103
+ # counted as storage. An open session is refused +session_open+
104
+ # (ConflictError): stop it first.
105
+ def delete(id) = @t.json("DELETE", "/v1/agents/sessions/#{seg(id)}") && nil
106
+
107
+ # The first page of sessions, as records; +each+ walks every page.
108
+ # +agent+ is an id or a name; +state+ is queued, running, waiting, done,
109
+ # failed or stopped; at most +limit+ a page.
110
+ def list(agent: nil, state: nil, cursor: nil, limit: nil)
111
+ sessions_page({ "agent" => agent, "state" => state, "limit" => limit }, cursor)
112
+ end
113
+
114
+ private
115
+
116
+ def sessions_page(query, cursor)
117
+ body = @t.json("GET", "/v1/agents/sessions", query: query.merge("cursor" => cursor))
118
+ Page.new(body["data"].map { |item| Record.new(item) }, body["nextCursor"]) { |after| sessions_page(query, after) }
119
+ end
120
+ end
121
+
122
+ # A session of an agent. +info+ is its latest record: +state+ (queued,
123
+ # running, waiting, done, failed or stopped), +reason+, +result+,
124
+ # +last_seq+ and the rest; once the API sends them, +parent_session_id+ and
125
+ # +root_session_id+ (a child session's parent and its tree's root), +cost+
126
+ # ({ dollars, vcpuSeconds, gibSeconds } so far), +tree_cost+ (its children's
127
+ # summed in), +history_bytes+ (text history, counted as storage) and
128
+ # +attachment_bytes+ (files and screenshots, counted as storage), each nil
129
+ # until then; history is kept until the session is deleted
130
+ # (+sessions.delete+). +joined+ is whether +start+
131
+ # joined an open session with the same key, and +seq+ the number of the
132
+ # words it sent.
133
+ class AgentSession
134
+ ENDED = %w[done failed stopped].freeze
135
+ # How long the API holds an events read open, and the margin past it a
136
+ # read waits for its answer.
137
+ POLL_SECONDS = 30
138
+ POLL_MARGIN = 15
139
+
140
+ attr_reader :info, :joined, :seq
141
+
142
+ def initialize(transport, info, joined: false, seq: nil)
143
+ @t = transport
144
+ @info = Record.new(info)
145
+ @joined = joined
146
+ @seq = seq
147
+ end
148
+
149
+ def id = @info["id"]
150
+ def state = @info["state"]
151
+ def joined? = @joined
152
+ def ended? = ENDED.include?(state)
153
+
154
+ # Reads the session again.
155
+ def refresh(timeout: nil)
156
+ @info = Record.new(@t.json("GET", path, timeout: timeout))
157
+ self
158
+ end
159
+
160
+ # Sends words into a running or waiting session and answers their seq.
161
+ # The idempotency key, made here when not given, means a retried send is
162
+ # never two messages. Named send_message, not send, so Ruby's own
163
+ # Object#send stays as it is.
164
+ def send_message(text, idempotency_key: nil)
165
+ @t.json("POST", path("/messages"), body: { "text" => text }, key: idempotency_key)["seq"]
166
+ end
167
+
168
+ # The session's log after +after+ (0, the whole log), in order, each an
169
+ # AgentEvent; without a block, an Enumerator. It waits for new events and
170
+ # ends once the session has ended and its last event has been given.
171
+ def events(after: 0, &block)
172
+ return enum_for(:events, after: after) unless block
173
+
174
+ wait = POLL_SECONDS
175
+ loop do
176
+ page = read_events(after, wait)
177
+ data = page["data"] || []
178
+ data.each do |item|
179
+ event = AgentEvent.new(item)
180
+ after = event.seq
181
+ yield event
182
+ end
183
+ # Ended: read on at once, without waiting, until nothing is left.
184
+ if page["ended"]
185
+ return nil if wait.zero? && data.empty?
186
+
187
+ wait = 0
188
+ end
189
+ end
190
+ end
191
+
192
+ # Asks the session to stop and keeps the answer. A running session ends
193
+ # once its sandbox is deleted; this does not wait for that (+wait+ does).
194
+ def stop
195
+ @info = Record.new(@t.json("POST", path("/stop"), body: {}))
196
+ end
197
+
198
+ # The session once it has ended (done, failed or stopped); a failed
199
+ # session is returned, not raised. Waits on the server, with no polling.
200
+ # +timeout+ in seconds (nil or 0: no deadline) raises a ConnectionError
201
+ # with code "timeout" when it passes first.
202
+ def wait(timeout: nil)
203
+ deadline = timeout&.positive? ? now + timeout : nil
204
+ loop do
205
+ refresh(timeout: deadline && left(deadline))
206
+ return @info if ended?
207
+
208
+ loop do
209
+ seconds = deadline ? [POLL_SECONDS, left(deadline).ceil].min : POLL_SECONDS
210
+ page = read_events(@info["lastSeq"] || 0, seconds)
211
+ break if page["ended"] || !(page["data"] || []).empty?
212
+
213
+ left(deadline) if deadline
214
+ end
215
+ end
216
+ end
217
+
218
+ def inspect = "#<#{self.class.name} #{id} #{state}>"
219
+
220
+ private
221
+
222
+ def path(suffix = "") = "/v1/agents/sessions/#{Transport.segment(id)}#{suffix}"
223
+
224
+ # One long poll: its own deadline past the API's hold, whatever the client's.
225
+ def read_events(after, wait)
226
+ @t.json("GET", path("/events"), query: { "after" => after, "wait" => wait }, timeout: [@t.timeout, wait + POLL_MARGIN].max)
227
+ end
228
+
229
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
230
+
231
+ def left(deadline)
232
+ seconds = deadline - now
233
+ raise @t.connection_error(true, false, nil) unless seconds.positive?
234
+
235
+ seconds
236
+ end
237
+ end
238
+
239
+ # +runtime.agents.triggers+: what starts or feeds an agent's sessions
240
+ # besides the API. A trigger is +id+, +agent_id+, +kind+, +config+,
241
+ # +paused_at+, +paused_why+ and +created_at+. The kinds and their configs:
242
+ #
243
+ # slack { team: "T…", channel: "C…" }, once Runtime's Slack app is installed
244
+ # github { repository: "owner/name", installation: <Runtime's GitHub App
245
+ # installation id, the organization's own>, events: [...] (optional) }
246
+ # schedule { cron: "0 9 * * 1-5", timezone: "America/Denver" (optional), input: "words" }
247
+ # webhook {}
248
+ #
249
+ # A slack or github trigger whose app is not installed for the account is
250
+ # refused +not_installed+ (ConflictError).
251
+ class AgentTriggers < Product
252
+ def create(agent, kind, config = {})
253
+ Record.new(@t.json("POST", "/v1/agents/#{seg(agent)}/triggers", body: { "kind" => kind, "config" => Fields.convert(config.to_h) }))
254
+ end
255
+
256
+ def list(agent) = @t.json("GET", "/v1/agents/#{seg(agent)}/triggers")["data"].map { |row| Record.new(row) }
257
+
258
+ # Holds this trigger alone; its events are refused +trigger_paused+, with
259
+ # +why+, where they came from. Nothing else is held.
260
+ def pause(id, why) = Record.new(@t.json("POST", "/v1/agents/triggers/#{seg(id)}/pause", body: { "why" => why }))
261
+
262
+ def resume(id) = Record.new(@t.json("POST", "/v1/agents/triggers/#{seg(id)}/resume", body: {}))
263
+
264
+ def delete(id) = @t.json("DELETE", "/v1/agents/triggers/#{seg(id)}") && nil
265
+ end
266
+
267
+ # +runtime.agents.templates+: the five harnesses an agent can run, each
268
+ # usable for either use, and the two uses.
269
+ #
270
+ # runtime.agents.templates.map(&:key) # claude-code, codex, gemini-cli, claude-agent-sdk, openai-agents-sdk
271
+ # runtime.agents.templates["codex"].api_key_secret
272
+ # runtime.agents.templates.uses["support"].session_key
273
+ # runtime.agents.templates.definition("claude-code", use: "support")
274
+ #
275
+ # A subscription is for your own team's agents. An agent that serves your
276
+ # customers (api or webhook sessions for end users) uses an API key: those
277
+ # are the providers' terms, and the API warns +subscription_for_customers+
278
+ # on an agent that does not.
279
+ class AgentTemplates
280
+ include Enumerable
281
+
282
+ # The same values as every Runtime SDK and the guide
283
+ # (packages/cloud-guide/agent-templates.json).
284
+ DATA = {
285
+ "templates" => {
286
+ "claude-code" => {
287
+ "key" => "claude-code",
288
+ "title" => "Claude Code",
289
+ "command" => "npx -y @anthropic-ai/claude-code --dangerously-skip-permissions --mcp-config ~/.runtime-agent/mcp.json -p \"$(cat ~/.runtime-agent/start.md)\"",
290
+ "apiKeySecret" => "ANTHROPIC_API_KEY",
291
+ "apiKeyHost" => "api.anthropic.com",
292
+ "subscription" => "token: run `claude setup-token` on your own computer (Pro/Max), then `runtime secrets set CLAUDE_CODE_OAUTH_TOKEN` scoped to api.anthropic.com",
293
+ "definition" => {
294
+ "command" => "npx -y @anthropic-ai/claude-code --dangerously-skip-permissions --mcp-config ~/.runtime-agent/mcp.json -p \"$(cat ~/.runtime-agent/start.md)\"",
295
+ "secrets" => ["ANTHROPIC_API_KEY"]
296
+ }
297
+ },
298
+ "codex" => {
299
+ "key" => "codex",
300
+ "title" => "Codex CLI",
301
+ "command" => "npx -y @openai/codex exec --dangerously-bypass-approvals-and-sandbox \"$(cat ~/.runtime-agent/start.md)\"",
302
+ "apiKeySecret" => "OPENAI_API_KEY",
303
+ "apiKeyHost" => "api.openai.com",
304
+ "subscription" => "login once in the sandbox's terminal: `npx -y @openai/codex login --device-auth`, kept in the snapshot or a volume at `~/.codex`",
305
+ "definition" => {
306
+ "command" => "npx -y @openai/codex exec --dangerously-bypass-approvals-and-sandbox \"$(cat ~/.runtime-agent/start.md)\"",
307
+ "secrets" => ["OPENAI_API_KEY"]
308
+ }
309
+ },
310
+ "gemini-cli" => {
311
+ "key" => "gemini-cli",
312
+ "title" => "Gemini CLI",
313
+ "command" => "npx -y @google/gemini-cli --yolo -p \"$(cat ~/.runtime-agent/start.md)\"",
314
+ "apiKeySecret" => "GEMINI_API_KEY",
315
+ "apiKeyHost" => "generativelanguage.googleapis.com",
316
+ "subscription" => "login once in the sandbox's terminal (Sign in with Google), kept at `~/.gemini`",
317
+ "definition" => {
318
+ "command" => "npx -y @google/gemini-cli --yolo -p \"$(cat ~/.runtime-agent/start.md)\"",
319
+ "secrets" => ["GEMINI_API_KEY"]
320
+ }
321
+ },
322
+ "claude-agent-sdk" => {
323
+ "key" => "claude-agent-sdk",
324
+ "title" => "Claude Agent SDK",
325
+ "command" => "python3 ~/agent.py",
326
+ "apiKeySecret" => "ANTHROPIC_API_KEY",
327
+ "apiKeyHost" => "api.anthropic.com",
328
+ "subscription" => nil,
329
+ "definition" => {
330
+ "command" => "python3 ~/agent.py",
331
+ "secrets" => ["ANTHROPIC_API_KEY"]
332
+ }
333
+ },
334
+ "openai-agents-sdk" => {
335
+ "key" => "openai-agents-sdk",
336
+ "title" => "OpenAI Agents SDK",
337
+ "command" => "python3 ~/agent.py",
338
+ "apiKeySecret" => "OPENAI_API_KEY",
339
+ "apiKeyHost" => "api.openai.com",
340
+ "subscription" => nil,
341
+ "definition" => {
342
+ "command" => "python3 ~/agent.py",
343
+ "secrets" => ["OPENAI_API_KEY"]
344
+ }
345
+ }
346
+ },
347
+ "uses" => {
348
+ "support" => {
349
+ "key" => "support",
350
+ "title" => "Your customers",
351
+ "sessionKey" => "customer:<id>",
352
+ "triggers" => %w[api webhook],
353
+ "definition" => {
354
+ "template" => "support",
355
+ "vcpu" => 1,
356
+ "memoryMiB" => 1024,
357
+ "idlePauseSeconds" => 10,
358
+ "maxConcurrent" => 1000
359
+ }
360
+ },
361
+ "team" => {
362
+ "key" => "team",
363
+ "title" => "Your team",
364
+ "sessionKey" => nil,
365
+ "triggers" => %w[slack github],
366
+ "definition" => {
367
+ "template" => "team",
368
+ "vcpu" => 4,
369
+ "memoryMiB" => 8192
370
+ }
371
+ }
372
+ }
373
+ }.then { |data| JSON.parse(JSON.generate(data), freeze: true) }
374
+
375
+ # A definition given as a Record (a template's own) as the Hash it holds.
376
+ def self.plain(definition) = definition.is_a?(Record) ? definition.to_h : definition
377
+
378
+ # The harness template +key+ (+key+, +title+, +command+, +api_key_secret+,
379
+ # +api_key_host+, +subscription+: how to log in on one, or nil, and
380
+ # +definition+), or nil.
381
+ def [](key) = DATA["templates"][key.to_s]&.then { |template| Record.new(template) }
382
+
383
+ def keys = DATA["templates"].keys
384
+
385
+ # The harness templates, in order.
386
+ def each(&block)
387
+ return enum_for(:each) unless block
388
+
389
+ DATA["templates"].each_value { |template| block.call(Record.new(template)) }
390
+ end
391
+
392
+ # The uses, "support" (your customers, driven by your own app: one session
393
+ # per end customer under your own key such as "customer:42", paused between
394
+ # their messages) and "team" (your team's
395
+ # code, from Slack and GitHub): +key+, +title+, +session_key+, +triggers+
396
+ # and the +definition+ preset merged over a template's.
397
+ def uses = DATA["uses"].transform_values { |use| Record.new(use) }
398
+
399
+ # A new definition: the template's, with the use's preset over it. Ready
400
+ # for +agents.create(name, definition)+. An unknown key raises
401
+ # InvalidRequestError before anything is sent.
402
+ def definition(template, use: nil)
403
+ found = DATA["templates"][template.to_s] or raise unknown("template", template, DATA["templates"].keys)
404
+ out = JSON.parse(JSON.generate(found["definition"]))
405
+ return out if use.nil?
406
+
407
+ preset = DATA["uses"][use.to_s] or raise unknown("use", use, DATA["uses"].keys)
408
+ out.merge(JSON.parse(JSON.generate(preset["definition"])))
409
+ end
410
+
411
+ # Every template and use as plain JSON data: { "templates" => ..., "uses" => ... }.
412
+ def to_h = JSON.parse(JSON.generate(DATA))
413
+
414
+ def inspect = "#<#{self.class.name} #{keys.join(", ")}; uses #{DATA["uses"].keys.join(", ")}>"
415
+
416
+ private
417
+
418
+ def unknown(what, value, known)
419
+ InvalidRequestError.new("No agent #{what} is called #{value.inspect}.", code: "invalid_request", status: 400,
420
+ hint: "One of: #{known.join(", ")}.")
421
+ end
422
+ end
423
+ end
@@ -23,8 +23,8 @@ module WithRuntime
23
23
  RENAME_RETIRES = nil
24
24
 
25
25
  class Client
26
- attr_reader :transport, :sandboxes, :snapshots, :images, :volumes, :secrets, :limits, :referrals,
27
- :switching, :events, :webhooks, :otel, :audit, :feedback, :support, :domains, :ports, :ips, :networks, :sso, :mcp
26
+ attr_reader :transport, :sandboxes, :snapshots, :images, :volumes, :secrets, :limits, :emails, :referrals,
27
+ :switching, :events, :webhooks, :otel, :audit, :feedback, :support, :domains, :ports, :ips, :networks, :sso, :mcp, :agents
28
28
 
29
29
  # +api_key+: default RUNTIME_API_KEY, then this machine's saved connection.
30
30
  # +base_url+: default RUNTIME_API_URL, then https://api.withruntime.com.
@@ -56,6 +56,7 @@ module WithRuntime
56
56
  @volumes = Volumes.new(@transport)
57
57
  @secrets = Secrets.new(@transport)
58
58
  @limits = Limits.new(@transport)
59
+ @emails = Emails.new(@transport)
59
60
  @referrals = Referrals.new(@transport)
60
61
  @switching = Switching.new(@transport)
61
62
  @events = Events.new(@transport)
@@ -70,6 +71,7 @@ module WithRuntime
70
71
  @networks = Networks.new(PrivateTunnel.new(@transport))
71
72
  @sso = SSO.new(@transport)
72
73
  @mcp = MCP.new(@transport)
74
+ @agents = Agents.new(@transport)
73
75
  end
74
76
 
75
77
  # Renamed on 6 October 2026, one plural word per product: each old name is the product it became, with a warning,
@@ -88,12 +88,22 @@ module WithRuntime
88
88
  class Secrets < Product
89
89
  # Stores or replaces a secret for +hosts+ ("api.openai.com", "*.github.com").
90
90
  # With +header:+, that header is set on every request to them, +format:+'s
91
- # {value} replaced.
92
- def set(name, value:, hosts:, header: nil, format: nil)
93
- body = { "value" => value, "hosts" => hosts, "header" => header, "format" => format }.compact
91
+ # {value} replaced. With +only:+ (<tt>{ labels: [{ role: "hub" }], sandboxes: [id] }</tt>),
92
+ # only those sandboxes get it instead of every sandbox of the organization: those whose
93
+ # labels hold every pair of any one selector, and those named by id. Left out when replacing
94
+ # a secret, the scope it has stays, so a new value never widens who gets it; +{}+ is every
95
+ # sandbox again.
96
+ def set(name, value:, hosts:, header: nil, format: nil, only: nil)
97
+ body = { "value" => value, "hosts" => hosts, "header" => header, "format" => format, "only" => only }.compact
94
98
  Record.new(@t.json("PUT", "/v1/egress-secrets/#{seg(name)}", body: body))
95
99
  end
96
100
 
101
+ # Changes which sandboxes get a secret, without its value: +{}+ is every sandbox of the
102
+ # organization again. Returns the secret, with +only+ and +enforced+.
103
+ def scope(name, only)
104
+ Record.new(@t.json("PUT", "/v1/egress-secrets/#{seg(name)}/sandboxes", body: only))
105
+ end
106
+
97
107
  # Names, hosts and placeholders. Never values.
98
108
  def list = @t.json("GET", "/v1/egress-secrets")["secrets"].map { |secret| Record.new(secret) }
99
109
 
@@ -115,6 +125,21 @@ module WithRuntime
115
125
  def get = Record.new(@t.json("GET", "/v1/referrals"))
116
126
  end
117
127
 
128
+ # +runtime.emails+: which of Runtime's optional emails this key's person gets
129
+ # for this account: own-deletions (warnings for dates the account's own
130
+ # retention set), billing, usage and tips. Sign-in, support replies, credit
131
+ # expiry and deletions Runtime decides are always sent and are not listed.
132
+ class Emails < Product
133
+ def get = @t.json("GET", "/v1/account/emails")["settings"].map { |row| Record.new(row) }
134
+
135
+ # Turns one on or off for this key's person only, and answers all of them.
136
+ # Needs a key for every action.
137
+ def set(category, enabled)
138
+ body = { "category" => category, "enabled" => enabled }
139
+ @t.json("POST", "/v1/account/emails:update", body: body)["settings"].map { |row| Record.new(row) }
140
+ end
141
+ end
142
+
118
143
  # +runtime.switching+: compare usage with a rival's published rates, and record a switch.
119
144
  class Switching < Product
120
145
  # Your settled usage over the last +days+ (30 by default, at most 90) priced
@@ -241,19 +241,20 @@ module WithRuntime
241
241
  # Runs a command and returns its exit code and output. A String runs under
242
242
  # +bash -c+; an Array runs the program directly, with no shell.
243
243
  #
244
- # +cwd+ (default /workspace), +env+ (merged over the sandbox's; put secrets
245
- # here), +stdin+, +timeout+ (seconds, default 60, up to a day; a timeout is
246
- # a result, not an error), +on_stdout+ and +on_stderr+ (stream the command;
247
- # the result keeps everything), and +check+ (raise CommandError on a
248
- # non-zero exit or a timeout).
249
- def exec(command, cwd: nil, env: nil, stdin: nil, timeout: nil, on_stdout: nil, on_stderr: nil,
244
+ # +cwd+ (default /workspace), +user+ ("sandbox", the default, or "root",
245
+ # through the sandbox's passwordless sudo), +env+ (merged over the
246
+ # sandbox's; put secrets here), +stdin+, +timeout+ (seconds, default 60, up
247
+ # to a day; a timeout is a result, not an error), +on_stdout+ and
248
+ # +on_stderr+ (stream the command; the result keeps everything), and
249
+ # +check+ (raise CommandError on a non-zero exit or a timeout).
250
+ def exec(command, cwd: nil, user: nil, env: nil, stdin: nil, timeout: nil, on_stdout: nil, on_stderr: nil,
250
251
  check: false, idempotency_key: nil)
251
252
  streamed = on_stdout || on_stderr || (timeout && timeout > 60)
252
253
  result = if streamed
253
- collect(exec_stream(command, cwd: cwd, env: env, stdin: stdin, timeout: timeout,
254
+ collect(exec_stream(command, cwd: cwd, user: user, env: env, stdin: stdin, timeout: timeout,
254
255
  idempotency_key: idempotency_key), on_stdout, on_stderr)
255
256
  else
256
- body = command_body(command, cwd, env, stdin, timeout)
257
+ body = command_body(command, cwd, env, stdin, timeout, user)
257
258
  CommandResult.new(@t.json("POST", path(":exec"), body: body, key: idempotency_key,
258
259
  timeout: timeout && (timeout + 60)))
259
260
  end
@@ -265,14 +266,14 @@ module WithRuntime
265
266
  # Runs a command and yields its events as they happen: start, stdout,
266
267
  # stderr, exit (each a Hash). Resumes by itself when the server ends a long
267
268
  # stream, so it never drops output. Without a block, an Enumerator.
268
- def exec_stream(command, cwd: nil, env: nil, stdin: nil, timeout: nil, idempotency_key: nil, &block)
269
+ def exec_stream(command, cwd: nil, user: nil, env: nil, stdin: nil, timeout: nil, idempotency_key: nil, &block)
269
270
  unless block
270
- return enum_for(:exec_stream, command, cwd: cwd, env: env, stdin: stdin, timeout: timeout,
271
+ return enum_for(:exec_stream, command, cwd: cwd, user: user, env: env, stdin: stdin, timeout: timeout,
271
272
  idempotency_key: idempotency_key)
272
273
  end
273
274
 
274
275
  longest = ApiDefaults::STREAMED_EXEC_TIMEOUT_SECONDS
275
- body = command_body(command, cwd, env, stdin, timeout || longest).merge("stream" => true)
276
+ body = command_body(command, cwd, env, stdin, timeout || longest, user).merge("stream" => true)
276
277
  handed_over = nil
277
278
  process_id = nil
278
279
  cursor = 0
@@ -361,8 +362,8 @@ module WithRuntime
361
362
  # once. It outlives your connection; +process(id)+ gets it back.
362
363
  # +pipe_stdin: true+ keeps input open for +write+; +pty: { cols:, rows: }+
363
364
  # gives it a terminal.
364
- def spawn(command, cwd: nil, env: nil, timeout: nil, stdin: nil, pipe_stdin: false, pty: nil, idempotency_key: nil)
365
- body = command_body(command, cwd, env, pipe_stdin ? nil : stdin, timeout)
365
+ def spawn(command, cwd: nil, user: nil, env: nil, timeout: nil, stdin: nil, pipe_stdin: false, pty: nil, idempotency_key: nil)
366
+ body = command_body(command, cwd, env, pipe_stdin ? nil : stdin, timeout, user)
366
367
  body["stdinMode"] = "pipe" if pipe_stdin
367
368
  body["pty"] = Fields.body(pty) if pty
368
369
  SandboxProcess.new(self, @t.json("POST", path("/processes"), body: body, key: idempotency_key))
@@ -385,9 +386,10 @@ module WithRuntime
385
386
 
386
387
  def room_for(wait_for_capacity) = wait_for_capacity.nil? ? @t.wait_for_capacity : [0, wait_for_capacity].max
387
388
 
388
- def command_body(command, cwd, env, stdin, timeout)
389
+ def command_body(command, cwd, env, stdin, timeout, user)
389
390
  body = command.is_a?(Array) ? { "argv" => command.map(&:to_s) } : { "command" => command.to_s }
390
391
  body["cwd"] = cwd if cwd
392
+ body["user"] = user.to_s if user
391
393
  body["env"] = env.to_h { |key, value| [key.to_s, value.to_s] } if env
392
394
  body["stdinBase64"] = [stdin.to_s.b].pack("m0") unless stdin.nil?
393
395
  body["timeoutMs"] = (timeout * 1000).round if timeout
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WithRuntime
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/withruntime.rb CHANGED
@@ -26,6 +26,7 @@ require_relative "withruntime/wallet"
26
26
  require_relative "withruntime/tar"
27
27
  require_relative "withruntime/sandbox"
28
28
  require_relative "withruntime/products"
29
+ require_relative "withruntime/agents"
29
30
  require_relative "withruntime/images"
30
31
  require_relative "withruntime/network_products"
31
32
  require_relative "withruntime/parity_products"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: withruntime
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Runtime
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-17 00:00:00.000000000 Z
11
+ date: 2026-10-19 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: 'Linux sandboxes for agents, and every other Runtime Cloud product: streaming
14
14
  commands and terminals, files and watches, images, durable volumes and backups,
@@ -20,6 +20,7 @@ extra_rdoc_files: []
20
20
  files:
21
21
  - LICENSE
22
22
  - lib/withruntime.rb
23
+ - lib/withruntime/agents.rb
23
24
  - lib/withruntime/api_defaults.rb
24
25
  - lib/withruntime/client.rb
25
26
  - lib/withruntime/credentials.rb