pinesandbox 0.0.1 → 0.3.18

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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -0
  3. data/README.md +668 -4
  4. data/Rakefile +5 -0
  5. data/lib/pinesandbox/adapters/coordinator_adapter.rb +1374 -0
  6. data/lib/pinesandbox/adapters/snapshot_adapter.rb +37 -0
  7. data/lib/pinesandbox/agent_events.rb +213 -0
  8. data/lib/pinesandbox/agent_mode.rb +252 -0
  9. data/lib/pinesandbox/agent_turn.rb +75 -0
  10. data/lib/pinesandbox/attach_credentials_source.rb +392 -0
  11. data/lib/pinesandbox/author_terminal_translator.rb +96 -0
  12. data/lib/pinesandbox/bind_hpke.rb +54 -0
  13. data/lib/pinesandbox/capture_keypair.rb +43 -0
  14. data/lib/pinesandbox/client.rb +446 -0
  15. data/lib/pinesandbox/clock.rb +51 -0
  16. data/lib/pinesandbox/computer.rb +1075 -0
  17. data/lib/pinesandbox/computer_binding.rb +47 -0
  18. data/lib/pinesandbox/computer_credentials.rb +175 -0
  19. data/lib/pinesandbox/connection.rb +26 -0
  20. data/lib/pinesandbox/control_events.rb +156 -0
  21. data/lib/pinesandbox/control_plane.rb +341 -0
  22. data/lib/pinesandbox/control_state.rb +31 -0
  23. data/lib/pinesandbox/control_token_source.rb +232 -0
  24. data/lib/pinesandbox/delegated_connection.rb +46 -0
  25. data/lib/pinesandbox/diagnostics.rb +83 -0
  26. data/lib/pinesandbox/drive_mode.rb +67 -0
  27. data/lib/pinesandbox/error_taxonomy.rb +104 -0
  28. data/lib/pinesandbox/errors.rb +525 -0
  29. data/lib/pinesandbox/exec_stream_parser.rb +90 -0
  30. data/lib/pinesandbox/models/artifact.rb +51 -0
  31. data/lib/pinesandbox/models/computer_session.rb +57 -0
  32. data/lib/pinesandbox/models/computer_tab.rb +30 -0
  33. data/lib/pinesandbox/models/file_entry.rb +38 -0
  34. data/lib/pinesandbox/operations.rb +134 -0
  35. data/lib/pinesandbox/response_handler.rb +88 -0
  36. data/lib/pinesandbox/runtime_details.rb +25 -0
  37. data/lib/pinesandbox/runtime_handle.rb +252 -0
  38. data/lib/pinesandbox/session.rb +627 -0
  39. data/lib/pinesandbox/skills.rb +142 -0
  40. data/lib/pinesandbox/spec_version_middleware.rb +34 -0
  41. data/lib/pinesandbox/sse_frame_parser.rb +95 -0
  42. data/lib/pinesandbox/state_hpke.rb +57 -0
  43. data/lib/pinesandbox/version.rb +3 -0
  44. data/lib/pinesandbox/zone.rb +176 -0
  45. data/lib/pinesandbox.rb +40 -2
  46. data/pinesandbox.gemspec +47 -0
  47. metadata +155 -10
