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,1075 @@
1
+ require "base64"
2
+ require "json"
3
+ require "securerandom"
4
+ require "digest"
5
+ require_relative "errors"
6
+ require_relative "connection"
7
+ require_relative "bind_hpke"
8
+ require_relative "capture_keypair"
9
+ require_relative "adapters/coordinator_adapter"
10
+ require_relative "adapters/snapshot_adapter"
11
+ require_relative "session"
12
+
13
+ module PineSandbox
14
+ # A persistent Computer — the one object in this SDK. It is either
15
+ # powered-off (just an `id` + the per-Computer `key` you hold in your DB)
16
+ # or powered-on (bound to a live runtime). `attach` allocates a runtime,
17
+ # runs the bind/restore handshake so the Computer resumes its prior state,
18
+ # and returns `self`; you then drive it through `create_session`. `stop`
19
+ # saves supported state and releases the runtime, and you can `attach`
20
+ # again later onto a fresh runtime with state restored.
21
+ #
22
+ # computer = PineSandbox::Computer.new("0190…", key: secret_32_bytes)
23
+ # computer.attach(connection)
24
+ # session = computer.create_session(name: "main", browser: true)
25
+ # session.create_tab(url: "https://example.com")
26
+ # computer.stop
27
+ #
28
+ # `id`, `key`, and the asymmetric capture keypair are the caller-owned
29
+ # per-Computer inputs; the attach authorizer (the pk_-backed portal
30
+ # attach-credentials provider) is set once on the connection. The API key
31
+ # alone cannot read state (that needs the private key material).
32
+ #
33
+ # Not thread-safe: a single Computer instance carries one binding (runtime
34
+ # + ct_); drive one Computer from one thread at a time.
35
+ class Computer
36
+ CURRENT_KEY_VERSION = 1
37
+ DEFAULT_MAX_BIND_ATTEMPTS = 3
38
+ # Allocation and runtime readiness; independent of the sandbox lifetime.
39
+ DEFAULT_READY_TIMEOUT = 300
40
+ # Wall-clock budget for a just-allocated runtime to become bindable. Spans
41
+ # the gap between allocation reporting "running" and the Computer actually
42
+ # serving; the bind handshake retries not-ready responses (404 / 503 /
43
+ # transient transport errors) within it. Generous — a healthy runtime
44
+ # clears it in seconds; this only bounds one that never comes up.
45
+ DEFAULT_BIND_READY_TIMEOUT = 120
46
+ # The Computer id is a lowercase UUIDv7; a non-matching id would be
47
+ # rejected late as a BindAuthError, so reject it up front instead.
48
+ UUID_V7 = /\A[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/.freeze
49
+
50
+ # Transient transport failures worth retrying the bind handshake on, in
51
+ # both raw Faraday and SDK shapes. A just-allocated runtime may not be
52
+ # accepting connections yet (connection refused / TLS not up) — the same
53
+ # convergence window as 404/503.
54
+ TRANSIENT_BIND_ERRORS = [
55
+ Faraday::TimeoutError, Faraday::ConnectionFailed, Faraday::SSLError,
56
+ PineSandbox::TimeoutError, PineSandbox::ConnectionError
57
+ ].freeze
58
+
59
+ attr_reader :id, :binding_revision, :last_authorized_runtime_id, :location
60
+
61
+ # Deprecated: use #last_authorized_runtime_id.
62
+ def last_authorized_sandbox_id
63
+ PineSandbox.deprecated_name("Computer#last_authorized_sandbox_id", "#last_authorized_runtime_id")
64
+ last_authorized_runtime_id
65
+ end
66
+
67
+ # The runtime this Computer is attached to, or nil when it has none.
68
+ def runtime_id = @sandbox&.id
69
+
70
+ # @api private — internal plumbing, NOT a supported end-user surface. It is
71
+ # public only because `Session` reaches it via `parent.coordinator` (Ruby
72
+ # needs a public accessor for that cross-object call) and the wire-level
73
+ # OpenAPI conformance test drives raw routes through it. Use the typed
74
+ # Session (ps_) / Computer (ct_) methods, which attach the right token.
75
+ # Exposing it is NOT a privilege bypass: the Computer authorizes every
76
+ # route by token class (`/exec` is ps_-only; ps_ is bound to its own
77
+ # session), so a raw call here can only do what the caller's own token
78
+ # already permits.
79
+ attr_reader :coordinator
80
+
81
+ # A fresh Computer identity: a UUIDv7 `id` + a 32-byte state `key` — the SAME
82
+ # pair create_computer generates internally, exposed as the SDK's built-in
83
+ # generator so a client backend can mint + PERSIST them up front (crash-safe,
84
+ # and the required id format is guaranteed) and pass them into
85
+ # create_computer. The SDK never stores them; the integrator owns both (the
86
+ # `id` to address/adopt the Computer, the `key` to decrypt restored state).
87
+ # `ephemeral: true` mints an access-lease-only identity — no capture
88
+ # keypair, since an ephemeral Computer persists nothing.
89
+ def self.generate_credentials(ephemeral: false)
90
+ Credentials.new(
91
+ id: SecureRandom.uuid_v7,
92
+ key: SecureRandom.random_bytes(32),
93
+ capture_keypair: ephemeral ? nil : CaptureKeypair.generate(generation: 1),
94
+ ephemeral: ephemeral
95
+ )
96
+ end
97
+
98
+ # @param id [String] lowercase UUIDv7 — a non-secret, stable handle.
99
+ # @param key [String] 32 raw bytes, the per-Computer secret (REQUIRED).
100
+ # @param ephemeral [Boolean] when true the Computer binds an access lease
101
+ # ONLY — no state persistence: it submits no capture public key, seals no
102
+ # computer_key into the bind payload, carries no key_assertion, and #stop
103
+ # skips the pre-stop checkpoint. Default false is the persistent behavior.
104
+ def initialize(id, key:, binding_revision: 0, ephemeral: false)
105
+ raise ArgumentError, "id required" if id.to_s.empty?
106
+ raise ArgumentError, "id must be a lowercase UUIDv7" unless id.to_s.match?(UUID_V7)
107
+ raise ArgumentError, "key must be 32 raw bytes" unless key.is_a?(String) && key.bytesize == 32
108
+ unless binding_revision.is_a?(Integer) && binding_revision >= 0
109
+ raise ArgumentError, "binding_revision must be a non-negative integer"
110
+ end
111
+
112
+ @id = id.to_s
113
+ @key = key
114
+ @ephemeral = ephemeral ? true : false
115
+ @prior_keys = {} # version(Integer) => 32 raw bytes; rotation only
116
+ @capture_keypairs = {} # generation(Integer) => CaptureKeypair; asymmetric envelope v3
117
+ @capture_generation = 0
118
+ @binding_revision = binding_revision
119
+ reset_bound_state
120
+ end
121
+
122
+ # Register a prior key version so restore can decrypt a snapshot captured
123
+ # before a key rotation. Opt-in; steady state never calls
124
+ # it. Returns self for chaining.
125
+ def add_prior_key(version, bytes)
126
+ raise ArgumentError, "version must be an Integer" unless version.is_a?(Integer)
127
+ raise ArgumentError, "version must differ from the current key version" if version == CURRENT_KEY_VERSION
128
+ raise ArgumentError, "bytes must be 32 raw bytes" unless bytes.is_a?(String) && bytes.bytesize == 32
129
+
130
+ @prior_keys[version] = bytes
131
+ self
132
+ end
133
+
134
+ # Opt this Computer into asymmetric state encryption (component envelope v3):
135
+ # keypair.pk is submitted at the next attach, the platform seals every
136
+ # capture to it, and the bind runs the two-round restore (this SDK
137
+ # unwraps the per-component secrets with keypair.sk). Becomes the
138
+ # CURRENT generation; previously set generations stay registered for
139
+ # restores of pre-rotation state. Returns self for chaining.
140
+ def set_capture_keypair(keypair)
141
+ raise ArgumentError, "keypair must be a PineSandbox::CaptureKeypair" unless keypair.is_a?(CaptureKeypair)
142
+ if keypair.generation < @capture_generation
143
+ raise ArgumentError, "capture keypair generation #{keypair.generation} below the current generation #{@capture_generation} (rotations only move forward)"
144
+ end
145
+ existing = @capture_keypairs[keypair.generation]
146
+ if existing && (existing.pk != keypair.pk || existing.sk != keypair.sk)
147
+ raise ArgumentError, "capture keypair generation #{keypair.generation} is already registered with different key material"
148
+ end
149
+
150
+ @capture_keypairs[keypair.generation] = keypair
151
+ @capture_generation = keypair.generation
152
+ self
153
+ end
154
+
155
+ def capture_keypair
156
+ @capture_keypairs[@capture_generation]
157
+ end
158
+
159
+ # Caller-owned credentials, independent of this live handle. Save them before
160
+ # Create, and again only if you change keys. Start reads the binding itself.
161
+ def credentials
162
+ current = capture_keypair
163
+ Credentials.new(
164
+ id: @id.dup, key: @key.dup, ephemeral: @ephemeral,
165
+ capture_keypair: current&.dup,
166
+ prior_capture_keypairs: @capture_keypairs.values.reject { |pair| pair.equal?(current) }.map(&:dup),
167
+ prior_keys: @prior_keys.to_h { |version, bytes| [version, bytes.dup] }
168
+ )
169
+ end
170
+
171
+ # Register a SUPERSEDED capture keypair so a restore of state sealed
172
+ # under it can still unwrap its secrets. Never changes the current
173
+ # generation. Returns self for chaining.
174
+ def add_prior_capture_keypair(keypair)
175
+ raise ArgumentError, "keypair must be a PineSandbox::CaptureKeypair" unless keypair.is_a?(CaptureKeypair)
176
+ if @capture_generation.positive? && keypair.generation >= @capture_generation
177
+ raise ArgumentError, "prior capture keypair generation #{keypair.generation} must be below the current generation #{@capture_generation}"
178
+ end
179
+ existing = @capture_keypairs[keypair.generation]
180
+ if existing && (existing.pk != keypair.pk || existing.sk != keypair.sk)
181
+ raise ArgumentError, "capture keypair generation #{keypair.generation} is already registered with different key material"
182
+ end
183
+
184
+ @capture_keypairs[keypair.generation] = keypair
185
+ self
186
+ end
187
+
188
+ # Allocate a runtime and bind this Computer to it, restoring its state.
189
+ # Returns self (now powered-on). Safe across its internal retries —
190
+ # convergence retries re-send the SAME minted envelope, so a
191
+ # committed-but-lost bind replays idempotently. A runtime-identity
192
+ # shift is terminal because Portal has already committed the one
193
+ # authorization allowed for this sandbox. Raises if this Computer is already
194
+ # attached (call #stop or #kill first — re-attaching live must not
195
+ # silently discard state); on any bind failure the freshly allocated runtime
196
+ # is deleted and the Computer is left powered-off before a typed error.
197
+ #
198
+ # @param config [PineSandbox::Connection]
199
+ # timeout is the sandbox lifetime. ready_timeout bounds allocation and runtime
200
+ # readiness; bind_ready_timeout separately bounds the bind/restore handshake.
201
+ # @param pod_env [nil, Hash] Deprecated; only nil or an empty hash is accepted.
202
+ def attach(config, timeout: 300, ready_timeout: DEFAULT_READY_TIMEOUT, pod_env: nil, metadata: nil,
203
+ location: nil,
204
+ max_bind_attempts: DEFAULT_MAX_BIND_ATTEMPTS,
205
+ bind_ready_timeout: DEFAULT_BIND_READY_TIMEOUT)
206
+ unless config.respond_to?(:attach_provider) && config.respond_to?(:control_plane) && config.respond_to?(:zone)
207
+ raise ArgumentError, "config must be a PineSandbox::Connection (got #{config.class})"
208
+ end
209
+ unless max_bind_attempts.is_a?(Integer) && max_bind_attempts.positive?
210
+ raise ArgumentError, "max_bind_attempts must be a positive integer"
211
+ end
212
+ unless pod_env.nil? || pod_env == {}
213
+ raise ArgumentError, "pod_env is unsupported for Computer attach; omit it or pass an empty hash"
214
+ end
215
+ unless (ready_timeout.is_a?(Integer) || ready_timeout.is_a?(Float)) && ready_timeout.finite? && ready_timeout.positive?
216
+ raise ArgumentError, "ready_timeout must be a positive finite number of seconds"
217
+ end
218
+
219
+ # Refuse to re-attach a live Computer: silently tearing down the prior pod
220
+ # here would force-kill it (ungraceful) and lose state since the last
221
+ # checkpoint. Make the teardown an explicit caller choice — #stop (graceful,
222
+ # persist) or #kill (discard) — then attach again.
223
+ raise BindError.new("Computer already attached — call #stop (graceful) or #kill first") if @sandbox
224
+ # A persistent attach requires the capture keypair (it seals every capture
225
+ # to it); an ephemeral Computer persists nothing, so it binds without one.
226
+ if @capture_generation.zero? && !@ephemeral
227
+ raise ArgumentError, "v3 attach requires a capture_keypair (persist both halves)"
228
+ end
229
+ # Preserve omission as omission. @location is only the last Portal
230
+ # receipt, not authority to overwrite a newer country another client may
231
+ # have committed after this instance stopped.
232
+ requested_location = normalize_location(location) if location
233
+ @last_authorized_runtime_id = nil
234
+ @connection = config
235
+ @clock = config.respond_to?(:clock) ? config.clock : PineSandbox::SYSTEM_CLOCK
236
+
237
+ # Provision via the project-scoped control plane (api.<zone>, project JWS).
238
+ create_id = SecureRandom.uuid
239
+ ready_deadline = monotonic_now + ready_timeout
240
+ info = config.control_plane.create_computer(
241
+ body: build_computer_create_body(timeout: timeout, metadata: metadata).merge(create_id: create_id),
242
+ request_timeout: ready_timeout
243
+ )
244
+ @sandbox = RuntimeHandle.new(control_plane: config.control_plane, runtime_id: info.id, status: info.status)
245
+ # Wait for the pod to report Running (a CUA pod's readiness; the bind handshake
246
+ # below is the functional coord-readiness check). A pod that fails or never
247
+ # becomes Ready is destroyed before the typed error leaves #attach.
248
+ begin
249
+ @sandbox.wait_until_running(timeout: [ready_deadline - monotonic_now, 0].max)
250
+ rescue StandardError
251
+ reset_bound_state
252
+ raise
253
+ end
254
+ # Drive the Computer at its runtime's gateway host `https://<runtime_id>.computer.<zone>`
255
+ # — port-less, header-less, role-by-path; coord gates every route on the bearer.
256
+ @coordinator = Adapters::CoordinatorAdapter.new(runtime_id: info.id, zone: config.zone, clock: @clock)
257
+
258
+ # Two retry budgets converge on the bind handshake:
259
+ # - readiness (DEADLINE-bounded): the runtime was just allocated, so the
260
+ # gateway can't reach a serving Computer yet — 404 (not yet routable),
261
+ # 503 (not yet ready), a transient transport error, or an in-flight
262
+ # bind settling (409 bind-in-progress). All clear as the runtime
263
+ # starts, so retry until a wall-clock DEADLINE — a cold runtime or a
264
+ # slow restore can outlast a few tries, and attempt-counting gives up early.
265
+ # - restore restart (ATTEMPT-bounded): if coord's short-lived two-round
266
+ # challenge expires, clear only restore_secrets and restart round one
267
+ # with the same committed token/JTI/ciphertext. No Portal re-mint.
268
+ # Terminal bind errors (pod identity shift, epoch conflict, pod-tainted, auth, coord's own
269
+ # problem+json 503 bind-not-configured) never retry: the pod is
270
+ # single-use, so recovery is a fresh #attach on a fresh pod.
271
+ deadline = monotonic_now + bind_ready_timeout
272
+ restore_attempts = 1
273
+ converge_waits = 0
274
+ begin
275
+ raise BindTimeoutError.new("Computer did not become bindable within its bind budget") if monotonic_now >= deadline
276
+ run_bind(config, requested_location: requested_location)
277
+ rescue PineSandbox::ApiError => e
278
+ if coord_not_ready_via_ingress?(e) || transient_bind_restore?(e)
279
+ if monotonic_now < deadline
280
+ converge_waits += 1
281
+ sleep(bind_convergence_backoff(converge_waits))
282
+ retry
283
+ end
284
+ reset_bound_state
285
+ raise BindTimeoutError.new("Computer runtime did not become bindable within #{bind_ready_timeout}s (last: HTTP #{e.status}): #{e.message}", cause: e)
286
+ elsif restore_no_challenge?(e) && restore_attempts < max_bind_attempts
287
+ # Only coord's short-lived restore challenge expired. Reuse the one
288
+ # Portal authorization committed for this sandbox and restart round one.
289
+ restore_attempts += 1
290
+ @bind_envelope&.delete(:restore_secrets)
291
+ sleep(0.5 * (restore_attempts - 1))
292
+ retry
293
+ end
294
+ reset_bound_state
295
+ raise map_bind_error(e)
296
+ rescue *TRANSIENT_BIND_ERRORS => e
297
+ # A just-created pod's coord may not be listening yet (connection
298
+ # refused / TLS not up) — the same convergence window as 404/503,
299
+ # so bound by the same deadline rather than a few attempts.
300
+ if monotonic_now < deadline
301
+ converge_waits += 1
302
+ sleep(bind_convergence_backoff(converge_waits))
303
+ retry
304
+ end
305
+ reset_bound_state
306
+ raise BindTimeoutError.new("bind handshake did not complete within #{bind_ready_timeout}s: #{e.class}: #{e.message}", cause: e)
307
+ rescue StandardError
308
+ reset_bound_state
309
+ raise
310
+ end
311
+
312
+ self
313
+ end
314
+
315
+ # attach + auto-stop. Yields self; stops the Computer (saving supported
316
+ # state) when the block returns or raises.
317
+ def with_attached(config, **opts)
318
+ PineSandbox.deprecated_name("Computer#with_attached", "Client#with_computer")
319
+ attach(config, **opts)
320
+ yield self
321
+ ensure
322
+ # Graceful by default: persist state on the way out (no-op if attach
323
+ # already cleaned up after a bind failure — @sandbox is nil then).
324
+ stop
325
+ end
326
+
327
+ # Adopt an ALREADY-bound runtime by its `runtime_id` + `ct_`, WITHOUT allocating
328
+ # or re-binding. For a trusted backend that persisted `{id, key, runtime_id,
329
+ # ct_}` and needs to drive across processes/requests (e.g. re-mint a desktop
330
+ # `dt_` for a reconnect). No bind handshake runs, so the state `key` is unused
331
+ # here — it matters only for #attach (restore onto a fresh runtime). Refuses if
332
+ # already attached; use Client#start_computer to restore state onto a NEW runtime.
333
+ # `sandbox_id:` is the deprecated spelling of `runtime_id:`.
334
+ def adopt(config, computer_token:, runtime_id: nil, sandbox_id: nil, status: :running,
335
+ binding_revision: @binding_revision)
336
+ raise BindError.new("Computer already attached — call #stop or #kill first") if @sandbox
337
+ unless config.respond_to?(:control_plane) && config.respond_to?(:zone)
338
+ raise ArgumentError, "config must be a PineSandbox::Connection (control_plane + zone)"
339
+ end
340
+ runtime_id ||= sandbox_id
341
+ raise ArgumentError, "runtime_id required" if runtime_id.to_s.empty?
342
+ raise ArgumentError, "computer_token required" if computer_token.to_s.empty?
343
+ unless binding_revision.is_a?(Integer) && binding_revision >= 0
344
+ raise ArgumentError, "binding_revision must be a non-negative integer"
345
+ end
346
+
347
+ @connection = config
348
+ @clock = config.respond_to?(:clock) ? config.clock : PineSandbox::SYSTEM_CLOCK
349
+ @coordinator = Adapters::CoordinatorAdapter.new(runtime_id: runtime_id, zone: config.zone,
350
+ discovery_token: computer_token, clock: @clock)
351
+ @sandbox = RuntimeHandle.new(control_plane: config.control_plane, runtime_id: runtime_id, status: status)
352
+ @computer_token = computer_token
353
+ @binding_revision = binding_revision
354
+ self
355
+ end
356
+
357
+ # The Computer-tier token (`ct_*`) delivered in the bind response. Raises
358
+ # if called before #attach (or after #stop).
359
+ def computer_token
360
+ @computer_token || raise(BindError.new("Computer is not attached — call #attach first"))
361
+ end
362
+
363
+ # INFORMATIONAL ONLY: the `expires_at` returned with the ct_ in the bind
364
+ # response. Deprecated: ct_ is binding-lifetime (no idle expiry, no
365
+ # renewal), so current Computers omit the field and this is nil.
366
+ # The SDK keeps no timers and never refreshes either way. nil on adopted
367
+ # Computers too (the bind response isn't available there).
368
+ attr_reader :computer_token_expires_at
369
+
370
+ # The persistence mode recorded for this binding, read back from the
371
+ # bind response ("ephemeral" for an access-lease-only Computer). A response that
372
+ # omits the field leaves this nil; adopted Computers (no bind response) are
373
+ # nil too. Informational — the SDK acts on its own @ephemeral intent.
374
+ attr_reader :persistence_mode
375
+
376
+ # Whether this Computer was bound as ephemeral (access-lease only, no state
377
+ # persistence). Set at construction; the persistent default is false.
378
+ def ephemeral?
379
+ @ephemeral
380
+ end
381
+
382
+ # --- driving the powered-on Computer (sessions hold the `ps_*` token) ---
383
+
384
+ # `name` is optional: omit it (or pass nil/"") to let the Computer mint a
385
+ # friendly adjective-noun-hex name (e.g. "brave-otter-9f3a1c") — read it back
386
+ # off the returned {Session}. `label` is an optional human-meaningful tag,
387
+ # distinct from the addressing name. A duplicate name raises
388
+ # PineSandbox::ConflictError.
389
+ def create_session(name: nil, label: nil, browser: false, blind: false)
390
+ rebind_on_unauthorized do
391
+ payload = coordinator!.create_session(
392
+ token: computer_token, name: name, label: label, browser: browser, blind: blind
393
+ )
394
+ Session.new(parent: self, payload: payload)
395
+ end
396
+ end
397
+
398
+ # Ergonomic one-call desktop delegation — the common playground flow is
399
+ # client.create_computer(...).delegate_desktop
400
+ # Ensures the named (browser) desktop session exists, then returns its
401
+ # browser-safe {DelegatedConnection} (host + session name + spec major +
402
+ # a fresh short-TTL `dt_`; no `ct_`/`ps_`/JWS). `create: true` (default) creates
403
+ # the session if absent; `create: false` requires it. To refresh the `dt_`
404
+ # before a reconnect, call `session(name).delegate` again (server-side).
405
+ def delegate_desktop(name: "main", create: true, browser: true)
406
+ s =
407
+ begin
408
+ session(name)
409
+ rescue PineSandbox::NotFoundError
410
+ raise unless create
411
+
412
+ create_session(name: name, browser: browser)
413
+ end
414
+ s.delegate
415
+ end
416
+
417
+ def session(name)
418
+ rebind_on_unauthorized do
419
+ Session.new(parent: self, payload: coordinator!.get_session(token: computer_token, name: name))
420
+ end
421
+ end
422
+
423
+ # Reconstruct the same session from credentials saved with this binding.
424
+ # No request, session creation or token refresh; authorization is checked
425
+ # on first use. Metadata remains available through #session(name).
426
+ def adopt_session(name, session_token)
427
+ coordinator!
428
+ unless name.is_a?(String) && !name.empty? && session_token.is_a?(String) && !session_token.empty?
429
+ raise ArgumentError, "session name and token are required"
430
+ end
431
+
432
+ Session.new(parent: self, payload: { "name" => name, "token" => session_token })
433
+ end
434
+
435
+ # computer.skills.* — the Computer's learned-skill lifecycle (Computer Skills): list/get the
436
+ # served skills, review pending drafts (with bodies), activate/deactivate. Skills are
437
+ # computer-global; the authoring TRIGGERS are session-scoped — see Session#learn / #teach.
438
+ def skills
439
+ @skills ||= Skills.new(self)
440
+ end
441
+
442
+ # capabilities — the product features this Computer can use for its binding, and the limits
443
+ # it enforces on your calls.
444
+ def capabilities
445
+ rebind_on_unauthorized { coordinator!.capabilities(token: computer_token) }
446
+ end
447
+
448
+ # Qualify the current public agent contract before a workflow changes
449
+ # control or submits work. Unsupported runtimes raise AgentProtocolUnsupported;
450
+ # binding-auth and transport failures retain their ordinary SDK meaning.
451
+ def require_agent_protocol!
452
+ rebind_on_unauthorized { coordinator!.require_agent_protocol!(token: computer_token) }
453
+ self
454
+ end
455
+
456
+ def sessions
457
+ rebind_on_unauthorized do
458
+ coordinator!.list_sessions(token: computer_token).map { |payload| Session.new(parent: self, payload: payload) }
459
+ end
460
+ end
461
+
462
+ def destroy_session(name, clean: false)
463
+ rebind_on_unauthorized do
464
+ coordinator!.destroy_session(token: computer_token, name: name, clean: clean)
465
+ end
466
+ end
467
+
468
+ # --- control lease (drive ⇔ view), Computer-level facade ---
469
+ # The desktop stream forwards input only while a person holds control.
470
+ # A viewer (browser, dt_) can't take it; your backend brokers it here
471
+ # with the ct_. Hides the session lookup + etag handshake + actor_type so callers
472
+ # don't reach into Session. ct_-only, so it works on an adopted Computer.
473
+ # Returns the new ControlState.
474
+ def take_control(name: "main", actor_type: "user_click")
475
+ rebind_on_unauthorized { session(name).take_control(actor_type: actor_type) }
476
+ end
477
+
478
+ def release_control(name: "main", actor_type: "user_click")
479
+ rebind_on_unauthorized { session(name).release_control(actor_type: actor_type) }
480
+ end
481
+
482
+ # Lazily-allocated snapshot reader (GET /state, ct_-gated). Bound to the
483
+ # CURRENT runtime; reset on every (re-)attach so it never points at a
484
+ # stale one.
485
+ def snapshot
486
+ @snapshot ||= Adapters::SnapshotAdapter.new(coordinator: coordinator!, token_provider: -> { computer_token })
487
+ end
488
+
489
+ # Save a restore point NOW — a synchronous, durable capture of this
490
+ # Computer's cookie jar, returned once committed. This is the EXPLICIT
491
+ # durability primitive: call it when you want the current state guaranteed
492
+ # restorable on the next #attach without stopping the Computer. #stop
493
+ # tries a final save before releasing the runtime, and releases it even
494
+ # when that save fails.
495
+ # Content-hash-gated: an unchanged state returns `{"skipped" => true}` and
496
+ # the prior snapshot stays latest. Returns the persisted snapshot
497
+ # descriptor.
498
+ #
499
+ # SCOPE: a live checkpoint captures the cookie jar (the identity carrier
500
+ # for most auth). localStorage is only captured with Chrome stopped
501
+ # (#stop / restart onto a fresh runtime), so a mid-session #checkpoint does not
502
+ # refresh it — the last stopped-capture value is preserved and restored.
503
+ def checkpoint
504
+ rebind_on_unauthorized { coordinator!.capture(token: computer_token) }
505
+ end
506
+
507
+ # Metadata-only inventory of durable custodial passkeys held by this
508
+ # Computer. The private keys remain inside the Computer and its encrypted state.
509
+ def passkeys
510
+ rebind_on_unauthorized { coordinator!.list_passkeys(token: computer_token) }
511
+ end
512
+
513
+ def delete_passkey(credential_id)
514
+ raise ArgumentError, "credential_id is required" if credential_id.to_s.empty?
515
+
516
+ rebind_on_unauthorized do
517
+ coordinator!.delete_passkey(token: computer_token, credential_id: credential_id)
518
+ end
519
+ end
520
+
521
+ # Pending first-enrollment ceremonies require an explicit Computer-owner
522
+ # decision. Assertions for already-held credentials remain automatic.
523
+ def pending_passkey_ceremonies
524
+ rebind_on_unauthorized { coordinator!.list_passkey_ceremonies(token: computer_token) }
525
+ end
526
+
527
+ def approve_passkey_ceremony(ceremony_id)
528
+ decide_passkey_ceremony(ceremony_id, "approve_custodial")
529
+ end
530
+
531
+ def decline_passkey_ceremony(ceremony_id)
532
+ decide_passkey_ceremony(ceremony_id, "decline")
533
+ end
534
+
535
+ # Browser tabs no session owns (for example a page a site opened in a new
536
+ # window after its session ended). Returns the response Hash.
537
+ def orphan_tabs
538
+ rebind_on_unauthorized { coordinator!.list_orphan_tabs(token: computer_token) }
539
+ end
540
+
541
+ def close_orphan_tab(target_id)
542
+ raise ArgumentError, "target_id is required" if target_id.to_s.empty?
543
+
544
+ rebind_on_unauthorized { coordinator!.close_orphan_tab(token: computer_token, target_id: target_id) }
545
+ end
546
+
547
+ def orphan_downloads
548
+ rebind_on_unauthorized { coordinator!.list_orphan_downloads(token: computer_token) }
549
+ end
550
+
551
+ def claim_orphan_download(guid:, session_name:, filename: nil)
552
+ rebind_on_unauthorized do
553
+ coordinator!.claim_orphan_download(token: computer_token, guid: guid, session_name: session_name, filename: filename)
554
+ end
555
+ end
556
+
557
+ def discard_orphan_download(guid)
558
+ rebind_on_unauthorized { coordinator!.discard_orphan_download(token: computer_token, guid: guid) }
559
+ end
560
+
561
+ # Preserve the current sessions, capabilities and live process memory.
562
+ # These bounded calls wait for observed completion; neither rebinds.
563
+ def pause(timeout: 360)
564
+ raise ArgumentError, "Computer has no runtime" unless @sandbox
565
+
566
+ @sandbox.pause(timeout: timeout)
567
+ self
568
+ end
569
+
570
+ def resume(timeout: 360)
571
+ raise ArgumentError, "Computer has no retained runtime" unless @sandbox
572
+
573
+ @sandbox.resume(timeout: timeout)
574
+ self
575
+ end
576
+
577
+ # Stop is owned by the server after acceptance. A timeout only ends this
578
+ # wait; another backend using the saved handle can observe or retry it.
579
+ # true confirms compute release; final save is unknown after record removal.
580
+ def stop(wait_timeout: 390)
581
+ return true unless @sandbox
582
+
583
+ gone = @sandbox.stop(wait_timeout: wait_timeout)
584
+ clear_bound_state if gone
585
+ gone
586
+ end
587
+
588
+ # Force-terminate the runtime: UNGRACEFUL, no wait. The escape hatch for a stuck
589
+ # runtime — it may lose state written since the last checkpoint. #stop
590
+ # is the normal path. (Computers should always terminate gracefully; reach
591
+ # for #kill only to recover.)
592
+ #
593
+ # NOTE: there is intentionally no #destroy alias. The lifecycle verbs are
594
+ # distinct by intent: #stop saves supported state before shutdown;
595
+ # #kill is ungraceful force-recovery. The "done forever" verb —
596
+ # permanent delete, which ends the identity and purges saved state — is
597
+ # Client#delete_computer (it kills a still-attached Computer first).
598
+ def kill
599
+ return true unless @sandbox
600
+
601
+ deleted = @sandbox.kill
602
+ clear_bound_state if deleted
603
+ deleted
604
+ end
605
+
606
+ # PERMANENTLY delete this Computer: its identity and saved state end (see
607
+ # Client#delete_computer). A still-attached runtime is killed first (no
608
+ # final save). Needs the connection this Computer was attached or adopted
609
+ # through; for an id alone use Client#delete_computer. Returns true.
610
+ def delete
611
+ provider = @connection&.attach_provider
612
+ unless provider.respond_to?(:delete_computer)
613
+ raise ComputerDeletionError,
614
+ @connection ? "computer deletion is unavailable from the custom attach credentials provider" :
615
+ "this Computer was never attached; delete it with Client#delete_computer(id)"
616
+ end
617
+
618
+ kill
619
+ provider.delete_computer(id)
620
+ true
621
+ end
622
+
623
+ def alive?
624
+ @sandbox ? @sandbox.alive? : false
625
+ end
626
+
627
+ # Observe the saved binding through the lifecycle service. Unlike #alive?,
628
+ # absence and transport/auth failures remain distinct errors. This never
629
+ # clears a binding, resumes memory or allocates a replacement runtime.
630
+ def status
631
+ raise BindError, "Computer has no runtime binding" unless @sandbox
632
+
633
+ @sandbox.status
634
+ end
635
+
636
+ # Observe this exact runtime through the lifecycle API, including while
637
+ # paused. No Portal lookup, rebind or replacement follows an absent runtime.
638
+ def runtime_details
639
+ raise BindError, "Computer has no runtime binding" unless @sandbox
640
+
641
+ info = @sandbox.runtime_details
642
+ RuntimeDetails.from_runtime(info)
643
+ end
644
+
645
+ # An unchanged value is not a heartbeat. On conflict or a lost reply,
646
+ # observe runtime_details before deciding whether another edit is needed.
647
+ def set_idle_timeout(seconds:)
648
+ raise BindError, "Computer has no runtime binding" unless @sandbox
649
+ raise ArgumentError, "idle timeout must be an integer of at least 60 seconds" unless seconds.is_a?(Integer) && seconds >= 60
650
+
651
+ RuntimeDetails.from_runtime(@sandbox.set_idle_timeout(seconds: seconds))
652
+ end
653
+
654
+ # Save after Create/Start, alongside the durable credentials. Never log JSON:
655
+ # it contains the live Computer token. Stop clears this object's binding.
656
+ def binding
657
+ raise BindError, "Computer has no runtime binding" unless @sandbox && @computer_token
658
+
659
+ Binding.new(computer_id: id.dup, runtime_id: @sandbox.id.dup,
660
+ computer_token: @computer_token.dup, binding_revision: @binding_revision).validate!
661
+ end
662
+
663
+ # @api private — internal plumbing, NOT a supported end-user surface. Public
664
+ # only because the Computer's facade objects (e.g. Skills) wrap their own
665
+ # ct_-authenticated calls in the same 401→TokenRejectedError mapping (Ruby
666
+ # needs a public method for that cross-object call).
667
+ #
668
+ # A 401 on a bound Computer means the binding-lifetime ct_ is no longer
669
+ # recognized (runtime replaced or its binding lost). There is ONE recovery path —
670
+ # rebind through portal attach-credentials, i.e. a plain re-attach — so
671
+ # surface a typed TokenRejectedError rather than a generic auth error.
672
+ # Deliberately NOT an automatic rebind: a rebind always lands on a FRESH
673
+ # runtime (each runtime binds once), invalidating every session/ps_ the
674
+ # caller holds, so the re-attach must be the caller's explicit move.
675
+ # The data-plane client already classifies its own responses by token
676
+ # (a ps_ 401 stays UnauthorizedError); this maps a 401 that a custom
677
+ # coordinator raises for the ct_ the block used.
678
+ def rebind_on_unauthorized
679
+ yield
680
+ rescue PineSandbox::UnauthorizedError => e
681
+ raise unless e.status.to_i == 401
682
+
683
+ raise TokenRejectedError.for_api_error(e)
684
+ end
685
+
686
+ private
687
+
688
+ def decide_passkey_ceremony(ceremony_id, decision)
689
+ raise ArgumentError, "ceremony_id is required" if ceremony_id.to_s.empty?
690
+
691
+ rebind_on_unauthorized do
692
+ coordinator!.decide_passkey_ceremony(
693
+ token: computer_token, ceremony_id: ceremony_id, decision: decision
694
+ )
695
+ end
696
+ end
697
+
698
+ # The bound coordinator, or a clear error if powered-off. Keeps every
699
+ # drive method from NoMethodError-ing on a nil coordinator.
700
+ def coordinator!
701
+ @coordinator || raise(BindError.new("Computer is not attached — call #attach first"))
702
+ end
703
+
704
+ # Ungraceful tear-down: best-effort kill (no wait) + drop the local binding.
705
+ # Used at attach-entry (re-attach), on bind failure, and by #kill. The nils
706
+ # always run so the SDK object never holds a half-binding. (#stop uses the
707
+ # graceful Sandbox#terminate instead, then clear_bound_state.)
708
+ def reset_bound_state
709
+ begin
710
+ @sandbox&.kill
711
+ rescue StandardError
712
+ nil
713
+ end
714
+ clear_bound_state
715
+ end
716
+
717
+ # Drop the local binding WITHOUT touching the pod — used after a graceful
718
+ # #stop has already terminated it (so we don't double-DELETE).
719
+ def clear_bound_state
720
+ @sandbox = nil
721
+ @coordinator = nil
722
+ @computer_token = nil
723
+ @computer_token_expires_at = nil
724
+ @persistence_mode = nil
725
+ @bind_envelope = nil
726
+ @snapshot = nil
727
+ end
728
+
729
+ # The caller-owned part of a Computer create. Runtime selection belongs to
730
+ # the project policy and is never serialized by this SDK.
731
+ def build_computer_create_body(timeout:, metadata:)
732
+ body = { timeout: timeout }
733
+ body[:metadata] = metadata if metadata
734
+ body
735
+ end
736
+
737
+ # Client bind sequence (AUTH_REDESIGN attach flow): fetch pubkey → call
738
+ # the portal attach-credentials provider with the pod coordinates →
739
+ # HPKE-seal {key, broker_grant} → POST the portal bind_token (no
740
+ # client-side signing, no hash attestations) → store ct_ + expires_at.
741
+ #
742
+ # Re-runnable, with one load-bearing rule: a CONVERGENCE retry (lost
743
+ # response, transient transport, bind-in-progress) re-POSTs the SAME
744
+ # minted envelope byte-for-byte. coord keys bind idempotency on
745
+ # (jti, ciphertext) — re-sending the identical pair after a committed
746
+ # bind replays the cached ct_, while a fresh mint (new jti) on a bound
747
+ # pod is a terminal 409. Portal authorizes a sandbox exactly once, so
748
+ # this method never re-mints after an envelope has been committed.
749
+ # Bounds the two-round restore's challenge-answer loop client-side.
750
+ # Coord's own generation cap (4) refuses first in any legitimate run;
751
+ # this is the local backstop against a server challenging forever.
752
+ MAX_RESTORE_ROUNDS = 6
753
+
754
+ def run_bind(config, requested_location:)
755
+ envelope = @bind_envelope ||= mint_bind_envelope(
756
+ config, requested_location: requested_location
757
+ )
758
+ @bind_envelope = envelope
759
+
760
+ restore_secrets = envelope[:restore_secrets]
761
+ rounds = 0
762
+ loop do
763
+ bind_kwargs = {
764
+ bind_token: envelope.fetch(:bind_token), pod_uid: envelope.fetch(:pod_uid),
765
+ coord_boot_id: envelope.fetch(:coord_boot_id), ciphertext: envelope.fetch(:ciphertext)
766
+ }
767
+ bind_kwargs[:key_assertion] = envelope[:key_assertion] if envelope[:key_assertion]
768
+ bind_kwargs[:restore_secrets] = restore_secrets if restore_secrets
769
+ response = @coordinator.bind(**bind_kwargs)
770
+ challenge = response["restore_challenge"]
771
+ unless challenge
772
+ @bind_envelope = nil
773
+ @computer_token = response.fetch("computer_token")
774
+ @computer_token_expires_at = response["expires_at"]
775
+ @persistence_mode = response["persistence_mode"]
776
+ return
777
+ end
778
+ # The two-round v3 restore: unwrap each component's secret with the
779
+ # matching keypair generation and re-send the SAME envelope with the
780
+ # sealed answer. Every resumable outcome (initial challenge,
781
+ # rechallenge after latest moved, a superseded answer) is another
782
+ # challenge on this same loop. The built answer is held ON the
783
+ # envelope so a convergence retry after a transport fault re-sends
784
+ # it verbatim (idempotent replay).
785
+ rounds += 1
786
+ raise BindError.new("bind restore did not converge after #{MAX_RESTORE_ROUNDS} challenges") if rounds > MAX_RESTORE_ROUNDS
787
+
788
+ restore_secrets = answer_restore_challenge(envelope, challenge)
789
+ envelope[:restore_secrets] = restore_secrets
790
+ end
791
+ end
792
+
793
+ # Unwrap each challenged component's secret and seal the set to the
794
+ # pod's bind key under the RESTORE domain. Failures are terminal — a
795
+ # missing superseded generation or corrupt material never self-heals.
796
+ def answer_restore_challenge(envelope, challenge)
797
+ secrets = {}
798
+ challenge.fetch("components").each do |comp|
799
+ generation = comp.fetch("recipient_key_version")
800
+ keypair = @capture_keypairs[generation]
801
+ unless keypair
802
+ raise BindError.new(
803
+ "restore needs capture keypair generation #{generation} for component #{comp["component"]} — " \
804
+ "register the superseded keypair (add_prior_capture_keypair) or its state cannot be restored"
805
+ )
806
+ end
807
+ if keypair.fingerprint != comp.fetch("pk_fingerprint")
808
+ raise BindError.new(
809
+ "restore challenge for component #{comp["component"]} names fingerprint #{comp["pk_fingerprint"]} " \
810
+ "but keypair generation #{generation} has #{keypair.fingerprint} — wrong keypair registered"
811
+ )
812
+ end
813
+ begin
814
+ s = StateHPKE.open_component_secret(
815
+ keypair.sk,
816
+ Base64.urlsafe_decode64(comp.fetch("hpke_enc")),
817
+ Base64.urlsafe_decode64(comp.fetch("hpke_sealed_s")),
818
+ cid: @id, component: comp.fetch("component"), id: comp.fetch("id"),
819
+ attach_epoch: comp.fetch("attach_epoch"), key_version: generation,
820
+ fingerprint: comp.fetch("pk_fingerprint")
821
+ )
822
+ rescue KeyError
823
+ raise
824
+ rescue StandardError => e
825
+ raise BindError.new(
826
+ "restore challenge for component #{comp["component"]} could not be unwrapped " \
827
+ "with capture keypair generation #{generation}",
828
+ cause: e
829
+ )
830
+ end
831
+ secrets[comp.fetch("component")] = Base64.urlsafe_encode64(s, padding: false)
832
+ rescue KeyError => e
833
+ raise BindError.new("restore challenge malformed: #{e.message}")
834
+ end
835
+ plaintext = JSON.generate(challenge_id: challenge.fetch("challenge_id"), secrets: secrets)
836
+ sealed = BindHPKE.seal(
837
+ Base64.urlsafe_decode64(envelope.fetch(:ephem_pub)), plaintext,
838
+ BindHPKE.restore_info(envelope.fetch(:pod_uid), envelope.fetch(:coord_boot_id))
839
+ )
840
+ Base64.urlsafe_encode64(sealed, padding: false)
841
+ end
842
+
843
+ # Fetch the pod's bind identity, mint single-use portal attach
844
+ # credentials for it, and HPKE-seal the payload — everything a bind
845
+ # POST needs, held together so retries can re-send it verbatim.
846
+ def mint_bind_envelope(config, requested_location:)
847
+ pubkey = @coordinator.bind_pubkey
848
+ ephem_pub = Base64.urlsafe_decode64(pubkey.fetch("ephem_pub_x25519"))
849
+ pod_uid = pubkey.fetch("pod_uid")
850
+ coord_boot_id = pubkey.fetch("coord_boot_id")
851
+
852
+ # sandbox_id rides along so the portal can attribute the sandbox to
853
+ # this computer/project at the ONE moment it learns the pairing — the
854
+ # renew-relay revocation gate keys off that attribution.
855
+ mint_kwargs = {
856
+ computer_id: @id, pod_uid: pod_uid, coord_boot_id: coord_boot_id,
857
+ sandbox_id: @sandbox.id,
858
+ expected_binding_revision: @binding_revision,
859
+ idempotency_key: attach_idempotency_key(@sandbox.id, pod_uid, coord_boot_id)
860
+ }
861
+ mint_kwargs[:location] = requested_location if requested_location
862
+ # A persistent attach submits the capture public key so the portal advances
863
+ # its per-Computer key floor and returns a signed key_assertion. An
864
+ # ephemeral Computer has no capture keypair and persists nothing, so both
865
+ # pk_computer and key_generation are omitted.
866
+ unless @ephemeral
867
+ current = @capture_keypairs.fetch(@capture_generation)
868
+ mint_kwargs[:pk_computer] = Base64.urlsafe_encode64(current.pk, padding: false)
869
+ mint_kwargs[:key_generation] = current.generation
870
+ end
871
+ creds = config.attach_provider.credentials(**mint_kwargs)
872
+ committed_revision = Integer(creds.fetch("binding_revision"))
873
+ unless committed_revision > @binding_revision
874
+ raise AttachCredentialsError.new(
875
+ "attach_credentials_provider returned a non-advancing binding_revision"
876
+ )
877
+ end
878
+ # Record the committed CAS winner before validating the rest of the
879
+ # provider response. If an in-process/custom issuer returns an incomplete
880
+ # receipt, the caller still needs this revision to reconcile safely.
881
+ @binding_revision = committed_revision
882
+ @last_authorized_runtime_id = @sandbox.id
883
+ if creds.key?("location")
884
+ begin
885
+ committed_location = normalize_location(creds.fetch("location"))
886
+ rescue ArgumentError => e
887
+ raise AttachCredentialsError.new(
888
+ "attach_credentials_provider returned an invalid location: #{e.message}"
889
+ )
890
+ end
891
+ if requested_location && committed_location != requested_location
892
+ raise AttachCredentialsError.new(
893
+ "attach_credentials_provider returned a location different from the requested Computer location"
894
+ )
895
+ end
896
+ @location = committed_location
897
+ end
898
+ # key_assertion accompanies the capture public key on a persistent attach;
899
+ # an ephemeral attach neither submits pk_computer nor requires it back.
900
+ if creds["bind_token"].to_s.empty? || creds["broker_grant"].to_s.empty? ||
901
+ (!@ephemeral && creds["key_assertion"].to_s.empty?)
902
+ raise AttachCredentialsError.new(
903
+ "attach_credentials_provider result missing bind_token/broker_grant/key_assertion"
904
+ )
905
+ end
906
+ # The HPKE-sealed bind payload. A persistent Computer ships its state key
907
+ # (current + optional prior-for-restore) alongside the broker grant; an
908
+ # ephemeral Computer seals ONLY the broker grant — it holds no state to
909
+ # decrypt. broker_grant is always present.
910
+ #
911
+ # Only integrator-custodied material belongs here. Platform-internal
912
+ # credentials — the coordinator's accounting grant among them — are
913
+ # obtained by coord over its own internal rail, because routing one
914
+ # through this payload made metering depend on the SDK version an
915
+ # integrator had pinned.
916
+ plaintext =
917
+ if @ephemeral
918
+ JSON.generate(broker_grant: creds.fetch("broker_grant"))
919
+ else
920
+ restore_version, restore_bytes = @prior_keys.max_by { |version, _| version }
921
+ JSON.generate(
922
+ computer_key_current: wire_key(CURRENT_KEY_VERSION, @key),
923
+ computer_key_for_restore: restore_bytes ? wire_key(restore_version, restore_bytes) : nil,
924
+ broker_grant: creds.fetch("broker_grant")
925
+ )
926
+ end
927
+ ciphertext = BindHPKE.seal(ephem_pub, plaintext, BindHPKE.info(pod_uid, coord_boot_id))
928
+
929
+ {
930
+ bind_token: creds.fetch("bind_token"),
931
+ pod_uid: pod_uid,
932
+ coord_boot_id: coord_boot_id,
933
+ ciphertext: Base64.urlsafe_encode64(ciphertext, padding: false),
934
+ key_assertion: @ephemeral ? nil : creds["key_assertion"],
935
+ ephem_pub: pubkey.fetch("ephem_pub_x25519")
936
+ }
937
+ end
938
+
939
+ def attach_idempotency_key(sandbox_id, pod_uid, coord_boot_id)
940
+ material = [ @id, sandbox_id, pod_uid, coord_boot_id ].join("\0")
941
+ "attach-#{Digest::SHA256.hexdigest(material)}"
942
+ end
943
+
944
+ def wire_key(version, bytes)
945
+ {version: version, bytes: Base64.urlsafe_encode64(bytes, padding: false)}
946
+ end
947
+
948
+ def normalize_location(value)
949
+ unless value.is_a?(Hash)
950
+ raise ArgumentError, "location must be a Hash with country: or mode: \"direct\""
951
+ end
952
+ normalized = value.transform_keys(&:to_s)
953
+ return { mode: "direct" }.freeze if normalized == { "mode" => "direct" }
954
+ unless normalized.keys == [ "country" ] &&
955
+ normalized["country"].is_a?(String) &&
956
+ normalized["country"].match?(/\A[A-Z]{2}\z/)
957
+ raise ArgumentError, "location must contain an ISO alpha-2 country or mode: \"direct\", exclusively"
958
+ end
959
+ { country: normalized.fetch("country") }.freeze
960
+ end
961
+
962
+ def restore_no_challenge?(err)
963
+ problem = err.respond_to?(:problem_type) ? err.problem_type : nil
964
+ problem == "/errors/bind-restore-no-challenge"
965
+ end
966
+
967
+ # A broker/restore 5xx can converge by replaying the SAME committed
968
+ # envelope. An epoch conflict is terminal because another pod won.
969
+ def transient_bind_restore?(err)
970
+ problem = err.respond_to?(:problem_type) ? err.problem_type : nil
971
+ status = err.respond_to?(:status) ? err.status.to_i : 0
972
+ problem == "/errors/bind-restore-failed" && status >= 500 && !err.message.to_s.include?("epoch")
973
+ end
974
+
975
+ # The just-allocated runtime isn't routable through the gateway YET —
976
+ # NOT a bind rejection. These are the not-ready signals (all clear as the
977
+ # runtime starts serving, so retry within the attach deadline):
978
+ # 404 — not yet routable (brief post-create window);
979
+ # 503 — routable but not yet ready;
980
+ # 409 /errors/bind-in-progress — an in-flight bind settling.
981
+ # The gateway now types its two routing decisions. They remain readiness
982
+ # signals ONLY inside this attach deadline; on ordinary data-plane calls a
983
+ # sandbox-not-found response means the bound sandbox is gone. Other typed
984
+ # coord failures (bind-not-configured, epoch-conflict, pod-tainted, auth)
985
+ # are bind verdicts and fall through to transient_bind_restore?/
986
+ # map_bind_error. Plain 404/503 remain compatible with older gateways.
987
+ def coord_not_ready_via_ingress?(err)
988
+ return true if bind_in_progress?(err)
989
+ return false unless err.respond_to?(:status)
990
+
991
+ problem = err.respond_to?(:problem_type) ? err.problem_type : nil
992
+ return true if [
993
+ "/errors/sandbox-not-found",
994
+ "/errors/sandbox-not-ready",
995
+ "/errors/egress-activation-failed"
996
+ ].include?(problem)
997
+ return false unless problem.nil?
998
+
999
+ status = err.status.to_i
1000
+ status == 404 || status == 503
1001
+ end
1002
+
1003
+ # coord's bindGuard is mid-flight completing a bind for this pod (a prior
1004
+ # attempt of THIS attach whose response we lost, or a racing caller); coord
1005
+ # returns 409 + Retry-After. It settles once the in-flight bind/restore
1006
+ # finishes — which can outlast a few attempts — so it rides the attach
1007
+ # DEADLINE, not max_bind_attempts. Re-running run_bind is safe: the guard
1008
+ # serializes binds per runtime and a settled bind answers consistently.
1009
+ def bind_in_progress?(err)
1010
+ (err.respond_to?(:problem_type) ? err.problem_type : nil) == "/errors/bind-in-progress"
1011
+ end
1012
+
1013
+ # Monotonic seconds — immune to wall-clock jumps during a long attach.
1014
+ def monotonic_now
1015
+ (@clock || PineSandbox::SYSTEM_CLOCK).monotonic
1016
+ end
1017
+
1018
+ # Every attach wait goes through the connection's clock.
1019
+ def sleep(seconds)
1020
+ (@clock || PineSandbox::SYSTEM_CLOCK).sleep(seconds)
1021
+ end
1022
+
1023
+ # Capped linear backoff (0.5s..3s) for the convergence wait, so a slow
1024
+ # cold pod is polled steadily without hammering the ingress.
1025
+ def bind_convergence_backoff(wait_count)
1026
+ [0.5 * wait_count, 3.0].min
1027
+ end
1028
+
1029
+ # Maps a terminal bind failure to the SDK's typed bind errors. Prefer the
1030
+ # RFC 9457 problem-type slug (coord sets it on the
1031
+ # problem+json body); fall back to the HTTP status tier for older coords
1032
+ # or client-side faults (malformed-request / hpke-decrypt-failed / etc).
1033
+ def map_bind_error(err)
1034
+ status = err.respond_to?(:status) ? err.status : nil
1035
+ problem = err.respond_to?(:problem_type) ? err.problem_type : nil
1036
+ message = err.message.to_s
1037
+
1038
+ case problem
1039
+ when "/errors/bind-rejected"
1040
+ BindAuthError.new("the Computer rejected the bind_token: #{message}", status: status, cause: err)
1041
+ when "/errors/bind-not-configured"
1042
+ BindError.new("this runtime cannot be bound: #{message}", status: status, cause: err)
1043
+ when "/errors/bind-lease-unenforceable"
1044
+ # Structural deployment misconfiguration (lease relay unconfigured on
1045
+ # a portal-trusting pod) — a bind retry cannot fix it, and it must
1046
+ # not masquerade as a broker outage.
1047
+ BindError.new("this runtime cannot enforce the access lease: #{message}", status: status, cause: err)
1048
+ when "/errors/already-bound-different-cid", "/errors/pod-tainted"
1049
+ # Terminal: a prior bind tainted this pod (bound to another Computer,
1050
+ # or failed after restore). The pod is single-use, so recovery is a
1051
+ # fresh #attach on a fresh pod — never a same-pod replay.
1052
+ RuntimeUnusableError.new("this runtime cannot be bound again; attach a fresh one (#{problem}): #{message}", status: status, cause: err)
1053
+ when "/errors/bind-restore-failed"
1054
+ if message.include?("epoch")
1055
+ ComputerAlreadyAttachedError.new("another runtime holds this Computer's epoch lease: #{message}", status: status, cause: err)
1056
+ else
1057
+ SavedStateUnavailableError.new("restoring saved state failed during bind: #{message}", status: status, cause: err)
1058
+ end
1059
+ else
1060
+ case status
1061
+ when 401, 403
1062
+ BindAuthError.new("the Computer rejected the bind_token (#{status}): #{message}", status: status, cause: err)
1063
+ when 409
1064
+ BindError.new("bind rejected (409): #{message}", status: status, cause: err)
1065
+ when 503
1066
+ SavedStateUnavailableError.new("saved state was unreachable during bind: #{message}", status: status, cause: err)
1067
+ else
1068
+ BindError.new("bind failed (status=#{status || 'n/a'}): #{message}", status: status, cause: err)
1069
+ end
1070
+ end
1071
+ end
1072
+ end
1073
+ end
1074
+
1075
+ require_relative "computer_credentials"