bsdkrun 0.1.0 → 0.3.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,564 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "base64"
7
+
8
+ require_relative "errors"
9
+ require_relative "types"
10
+ require_relative "ws_client"
11
+ require_relative "shell_session"
12
+
13
+ module Bsdkrun
14
+ # A client that talks to a remote +bsdkrund+ daemon's GraphQL API directly
15
+ # — HTTP for queries/mutations, a hand-rolled +graphql-transport-ws+ socket
16
+ # for subscriptions — instead of shelling out to a local +bsdkrun+ binary
17
+ # the way {Bsdkrun::Sandbox} does.
18
+ #
19
+ # The wire contract (URL/header shape, error mapping, subscription
20
+ # protocol, field names) is locked to match the other bsdkrun SDKs
21
+ # (TypeScript/Python/Elixir/Gleam) and the web frontend's
22
+ # +web/src/lib/graphql.ts+ byte-for-byte — see that file for the reference
23
+ # implementation this one mirrors.
24
+ #
25
+ # @example
26
+ # client = Bsdkrun::Client.from_env
27
+ # client.list.each { |m| puts m.id }
28
+ # result = client.exec(id, ["uname", "-a"])
29
+ # puts result.output
30
+ class Client
31
+ URL_ENV = "BSDKRUN_URL"
32
+ TOKEN_ENV = "BSDKRUN_TOKEN"
33
+
34
+ # The +Machine+ field selection shared by +machines+ / +machine+ — mirrors
35
+ # +web/src/lib/api.ts+'s +MACHINE_FIELDS+ fragment exactly.
36
+ MACHINE_FIELDS = <<~GQL.freeze
37
+ id name image kind command status running exitCode pid detached
38
+ cpus mem volume stateDir createdAt finishedAt network netIp
39
+ ports { bind host guest }
40
+ GQL
41
+
42
+ # @return [String] the GraphQL endpoint URL (normalized).
43
+ attr_reader :url
44
+
45
+ # @param url [String] the daemon's URL, e.g. "http://host:50052" or a
46
+ # full "http://host:50052/graphql" — normalized either way.
47
+ # @param token [String] the daemon's bearer token.
48
+ def initialize(url:, token:)
49
+ @url = self.class.normalize_url(url)
50
+ @token = token.to_s
51
+ @ws_mutex = Mutex.new
52
+ @ws = nil
53
+ end
54
+
55
+ # Build a client from +BSDKRUN_URL+ / +BSDKRUN_TOKEN+.
56
+ #
57
+ # A host set without a token is an error, not a silent fallback —
58
+ # mirrors +daemon/src/client.rs+'s +RemoteConfig::from_env+ (which uses
59
+ # +BSDKRUN_HOST+/+BSDKRUN_TOKEN+ for the gRPC client; these are
60
+ # GraphQL-specific env vars with a different URL shape, not aliases).
61
+ #
62
+ # @return [Client]
63
+ # @raise [Error] if +BSDKRUN_URL+ is unset, or set without +BSDKRUN_TOKEN+.
64
+ def self.from_env
65
+ url = ENV[URL_ENV]
66
+ raise Error, "#{URL_ENV} is not set; nothing to connect to" if url.nil? || url.strip.empty?
67
+
68
+ token = ENV[TOKEN_ENV]
69
+ if token.nil? || token.strip.empty?
70
+ raise Error, "#{URL_ENV} is set but #{TOKEN_ENV} is not"
71
+ end
72
+
73
+ new(url: url, token: token)
74
+ end
75
+
76
+ # Normalize a user-supplied URL into a full GraphQL endpoint: trim, add
77
+ # +http://+ if no scheme was given, strip trailing slashes, append
78
+ # +/graphql+ unless the path already ends with it. Mirrors
79
+ # +web/src/lib/connection.ts+'s +normalizeUrl+ exactly.
80
+ #
81
+ # @param input [String]
82
+ # @return [String]
83
+ def self.normalize_url(input)
84
+ s = input.to_s.strip
85
+ return s if s.empty?
86
+
87
+ s = "http://#{s}" unless s.match?(%r{\Ahttps?://}i)
88
+ s = s.sub(%r{/+\z}, "")
89
+ s = "#{s}/graphql" unless s.match?(%r{/graphql\z}i)
90
+ s
91
+ end
92
+
93
+ # Derive the websocket endpoint from the HTTP one: +http://+ -> +ws://+,
94
+ # +https://+ -> +wss://+, trailing slashes on the path stripped, +/ws+
95
+ # appended. Mirrors +web/src/lib/graphql.ts+'s +wsUrl+.
96
+ #
97
+ # @param http_url [String]
98
+ # @return [String]
99
+ def self.ws_url(http_url)
100
+ uri = URI.parse(http_url)
101
+ uri.scheme = uri.scheme == "https" ? "wss" : "ws"
102
+ uri.path = "#{uri.path.to_s.sub(%r{/+\z}, "")}/ws"
103
+ uri.to_s
104
+ end
105
+
106
+ # ---- HTTP transport: the escape hatch, and everything below is built on it ----
107
+
108
+ # Run an arbitrary query or mutation. Every typed method on this class is
109
+ # implemented in terms of this — it exists as a public escape hatch for
110
+ # documents this SDK has no typed wrapper for yet.
111
+ #
112
+ # @param query [String] a GraphQL document.
113
+ # @param variables [Hash]
114
+ # @return [Hash] +body["data"]+ (String-keyed, as parsed by +JSON.parse+).
115
+ # @raise [AuthError] on HTTP 401, or a GraphQL error with
116
+ # +extensions.code == "UNAUTHENTICATED"+.
117
+ # @raise [GraphQLError] on transport failure, a non-JSON response, or any
118
+ # other GraphQL error.
119
+ def request(query, variables = {})
120
+ uri = URI.parse(@url)
121
+ http = Net::HTTP.new(uri.host, uri.port)
122
+ http.use_ssl = uri.scheme == "https"
123
+
124
+ req = Net::HTTP::Post.new(uri.request_uri.empty? ? "/" : uri.request_uri)
125
+ req["content-type"] = "application/json"
126
+ req["authorization"] = "Bearer #{@token}"
127
+ req.body = JSON.generate({ query: query, variables: variables })
128
+
129
+ begin
130
+ res = http.request(req)
131
+ rescue StandardError => e
132
+ raise GraphQLError, "cannot reach the bsdkrun daemon at #{@url} — #{e.message}"
133
+ end
134
+
135
+ raise AuthError if res.code.to_i == 401
136
+
137
+ body = begin
138
+ JSON.parse(res.body.to_s)
139
+ rescue JSON::ParserError
140
+ nil
141
+ end
142
+ raise GraphQLError, "the daemon returned a non-JSON response (#{res.code})" if body.nil?
143
+
144
+ errors = body["errors"]
145
+ if errors.is_a?(Array) && !errors.empty?
146
+ first = errors.first
147
+ message = first["message"].to_s
148
+ code = first.is_a?(Hash) ? first.dig("extensions", "code") : nil
149
+ raise AuthError, message if code == "UNAUTHENTICATED"
150
+ raise GraphQLError.new(message, code)
151
+ end
152
+
153
+ body["data"]
154
+ end
155
+
156
+ # ---- subscriptions ----------------------------------------------------
157
+
158
+ # Start a subscription over the shared websocket (opened lazily on first
159
+ # use). See {WsClient#subscribe} for the exact queueing/ack semantics.
160
+ #
161
+ # @param query [String]
162
+ # @param variables [Hash]
163
+ # @param on_next [#call]
164
+ # @param on_error [#call, nil]
165
+ # @param on_complete [#call, nil]
166
+ # @return [Proc] call to unsubscribe.
167
+ def subscribe(query, variables = {}, on_next:, on_error: nil, on_complete: nil)
168
+ ws.subscribe(query, variables, on_next: on_next, on_error: on_error, on_complete: on_complete)
169
+ end
170
+
171
+ # ---- lifecycle / listing -----------------------------------------------
172
+
173
+ # @param all [Boolean] include stopped machines too.
174
+ # @return [Array<SandboxInfo>]
175
+ def list(all: false)
176
+ data = request("query($all:Boolean!){ machines(all:$all){ #{MACHINE_FIELDS} } }", { all: all })
177
+ (data["machines"] || []).map { |m| SandboxInfo.from_graphql(m) }
178
+ end
179
+
180
+ # @param id [String] id, name, or unique id prefix.
181
+ # @return [SandboxInfo, nil] nil if no such machine exists.
182
+ def get(id)
183
+ data = request("query($id:String!){ machine(id:$id){ #{MACHINE_FIELDS} } }", { id: id })
184
+ m = data["machine"]
185
+ m && SandboxInfo.from_graphql(m)
186
+ end
187
+
188
+ # @param id [String]
189
+ # @return [CommandResult]
190
+ def stop(id)
191
+ run_command_mutation(
192
+ "stopMachine",
193
+ "mutation($id:String!){ stopMachine(id:$id){ exitCode stdout stderr } }",
194
+ { id: id }
195
+ )
196
+ end
197
+
198
+ # @param id [String]
199
+ # @return [CommandResult]
200
+ def start(id)
201
+ run_command_mutation(
202
+ "startMachine",
203
+ "mutation($id:String!){ startMachine(id:$id){ exitCode stdout stderr } }",
204
+ { id: id }
205
+ )
206
+ end
207
+
208
+ # @param ids [String, Array<String>]
209
+ # @param force [Boolean]
210
+ # @return [CommandResult]
211
+ def remove(ids, force: false)
212
+ run_command_mutation(
213
+ "removeMachines",
214
+ "mutation($ids:[String!]!,$force:Boolean!){ removeMachines(ids:$ids, force:$force){ exitCode stdout stderr } }",
215
+ { ids: Array(ids), force: force }
216
+ )
217
+ end
218
+
219
+ # @param id [String]
220
+ # @param cpus [Integer, nil]
221
+ # @param mem [Integer, nil]
222
+ # @return [CommandResult]
223
+ def update(id, cpus: nil, mem: nil)
224
+ run_command_mutation(
225
+ "updateMachine",
226
+ "mutation($id:String!,$cpus:Int,$mem:Int){ updateMachine(id:$id, cpus:$cpus, mem:$mem){ exitCode stdout stderr } }",
227
+ { id: id, cpus: cpus, mem: mem }
228
+ )
229
+ end
230
+
231
+ # Snapshot a machine into a named flavor, like +docker commit+.
232
+ # @param id [String]
233
+ # @param name [String]
234
+ # @param description [String]
235
+ # @return [CommandResult]
236
+ def commit(id, name, description: "")
237
+ run_command_mutation(
238
+ "commitMachine",
239
+ "mutation($id:String!,$name:String!,$description:String!){ " \
240
+ "commitMachine(id:$id, name:$name, description:$description){ exitCode stdout stderr } }",
241
+ { id: id, name: name, description: description }
242
+ )
243
+ end
244
+
245
+ # One-shot console log fetch. Use {#follow_logs} to stream instead.
246
+ # @param id [String]
247
+ # @param boot [Boolean] bsdkrun's own boot log instead of the guest console.
248
+ # @return [String]
249
+ def logs(id, boot: false)
250
+ data = request("query($id:String!,$boot:Boolean!){ machineLogs(id:$id, boot:$boot) }",
251
+ { id: id, boot: boot })
252
+ data["machineLogs"]
253
+ end
254
+
255
+ # Stream a machine's console log live.
256
+ # @param id [String]
257
+ # @param follow [Boolean]
258
+ # @param boot [Boolean]
259
+ # @yieldparam data [String] binary-safe decoded chunk.
260
+ # @return [Proc] call to stop following.
261
+ def follow_logs(id, follow: true, boot: false, &on_data)
262
+ subscribe(
263
+ "subscription($id:String!,$follow:Boolean!,$boot:Boolean!){ " \
264
+ "machineLogs(id:$id, follow:$follow, boot:$boot){ dataBase64 exitCode } }",
265
+ { id: id, follow: follow, boot: boot },
266
+ on_next: lambda { |data|
267
+ payload = data && data["machineLogs"]
268
+ next unless payload && payload["dataBase64"]
269
+
270
+ on_data&.call(Base64.decode64(payload["dataBase64"]))
271
+ }
272
+ )
273
+ end
274
+
275
+ # ---- booting ------------------------------------------------------------
276
+ #
277
+ # Seven variants, one per daemon mutation — see +daemon/src/graphql.rs+ for
278
+ # the exact input field names this transcribes (RunLinuxInput ~L384,
279
+ # RunBsdInput ~L406, RunNanosInput ~L429, RunUnikraftInput ~L445,
280
+ # RunSolo5Input ~L465, RunOsvInput ~L498, RunFlavorInput ~L541,
281
+ # NetInput ~L360). Each accepts
282
+ # an options Hash and/or keyword arguments, merged — the same convention
283
+ # {Sandbox.create} uses — with snake_case Ruby keys mapped 1:1 to the
284
+ # camelCase GraphQL fields on the wire.
285
+
286
+ # @return [String] the new machine's id.
287
+ def run_linux(opts = {}, **kwargs)
288
+ o = merge_opts(opts, kwargs)
289
+ input = {
290
+ image: o.fetch(:image),
291
+ cpus: o[:cpus],
292
+ mem: o[:mem],
293
+ net: net_input(o[:net]),
294
+ volume: o[:volume],
295
+ mounts: o[:mounts] || [],
296
+ env: o[:env] || [],
297
+ entrypoint: o[:entrypoint],
298
+ initramfs: o[:initramfs] || false,
299
+ kernel: o[:kernel],
300
+ kernelVersion: o[:kernel_version],
301
+ console: o[:console],
302
+ repo: o[:repo],
303
+ command: o[:command] || []
304
+ }
305
+ request("mutation($i:RunLinuxInput!){ runLinux(input:$i) }", { i: input })["runLinux"]
306
+ end
307
+
308
+ # @return [String] the new machine's id.
309
+ def run_bsd(opts = {}, **kwargs)
310
+ o = merge_opts(opts, kwargs)
311
+ input = {
312
+ os: bsd_os_enum(o.fetch(:os)),
313
+ version: o[:version],
314
+ cpus: o[:cpus],
315
+ mem: o[:mem],
316
+ net: net_input(o[:net]),
317
+ volume: o[:volume],
318
+ persist: o[:persist] || false,
319
+ force: o[:force] || false,
320
+ firmware: o[:firmware],
321
+ attachDisk: o[:attach_disk] || [],
322
+ diskSize: o[:disk_size],
323
+ repo: o[:repo],
324
+ command: o[:command] || []
325
+ }
326
+ request("mutation($i:RunBsdInput!){ runBsd(input:$i) }", { i: input })["runBsd"]
327
+ end
328
+
329
+ # No agent (no exec/shell), but it does have a root disk, so +persist:+
330
+ # is the one disk option it takes.
331
+ # @return [String] the new machine's id.
332
+ def run_nanos(opts = {}, **kwargs)
333
+ o = merge_opts(opts, kwargs)
334
+ input = {
335
+ image: o.fetch(:image),
336
+ cpus: o[:cpus],
337
+ mem: o[:mem],
338
+ net: net_input(o[:net]),
339
+ kernel: o[:kernel],
340
+ cmdline: o[:cmdline],
341
+ persist: o[:persist] || false
342
+ }
343
+ request("mutation($i:RunNanosInput!){ runNanos(input:$i) }", { i: input })["runNanos"]
344
+ end
345
+
346
+ # A unikernel has no disk and no agent, so no volume/persist/repo/command
347
+ # fields — +mounts:+ (virtio-fs shares) is the exception, needing neither.
348
+ # @return [String] the new machine's id.
349
+ def run_unikraft(opts = {}, **kwargs)
350
+ o = merge_opts(opts, kwargs)
351
+ input = {
352
+ path: o[:path],
353
+ cpus: o[:cpus],
354
+ mem: o[:mem],
355
+ net: net_input(o[:net]),
356
+ cmdline: o[:cmdline],
357
+ initramfs: o[:initramfs],
358
+ mounts: o[:mounts] || []
359
+ }
360
+ request("mutation($i:RunUnikraftInput!){ runUnikraft(input:$i) }", { i: input })["runUnikraft"]
361
+ end
362
+
363
+ # Solo5 (MirageOS): runs under the +solo5-hvt+ tender rather than libkrun.
364
+ # The unikernel declares its own network and block devices in its +MFT1+
365
+ # manifest note, so only what the host alone can know is asked for —
366
+ # +block:+ backing files (+NAME=FILE+) and the unikernel's own +args:+
367
+ # (e.g. "--ipv4=10.0.0.2/24"). Like Unikraft, no disk and no agent, so no
368
+ # volume/persist/repo/command fields.
369
+ # @return [String] the new machine's id.
370
+ def run_solo5(opts = {}, **kwargs)
371
+ o = merge_opts(opts, kwargs)
372
+ input = {
373
+ path: o[:path],
374
+ cpus: o[:cpus],
375
+ mem: o[:mem],
376
+ net: net_input(o[:net]),
377
+ block: o[:block] || [],
378
+ args: o[:args] || []
379
+ }
380
+ request("mutation($i:RunSolo5Input!){ runSolo5(input:$i) }", { i: input })["runSolo5"]
381
+ end
382
+
383
+ # Like Nanos, no agent, but it does have a root filesystem, so the disk
384
+ # options apply — +disk:+ in particular, how an x86_64 guest gets a
385
+ # filesystem (its loader ELF is kernel only).
386
+ # @return [String] the new machine's id.
387
+ def run_osv(opts = {}, **kwargs)
388
+ o = merge_opts(opts, kwargs)
389
+ input = {
390
+ image: o.fetch(:image),
391
+ cpus: o[:cpus],
392
+ mem: o[:mem],
393
+ net: net_input(o[:net]),
394
+ cmdline: o[:cmdline],
395
+ disk: o[:disk],
396
+ noDisk: o[:no_disk] || false,
397
+ attachDisk: o[:attach_disk] || [],
398
+ gic: o[:gic],
399
+ persist: o[:persist] || false,
400
+ volume: o[:volume]
401
+ }
402
+ request("mutation($i:RunOsvInput!){ runOsv(input:$i) }", { i: input })["runOsv"]
403
+ end
404
+
405
+ # @return [String] the new machine's id.
406
+ def run_flavor(opts = {}, **kwargs)
407
+ o = merge_opts(opts, kwargs)
408
+ input = {
409
+ name: o.fetch(:name),
410
+ cpus: o[:cpus],
411
+ mem: o[:mem],
412
+ ports: o[:ports] || [],
413
+ volume: o[:volume],
414
+ repo: o[:repo]
415
+ }
416
+ request("mutation($i:RunFlavorInput!){ runFlavor(input:$i) }", { i: input })["runFlavor"]
417
+ end
418
+
419
+ # ---- exec / interactive shell ------------------------------------------
420
+
421
+ # Run a command to completion and collect its output. Implemented as the
422
+ # three-operation sequence +daemon/README.md+ documents: +openShell+
423
+ # (with a +command:+, so the session runs it instead of a login shell),
424
+ # THEN subscribe to +shellOutput+ (so nothing written in between is
425
+ # lost), THEN wait for an exit code. +closeShell+ runs in an +ensure+ so
426
+ # it happens whether the wait succeeded, failed, or raised.
427
+ #
428
+ # @param id [String] machine id.
429
+ # @param command [Array<String>] argv.
430
+ # @param env [Hash, Array<String>, nil] +"K=V"+ pairs, or a Hash of them.
431
+ # @return [ExecResult]
432
+ def exec(id, command, env: nil)
433
+ data = request(
434
+ "mutation($m:String!,$c:[String!]!,$e:[String!]!,$r:Int!,$k:Int!){ " \
435
+ "openShell(machineId:$m, command:$c, env:$e, rows:$r, cols:$k){ id } }",
436
+ { m: id, c: Array(command), e: env_to_list(env), r: 24, k: 80 }
437
+ )
438
+ session_id = data["openShell"]["id"]
439
+
440
+ output = +"".b
441
+ exit_code = nil
442
+ done = Queue.new
443
+
444
+ unsubscribe = subscribe(
445
+ "subscription($s:String!){ shellOutput(sessionId:$s){ dataBase64 exitCode } }",
446
+ { s: session_id },
447
+ on_next: lambda { |d|
448
+ payload = d && d["shellOutput"]
449
+ next unless payload
450
+
451
+ output << Base64.decode64(payload["dataBase64"]) if payload["dataBase64"]
452
+ unless payload["exitCode"].nil?
453
+ exit_code = payload["exitCode"]
454
+ done << :done
455
+ end
456
+ },
457
+ on_error: ->(e) { done << e },
458
+ on_complete: -> { done << :done }
459
+ )
460
+
461
+ begin
462
+ result = done.pop
463
+ raise result if result.is_a?(Exception)
464
+ ensure
465
+ unsubscribe.call
466
+ begin
467
+ request("mutation($s:String!){ closeShell(sessionId:$s) }", { s: session_id })
468
+ rescue GraphQLError
469
+ # closeShell is idempotent server-side; a request failure here
470
+ # (already gone, machine removed, etc.) must not mask the actual
471
+ # exec result/exception above — same as the other SDKs' Client#exec.
472
+ nil
473
+ end
474
+ end
475
+
476
+ ExecResult.new(exit_code: exit_code, output: output)
477
+ end
478
+
479
+ # Open a live interactive session. Unlike {#exec}, this returns
480
+ # immediately with a handle whose {ShellSession#on_output} /
481
+ # {ShellSession#on_exit} callbacks fire as output arrives.
482
+ #
483
+ # @param id [String] machine id.
484
+ # @param command [Array<String>, nil] nil opens a login shell.
485
+ # @param env [Hash, Array<String>, nil]
486
+ # @param rows [Integer]
487
+ # @param cols [Integer]
488
+ # @return [ShellSession]
489
+ def shell(id, command: nil, env: nil, rows: 24, cols: 80)
490
+ data = request(
491
+ "mutation($m:String!,$c:[String!]!,$e:[String!]!,$r:Int!,$k:Int!){ " \
492
+ "openShell(machineId:$m, command:$c, env:$e, rows:$r, cols:$k){ id } }",
493
+ { m: id, c: command.nil? ? [] : Array(command), e: env_to_list(env), r: rows, k: cols }
494
+ )
495
+ session_id = data["openShell"]["id"]
496
+ session = ShellSession.new(client: self, id: session_id)
497
+
498
+ unsubscribe = subscribe(
499
+ "subscription($s:String!){ shellOutput(sessionId:$s){ dataBase64 exitCode } }",
500
+ { s: session_id },
501
+ on_next: lambda { |d|
502
+ payload = d && d["shellOutput"]
503
+ next unless payload
504
+
505
+ session.deliver_output(Base64.decode64(payload["dataBase64"])) if payload["dataBase64"]
506
+ session.deliver_exit(payload["exitCode"]) unless payload["exitCode"].nil?
507
+ },
508
+ on_error: ->(_e) { session.deliver_exit(nil) },
509
+ on_complete: -> {}
510
+ )
511
+ session.unsubscribe = unsubscribe
512
+ session
513
+ end
514
+
515
+ private
516
+
517
+ def ws
518
+ @ws_mutex.synchronize { @ws ||= WsClient.new(ws_url: self.class.ws_url(@url), token: @token) }
519
+ end
520
+
521
+ def run_command_mutation(field, query, variables)
522
+ r = request(query, variables)[field]
523
+ CommandResult.new(exit_code: r["exitCode"].to_i, stdout: r["stdout"].to_s, stderr: r["stderr"].to_s)
524
+ end
525
+
526
+ # Symbolize keys and merge a positional options Hash with keyword
527
+ # arguments — mirrors {Args.normalize}, which {Sandbox.create} uses.
528
+ def merge_opts(opts, kwargs)
529
+ opts.merge(kwargs).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
530
+ end
531
+
532
+ # @param net [Hash, nil] +:no_net+/+:ports+/+:mac+/+:network+/+:name+.
533
+ # @return [Hash, nil] a +NetInput+-shaped wire hash.
534
+ def net_input(net)
535
+ return nil unless net
536
+
537
+ n = net.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
538
+ {
539
+ noNet: n[:no_net] || n[:noNet] || false,
540
+ ports: n[:ports] || [],
541
+ mac: n[:mac],
542
+ network: n[:network],
543
+ name: n[:name]
544
+ }
545
+ end
546
+
547
+ def bsd_os_enum(os)
548
+ case os.to_s.downcase
549
+ when "freebsd" then "FREEBSD"
550
+ when "netbsd" then "NETBSD"
551
+ else raise ArgumentError, "unknown BSD os: #{os.inspect}"
552
+ end
553
+ end
554
+
555
+ # @param env [Hash, Array<String>, nil]
556
+ # @return [Array<String>] "K=V" pairs, as +openShell+'s +env:+ field wants.
557
+ def env_to_list(env)
558
+ return [] if env.nil?
559
+ return env if env.is_a?(Array)
560
+
561
+ env.map { |k, v| "#{k}=#{v}" }
562
+ end
563
+ end
564
+ end
@@ -49,4 +49,29 @@ module Bsdkrun
49
49
  super("no sandbox found matching id #{id.inspect}")
50
50
  end
51
51
  end
52
+
53
+ # A GraphQL request to a remote +bsdkrund+ daemon failed — a transport
54
+ # failure, a non-JSON response, or a +body["errors"]+ entry that was not an
55
+ # auth failure. Mirrors +web/src/lib/graphql.ts+'s +GraphQLError+.
56
+ class GraphQLError < Error
57
+ # @return [String, nil] the error's +extensions.code+, when the daemon set one.
58
+ attr_reader :code
59
+
60
+ # @param message [String]
61
+ # @param code [String, nil]
62
+ def initialize(message, code = nil)
63
+ super(message)
64
+ @code = code
65
+ end
66
+ end
67
+
68
+ # The daemon rejected our token — an HTTP 401, a GraphQL error whose
69
+ # +extensions.code+ is +"UNAUTHENTICATED"+, or a websocket that closed
70
+ # before +connection_ack+ ever arrived.
71
+ class AuthError < GraphQLError
72
+ # @param message [String]
73
+ def initialize(message = "the daemon rejected this token")
74
+ super(message, "UNAUTHENTICATED")
75
+ end
76
+ end
52
77
  end