@@ -0,0 +1,446 @@
1
+ require "securerandom"
2
+ require_relative "zone"
3
+ require_relative "delegated_connection"
4
+ require_relative "control_token_source"
5
+ require_relative "attach_credentials_source"
6
+ require_relative "control_plane"
7
+ require_relative "connection"
8
+ require_relative "computer"
9
+ require_relative "diagnostics"
10
+
11
+ module PineSandbox
12
+ # The SDK entry point. Holds the project credential (`pk_`) + the endpoint
13
+ # ONCE, and returns ready Computers. Construction details (token mint+cache,
14
+ # attach-credential provider, control-plane client, host derivation) are
15
+ # internal — the operational API (Computer / Session) is unchanged.
16
+ #
17
+ # Attach authorization is portal-issued (AUTH_REDESIGN: portal-as-issuer) —
18
+ # the SDK exchanges the pk_ for per-attach authorization at attach time;
19
+ # Portal lazily creates only its minimal ownership row in that transaction:
20
+ #
21
+ # client = PineSandbox::Client.new(
22
+ # api_key: ENV.fetch("PINE_SANDBOX_API_KEY"), # "pk_…"
23
+ # endpoint: ENV.fetch("PINE_SANDBOX_ENDPOINT"), # URL or domain the portal shows
24
+ # )
25
+ # computer = client.create_computer # NEW Computer — persist computer.id + computer.key
26
+ # computer = client.attach_computer(id, key:) # re-attach an existing one (state restored)
27
+ #
28
+ # `attach_credentials_provider:` / `control_token_provider:` are the seams
29
+ # for in-process issuers (e.g. the portal's own playground, which IS the
30
+ # issuer and skips the self-HTTP hop).
31
+ class Client
32
+ # Wraps a caller-supplied `-> { jws }` provider as a token source for shops that
33
+ # centralize minting (or a future local-sign path). The SDK does no caching here
34
+ # — the provider owns expiry/refresh.
35
+ class CallableTokenSource
36
+ def initialize(callable)
37
+ @callable = callable
38
+ end
39
+
40
+ def token(force_refresh: false)
41
+ @callable.call
42
+ end
43
+
44
+ def inspect
45
+ "#<PineSandbox::Client::CallableTokenSource [provider redacted]>"
46
+ end
47
+ alias_method :to_s, :inspect
48
+ end
49
+
50
+ # Wraps a caller-supplied `->(computer_id:, pod_uid:, coord_boot_id:) { … }`
51
+ # provider as an attach-credential source for in-process issuers (the
52
+ # portal's own playground mints without a self-HTTP hop). The callable owns
53
+ # authorization; it receives the full v3 mint keyword set and must return
54
+ # bind_token, broker_grant, key_assertion, and binding_revision.
55
+ class CallableAttachCredentialsSource
56
+ def initialize(callable)
57
+ @callable = callable
58
+ end
59
+
60
+ def credentials(computer_id:, pod_uid:, coord_boot_id:, sandbox_id:,
61
+ expected_binding_revision:, idempotency_key:,
62
+ pk_computer: nil, key_generation: nil, location: nil)
63
+ kwargs = { computer_id: computer_id, pod_uid: pod_uid, coord_boot_id: coord_boot_id,
64
+ sandbox_id: sandbox_id, pk_computer: pk_computer,
65
+ key_generation: key_generation,
66
+ expected_binding_revision: expected_binding_revision,
67
+ idempotency_key: idempotency_key }
68
+ kwargs[:location] = location if location
69
+ result = @callable.call(**kwargs)
70
+ unless result.is_a?(Hash)
71
+ raise AttachCredentialsError.new("attach_credentials_provider must return a Hash with bind_token + broker_grant")
72
+ end
73
+
74
+ normalized = result.transform_keys(&:to_s)
75
+ # key_assertion is required only for a persistent (v3) attach — the same
76
+ # attach that submits pk_computer. An ephemeral attach omits both.
77
+ key_assertion_missing = !pk_computer.to_s.empty? && normalized["key_assertion"].to_s.empty?
78
+ if normalized["bind_token"].to_s.empty? || normalized["broker_grant"].to_s.empty? ||
79
+ key_assertion_missing || normalized["binding_revision"].to_i <= expected_binding_revision
80
+ raise AttachCredentialsError.new(
81
+ "attach_credentials_provider result missing bind_token/broker_grant/key_assertion/binding_revision"
82
+ )
83
+ end
84
+ normalized
85
+ end
86
+
87
+ def inspect
88
+ "#<PineSandbox::Client::CallableAttachCredentialsSource [provider redacted]>"
89
+ end
90
+ alias_method :to_s, :inspect
91
+ end
92
+
93
+ # `clock:` (@api private) replaces the SDK's view of time — every wait,
94
+ # deadline and jitter — for tests that run retries on a virtual clock.
95
+ def initialize(endpoint: nil, api_key: nil,
96
+ control_host: nil, spec_version: DelegatedConnection::SPEC_VERSION,
97
+ control_token_provider: nil, attach_credentials_provider: nil,
98
+ gateway_url: nil, clock: PineSandbox::SYSTEM_CLOCK)
99
+ raise ArgumentError, "endpoint required (e.g. \"https://pinesandbox.io\")" if endpoint.nil?
100
+
101
+ @zone =
102
+ if endpoint.is_a?(PineSandbox::Zone)
103
+ raise ArgumentError, "pass gateway_url to the Zone, not with one" unless gateway_url.nil?
104
+
105
+ endpoint
106
+ else
107
+ PineSandbox::Zone.new(endpoint, control_host: control_host, gateway_url: gateway_url)
108
+ end
109
+ @spec_version = spec_version
110
+ @diagnostics = Diagnostics.new(api_key: api_key, zone: @zone) unless api_key.to_s.empty?
111
+
112
+ token_source =
113
+ if control_token_provider
114
+ CallableTokenSource.new(control_token_provider)
115
+ else
116
+ raise ArgumentError, "api_key required (a pk_ client key) unless control_token_provider is given" if api_key.to_s.empty?
117
+
118
+ ControlTokenSource.new(api_key: api_key, host: @zone.control_host, protocol: @zone.http_scheme,
119
+ gateway_url: @zone.gateway_url, clock: -> { clock.now },
120
+ sleeper: ->(seconds) { clock.sleep(seconds) }, random: -> { clock.rand })
121
+ end
122
+
123
+ # Attach authorization. Default: the pk_-backed portal attach-credentials
124
+ # provider; `attach_credentials_provider:` is the in-process issuer seam.
125
+ attach_provider =
126
+ if attach_credentials_provider
127
+ if attach_credentials_provider.respond_to?(:credentials)
128
+ attach_credentials_provider
129
+ else
130
+ CallableAttachCredentialsSource.new(attach_credentials_provider)
131
+ end
132
+ elsif !api_key.to_s.empty?
133
+ AttachCredentialsSource.new(api_key: api_key, host: @zone.control_host, protocol: @zone.http_scheme,
134
+ gateway_url: @zone.gateway_url)
135
+ else
136
+ raise ArgumentError, "attach credentials unavailable: pass api_key (a pk_ client key) or attach_credentials_provider:"
137
+ end
138
+
139
+ @control_plane = ControlPlane.new(control_host: @zone.control_host, token_source: token_source,
140
+ protocol: @zone.http_scheme, spec_version: @spec_version,
141
+ gateway_url: @zone.gateway_url, clock: clock)
142
+ @connection = Connection.new(attach_provider: attach_provider, zone: @zone, control_plane: @control_plane,
143
+ clock: clock)
144
+ end
145
+
146
+ # @api private — retained for compatibility; applications use Computer operations.
147
+ attr_reader :control_plane
148
+ attr_reader :zone
149
+
150
+ # Queue a read-only check. The returned Hash contains a support reference;
151
+ # it does not open a support ticket or repeat customer work. Reuse an explicit
152
+ # idempotency_key after an uncertain response to retrieve the same request.
153
+ def create_diagnostic(computer_id:, session: nil, request_id: nil, description: nil, idempotency_key: SecureRandom.uuid)
154
+ raise ArgumentError, "diagnostics requires a project API key" unless @diagnostics
155
+
156
+ @diagnostics.create(computer_id: computer_id, session: session, request_id: request_id,
157
+ description: description, idempotency_key: idempotency_key)
158
+ end
159
+
160
+ # Read the saved result without scheduling work. The Hash uses string keys.
161
+ def get_diagnostic(id)
162
+ raise ArgumentError, "diagnostics requires a project API key" unless @diagnostics
163
+
164
+ @diagnostics.get(id)
165
+ end
166
+
167
+ # Both successful diagnostic responses and PortalError expose Retry-After.
168
+ def diagnostic_response_metadata? = true
169
+
170
+ # Discover project-visible country intents before creating a Computer.
171
+ def available_locations
172
+ provider = @connection.attach_provider
173
+ unless provider.respond_to?(:available_locations)
174
+ raise LocationDiscoveryError, "location discovery is unavailable from the custom attach credentials provider"
175
+ end
176
+ provider.available_locations
177
+ end
178
+
179
+ # Optional configuration discovery, not a runtime operation prerequisite.
180
+ def project_policy
181
+ provider = @connection.attach_provider
182
+ raise ArgumentError, "project policy requires a project API key provider" unless provider.respond_to?(:project_policy)
183
+
184
+ provider.project_policy
185
+ end
186
+
187
+ # PERMANENTLY delete a Computer: its identity and saved state end (stop
188
+ # and kill keep both). Accepts a PineSandbox::Computer or its id String. A
189
+ # Computer that is still attached is force-terminated first (#kill — no
190
+ # final capture, the state is being deleted). The portal tombstones the id
191
+ # immediately, destroys any remaining sandboxes asynchronously, purges saved
192
+ # state after 24 hours, and never accepts the id again. Idempotent: unknown,
193
+ # already-deleted, and other projects' ids all return true. No undelete.
194
+ def delete_computer(computer)
195
+ computer_id =
196
+ case computer
197
+ when Computer then computer.id
198
+ when String then computer
199
+ else raise ArgumentError, "delete_computer expects a PineSandbox::Computer or a computer_id String"
200
+ end
201
+ raise ArgumentError, "computer_id required" if computer_id.empty?
202
+
203
+ provider = @connection.attach_provider
204
+ unless provider.respond_to?(:delete_computer)
205
+ raise ComputerDeletionError, "computer deletion is unavailable from the custom attach credentials provider"
206
+ end
207
+
208
+ computer.kill if computer.is_a?(Computer)
209
+ provider.delete_computer(computer_id)
210
+ true
211
+ end
212
+
213
+ # Provision a NEW persistent Computer (fresh UUIDv7 id + random 32-byte state key),
214
+ # bind it, and return it ready. Pass saved Computer::Credentials for crash-safe
215
+ # creation, or save computer.credentials from the returned Computer.
216
+ #
217
+ # `ephemeral: true` provisions an access-lease-only Computer that persists
218
+ # NOTHING — no capture keypair is generated or required, no state key is
219
+ # sealed into the bind, and #stop skips the pre-stop checkpoint.
220
+ def create_computer(credentials = nil, **options)
221
+ options = credentials_options(credentials, options) unless credentials.nil?
222
+ create_new_computer(**options)
223
+ end
224
+
225
+ def create_new_computer(id: nil, key: nil, capture_keypair: nil, prior_capture_keypairs: [],
226
+ prior_keys: {}, ephemeral: false, location: nil, **attach_opts)
227
+ # An ephemeral Computer never carries a capture keypair, so only its id/key
228
+ # are ever generated; a persistent Computer also generates the keypair.
229
+ needs_generation = id.nil? || key.nil? || (!ephemeral && capture_keypair.nil?)
230
+ generated = Computer.generate_credentials(ephemeral: ephemeral) if needs_generation
231
+ cid = id || generated.id
232
+ state_key = key || generated.key
233
+ computer = Computer.new(cid, key: state_key, **(ephemeral ? { ephemeral: true } : {}))
234
+ capture_keypair ||= generated&.capture_keypair
235
+ configure_capture_keypairs(computer, capture_keypair, prior_capture_keypairs)
236
+ prior_keys.each { |version, bytes| computer.add_prior_key(version, bytes) }
237
+ recovery_credentials = computer.credentials
238
+ attach_opts = attach_opts.merge(location: location) if location
239
+ attach_with_recovery_context(
240
+ computer, initial_revision: 0, recovery_credentials: recovery_credentials, **attach_opts
241
+ )
242
+ end
243
+
244
+ private :create_new_computer
245
+
246
+ # Compatibility helper for integrations that explicitly pre-register a
247
+ # computer_id. Normal create/attach does not call this: first attach lazily
248
+ # creates Portal's minimal authorization row atomically with the binding.
249
+ def register_computer(computer_id, location: nil)
250
+ provider = @connection.attach_provider
251
+ return unless provider.respond_to?(:register_computer)
252
+
253
+ location ? provider.register_computer(computer_id, location: location) : provider.register_computer(computer_id)
254
+ end
255
+
256
+ # Re-attach an EXISTING persistent Computer by its stored id + state key. A fresh
257
+ # runtime is allocated and bound with the Computer's prior state restored.
258
+ # The id may be given positionally or as `id:` (matching #create_computer).
259
+ def start_computer(*args, **options)
260
+ if args.first.is_a?(Computer::Credentials)
261
+ unless @connection.attach_provider.respond_to?(:current_binding)
262
+ raise ArgumentError, "Start with saved credentials requires a provider with current_binding"
263
+ end
264
+ credentials, *args = args
265
+ options = credentials_options(credentials, options)
266
+ end
267
+ start_existing_computer(*args, **options, recover_runtime: false)
268
+ end
269
+
270
+ # Gracefully stop the binding observed for this ID, even when a caller lost
271
+ # its Start response before saving the runtime token. Never follow a newer
272
+ # binding during this invocation; project authorization is sufficient.
273
+ def stop_computer(computer_id, **wait_options)
274
+ raise ArgumentError, "id must be a lowercase UUIDv7" unless computer_id.to_s.match?(Computer::UUID_V7)
275
+ provider = @connection.attach_provider
276
+ unless provider.respond_to?(:current_binding)
277
+ raise ArgumentError, "Stop by ID requires a provider with current_binding"
278
+ end
279
+ reference = provider.current_binding(computer_id)
280
+ runtime_id = reference["sandbox_id"]
281
+ return true if runtime_id.nil?
282
+
283
+ RuntimeHandle.new(control_plane: @connection.control_plane, runtime_id: runtime_id).stop(**wait_options)
284
+ rescue UnknownComputerError
285
+ true
286
+ end
287
+
288
+ # Explicitly discard a failed runtime, wait for release, then Start from
289
+ # committed state. This loses unsaved memory; it never replaces a healthy
290
+ # paused or running Computer and never silently follows failed Resume.
291
+ def recover_computer(*args, **options)
292
+ if args.first.is_a?(Computer::Credentials)
293
+ credentials, *args = args
294
+ options = credentials_options(credentials, options)
295
+ end
296
+ start_existing_computer(*args, **options, recover_runtime: true)
297
+ end
298
+
299
+ def start_existing_computer(positional_id = nil, id: nil, key:, capture_keypair: nil, prior_capture_keypairs: [],
300
+ binding_revision: 0, prior_keys: {}, ephemeral: false, location: nil, recover_runtime: false, **attach_opts)
301
+ if positional_id && id && positional_id != id
302
+ raise ArgumentError, "attach_computer got two different ids (positional and id:)"
303
+ end
304
+ id ||= positional_id
305
+ raise ArgumentError, "attach_computer requires the Computer id" if id.nil?
306
+
307
+ provider = @connection.attach_provider
308
+ if recover_runtime && !provider.respond_to?(:current_binding)
309
+ raise ArgumentError, "saved-state recovery requires a provider with current_binding"
310
+ end
311
+ if provider.respond_to?(:current_binding)
312
+ reference = provider.current_binding(id)
313
+ binding_revision = reference.fetch("binding_revision")
314
+ if (runtime_id = reference["sandbox_id"])
315
+ begin
316
+ runtime = @connection.control_plane.get(runtime_id)
317
+ unless runtime.status == :terminated && runtime.reason == "COMPUTE_RELEASED"
318
+ unless recover_runtime || runtime.status == :stopping
319
+ raise RuntimeRetainedError.new(runtime_id: runtime_id, state: runtime.status)
320
+ end
321
+ handle = RuntimeHandle.new(control_plane: @connection.control_plane, runtime_id: runtime_id)
322
+ unless handle.release_failed_runtime(runtime)
323
+ raise TimeoutError, "Old runtime release is unconfirmed; no replacement was allocated"
324
+ end
325
+ end
326
+ rescue NotFoundError
327
+ # Authoritative project-scoped absence allows ordinary Start.
328
+ end
329
+ end
330
+ end
331
+
332
+ computer = Computer.new(id, key: key, binding_revision: binding_revision,
333
+ **(ephemeral ? { ephemeral: true } : {}))
334
+ configure_capture_keypairs(computer, capture_keypair, prior_capture_keypairs)
335
+ prior_keys.each { |version, bytes| computer.add_prior_key(version, bytes) }
336
+ attach_opts = attach_opts.merge(location: location) if location
337
+ attach_with_recovery_context(computer, initial_revision: binding_revision, **attach_opts)
338
+ end
339
+
340
+ private :start_existing_computer
341
+
342
+ # Compatibility name for saved-state Start; never a memory Resume.
343
+ alias_method :attach_computer, :start_computer
344
+
345
+ # Adopt an ALREADY-bound, still-live Computer from its persisted `{id, key,
346
+ # runtime_id, ct_}` — NO new runtime, NO re-bind. For driving a running Computer
347
+ # across requests (e.g. re-minting a desktop `dt_` for a reconnect) from a
348
+ # backend that cached the `ct_`. Use #start_computer to restore onto a fresh runtime.
349
+ # `sandbox_id:` is the deprecated spelling of `runtime_id:`.
350
+ def adopt_existing(id:, key:, computer_token:, runtime_id: nil, sandbox_id: nil, status: :running,
351
+ binding_revision: 0, ephemeral: false)
352
+ Computer.new(id, key: key, binding_revision: binding_revision, ephemeral: ephemeral)
353
+ .adopt(@connection, runtime_id: runtime_id || sandbox_id, computer_token: computer_token,
354
+ status: status, binding_revision: binding_revision)
355
+ end
356
+
357
+ # Reconstruct exactly the saved binding, without network I/O. A subsequent
358
+ # status/action reports whether it still exists; reconnect never allocates.
359
+ def reconnect_computer(credentials, binding:)
360
+ unless credentials.is_a?(Computer::Credentials) && binding.is_a?(Computer::Binding)
361
+ raise ArgumentError, "reconnect requires Computer credentials and binding"
362
+ end
363
+ credentials.validate!
364
+ binding.validate!
365
+ unless credentials.id == binding.computer_id
366
+ raise ArgumentError, "credentials and binding belong to different Computers"
367
+ end
368
+ computer = adopt_existing(id: credentials.id.dup, key: credentials.key.dup,
369
+ runtime_id: binding.runtime_id.dup, computer_token: binding.computer_token.dup,
370
+ binding_revision: binding.binding_revision, ephemeral: credentials.ephemeral)
371
+ configure_capture_keypairs(computer, credentials.capture_keypair, credentials.prior_capture_keypairs)
372
+ credentials.prior_keys.each { |version, key| computer.add_prior_key(version, key.dup) }
373
+ computer
374
+ end
375
+
376
+ # Block form: yields a ready Computer and stops it on exit.
377
+ # Failed saves and unconfirmed deletion are raised to the caller.
378
+ def with_computer(**opts)
379
+ computer = create_computer(**opts)
380
+ yield computer
381
+ ensure
382
+ if computer && !computer.stop
383
+ raise TimeoutError, "Computer #{computer.id} stop is unconfirmed; retry Stop with its saved binding"
384
+ end
385
+ end
386
+
387
+ private
388
+
389
+ def credentials_options(credentials, options)
390
+ unless credentials.is_a?(Computer::Credentials)
391
+ raise ArgumentError, "credentials must be a PineSandbox::Computer::Credentials"
392
+ end
393
+ credentials.validate!
394
+ if (options.keys & credentials.members).any?
395
+ raise ArgumentError, "pass credentials or individual identity/key options, not both"
396
+ end
397
+ credentials.to_h.merge(options)
398
+ end
399
+
400
+ def attach_with_recovery_context(computer, initial_revision:, recovery_credentials: nil, **attach_opts)
401
+ computer.attach(@connection, **attach_opts)
402
+ rescue StandardError => e
403
+ revision = computer.binding_revision
404
+ runtime_id = computer.last_authorized_runtime_id
405
+ raise if revision <= initial_revision || runtime_id.to_s.empty?
406
+
407
+ raise AttachAuthorizationCommittedError.new(
408
+ "attach authorization committed at revision #{revision} for runtime #{runtime_id}, " \
409
+ "but the bind failed: #{e.message}",
410
+ binding_revision: revision, runtime_id: runtime_id,
411
+ credentials: recovery_credentials,
412
+ status: (e.respond_to?(:status) ? e.status : nil), cause: e
413
+ )
414
+ end
415
+
416
+ def configure_capture_keypairs(computer, current, prior)
417
+ prior = Array(prior)
418
+ unless current.nil? || current.is_a?(CaptureKeypair)
419
+ raise ArgumentError, "capture_keypair must be a PineSandbox::CaptureKeypair"
420
+ end
421
+ unless prior.all? { |keypair| keypair.is_a?(CaptureKeypair) }
422
+ raise ArgumentError, "prior_capture_keypairs must contain only PineSandbox::CaptureKeypair values"
423
+ end
424
+ if prior.any? && current.nil?
425
+ raise ArgumentError, "prior_capture_keypairs require capture_keypair"
426
+ end
427
+ if current
428
+ generations = prior.map(&:generation)
429
+ duplicate = generations.tally.find { |_generation, count| count > 1 }&.first
430
+ raise ArgumentError, "duplicate prior capture keypair generation #{duplicate}" if duplicate
431
+
432
+ invalid = prior.find { |keypair| keypair.generation >= current.generation }
433
+ if invalid
434
+ raise ArgumentError,
435
+ "prior capture keypair generation #{invalid.generation} must be below the current generation #{current.generation}"
436
+ end
437
+ end
438
+
439
+ # Apply only after the complete option set passes validation. This keeps
440
+ # create_computer side-effect-free on bad input: no partial key setup and
441
+ # no ownership registration request.
442
+ computer.set_capture_keypair(current) if current
443
+ prior.each { |keypair| computer.add_prior_capture_keypair(keypair) }
444
+ end
445
+ end
446
+ end
@@ -0,0 +1,51 @@
1
+ module PineSandbox
2
+ # @api private — the SDK's view of time. Every wait, deadline and jitter the
3
+ # SDK uses goes through one of these, so a test can substitute a virtual
4
+ # clock (a sleep returns at once, advances the clock and is recorded) and
5
+ # assert the exact waits. Production uses SystemClock.
6
+ class SystemClock
7
+ # Seconds on a monotonic clock, for deadlines and elapsed time.
8
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
9
+
10
+ # Wall-clock time, for token expiry.
11
+ def now = Time.now
12
+
13
+ def sleep(seconds)
14
+ Kernel.sleep(seconds) if seconds.positive?
15
+ end
16
+
17
+ # A uniform random Float in [0, 1), for jitter.
18
+ def rand = Kernel.rand
19
+ end
20
+
21
+ # @api private
22
+ SYSTEM_CLOCK = SystemClock.new
23
+
24
+ # @api private — the shared client policy (computer-api.yaml x-pine-retry.client).
25
+ module RetryPolicy
26
+ # A replay-safe request is sent at most this many times after a transport fault.
27
+ TRANSIENT_ATTEMPTS = 3
28
+ TRANSIENT_BASE = 0.2
29
+ TRANSIENT_JITTER = 0.1
30
+ TRANSIENT_CAP = 2.0
31
+
32
+ # An event stream reconnects at most this many times in a row without
33
+ # delivering an event, then raises StreamLostError.
34
+ STREAM_RECONNECT_BUDGET = 5
35
+ STREAM_BASE = 0.5
36
+ STREAM_JITTER = 0.2
37
+ STREAM_CAP = 5.0
38
+
39
+ module_function
40
+
41
+ # The wait before transient retry `attempt` (1-based).
42
+ def transient_backoff(attempt, clock)
43
+ [TRANSIENT_BASE * (2**(attempt - 1)) + clock.rand * TRANSIENT_JITTER, TRANSIENT_CAP].min
44
+ end
45
+
46
+ # The wait before stream reconnect `attempt` (1-based).
47
+ def stream_backoff(attempt, clock)
48
+ [STREAM_BASE * (2**(attempt - 1)) + clock.rand * STREAM_JITTER, STREAM_CAP].min
49
+ end
50
+ end
51
+ end