pinesandbox 0.0.1 → 0.3.19

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -0
  3. data/LICENSE +201 -0
  4. data/README.md +670 -4
  5. data/Rakefile +5 -0
  6. data/lib/pinesandbox/adapters/coordinator_adapter.rb +1399 -0
  7. data/lib/pinesandbox/adapters/snapshot_adapter.rb +37 -0
  8. data/lib/pinesandbox/agent_events.rb +213 -0
  9. data/lib/pinesandbox/agent_mode.rb +252 -0
  10. data/lib/pinesandbox/agent_turn.rb +97 -0
  11. data/lib/pinesandbox/attach_credentials_source.rb +392 -0
  12. data/lib/pinesandbox/author_terminal_translator.rb +96 -0
  13. data/lib/pinesandbox/bind_hpke.rb +54 -0
  14. data/lib/pinesandbox/capture_keypair.rb +43 -0
  15. data/lib/pinesandbox/client.rb +469 -0
  16. data/lib/pinesandbox/clock.rb +51 -0
  17. data/lib/pinesandbox/computer.rb +1075 -0
  18. data/lib/pinesandbox/computer_binding.rb +51 -0
  19. data/lib/pinesandbox/computer_credentials.rb +175 -0
  20. data/lib/pinesandbox/connection.rb +26 -0
  21. data/lib/pinesandbox/control_events.rb +156 -0
  22. data/lib/pinesandbox/control_plane.rb +341 -0
  23. data/lib/pinesandbox/control_state.rb +31 -0
  24. data/lib/pinesandbox/control_token_source.rb +232 -0
  25. data/lib/pinesandbox/delegated_connection.rb +46 -0
  26. data/lib/pinesandbox/diagnostics.rb +83 -0
  27. data/lib/pinesandbox/drive_mode.rb +67 -0
  28. data/lib/pinesandbox/error_taxonomy.rb +105 -0
  29. data/lib/pinesandbox/errors.rb +505 -0
  30. data/lib/pinesandbox/exec_stream_parser.rb +90 -0
  31. data/lib/pinesandbox/models/artifact.rb +51 -0
  32. data/lib/pinesandbox/models/computer_session.rb +57 -0
  33. data/lib/pinesandbox/models/computer_tab.rb +30 -0
  34. data/lib/pinesandbox/models/file_entry.rb +38 -0
  35. data/lib/pinesandbox/operations.rb +131 -0
  36. data/lib/pinesandbox/response_handler.rb +88 -0
  37. data/lib/pinesandbox/sandbox_details.rb +25 -0
  38. data/lib/pinesandbox/sandbox_handle.rb +247 -0
  39. data/lib/pinesandbox/session.rb +647 -0
  40. data/lib/pinesandbox/skills.rb +142 -0
  41. data/lib/pinesandbox/spec_version_middleware.rb +34 -0
  42. data/lib/pinesandbox/sse_frame_parser.rb +95 -0
  43. data/lib/pinesandbox/state_hpke.rb +57 -0
  44. data/lib/pinesandbox/version.rb +3 -0
  45. data/lib/pinesandbox/zone.rb +173 -0
  46. data/lib/pinesandbox.rb +40 -2
  47. data/pinesandbox.gemspec +49 -0
  48. metadata +157 -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 (started and attached). `attach` starts it, runs the
17
+ # bind/restore handshake so the Computer resumes its prior state, and
18
+ # returns `self`; you then drive it through `create_session`. `stop` saves
19
+ # supported state and shuts the Computer down, and you can `attach` again
20
+ # later to start it with state restored.
21
+ #
22
+ # computer = PineSandbox::Computer.new("0190…", key: secret_32_bytes)
23
+ # computer.attach(connection)
24
+ # session = computer.create_session(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 (sandbox
34
+ # id + 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
+ # How long to wait for the Computer to start; independent of its idle timeout.
39
+ DEFAULT_READY_TIMEOUT = 300
40
+ # Wall-clock budget for a just-started Computer to become bindable. Spans
41
+ # the gap between the lifecycle reporting "running" and the Computer
42
+ # actually serving; the bind handshake retries not-ready responses (404 /
43
+ # 503 / transient transport errors) within it. Generous — a healthy
44
+ # Computer 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-started Computer 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_sandbox_id, :location
60
+
61
+ # The id of the Computer sandbox this Computer is attached to (the API's
62
+ # `sandbox_id`); it changes on every start.
63
+ # nil when it isn't attached.
64
+ def sandbox_id = @sandbox&.id
65
+
66
+ # @api private — internal plumbing, NOT a supported end-user surface. It is
67
+ # public only because `Session` reaches it via `parent.coordinator` (Ruby
68
+ # needs a public accessor for that cross-object call) and the wire-level
69
+ # OpenAPI conformance test drives raw routes through it. Use the typed
70
+ # Session (ps_) / Computer (ct_) methods, which attach the right token.
71
+ # Exposing it is NOT a privilege bypass: the Computer authorizes every
72
+ # route by token class (`/exec` is ps_-only; ps_ is bound to its own
73
+ # session), so a raw call here can only do what the caller's own token
74
+ # already permits.
75
+ attr_reader :coordinator
76
+
77
+ # A fresh Computer identity: a UUIDv7 `id` + a 32-byte state `key` — the SAME
78
+ # pair create_computer generates internally, exposed as the SDK's built-in
79
+ # generator so a client backend can mint + PERSIST them up front (crash-safe,
80
+ # and the required id format is guaranteed) and pass them into
81
+ # create_computer. The SDK never stores them; the integrator owns both (the
82
+ # `id` to address/adopt the Computer, the `key` to decrypt restored state).
83
+ # `ephemeral: true` mints an access-lease-only identity — no capture
84
+ # keypair, since an ephemeral Computer persists nothing.
85
+ def self.generate_credentials(ephemeral: false)
86
+ Credentials.new(
87
+ id: SecureRandom.uuid_v7,
88
+ key: SecureRandom.random_bytes(32),
89
+ capture_keypair: ephemeral ? nil : CaptureKeypair.generate(generation: 1),
90
+ ephemeral: ephemeral
91
+ )
92
+ end
93
+
94
+ # @param id [String] lowercase UUIDv7 — a non-secret, stable handle.
95
+ # @param key [String] 32 raw bytes, the per-Computer secret (REQUIRED).
96
+ # @param ephemeral [Boolean] when true the Computer binds an access lease
97
+ # ONLY — no state persistence: it submits no capture public key, seals no
98
+ # computer_key into the bind payload, carries no key_assertion, and #stop
99
+ # skips the pre-stop checkpoint. Default false is the persistent behavior.
100
+ def initialize(id, key:, binding_revision: 0, ephemeral: false)
101
+ raise ArgumentError, "id required" if id.to_s.empty?
102
+ raise ArgumentError, "id must be a lowercase UUIDv7" unless id.to_s.match?(UUID_V7)
103
+ raise ArgumentError, "key must be 32 raw bytes" unless key.is_a?(String) && key.bytesize == 32
104
+ unless binding_revision.is_a?(Integer) && binding_revision >= 0
105
+ raise ArgumentError, "binding_revision must be a non-negative integer"
106
+ end
107
+
108
+ @id = id.to_s
109
+ @key = key
110
+ @ephemeral = ephemeral ? true : false
111
+ @prior_keys = {} # version(Integer) => 32 raw bytes; rotation only
112
+ @capture_keypairs = {} # generation(Integer) => CaptureKeypair; asymmetric envelope v3
113
+ @capture_generation = 0
114
+ @binding_revision = binding_revision
115
+ reset_bound_state
116
+ end
117
+
118
+ # Register a prior key version so restore can decrypt a snapshot captured
119
+ # before a key rotation. Opt-in; steady state never calls
120
+ # it. Returns self for chaining.
121
+ def add_prior_key(version, bytes)
122
+ raise ArgumentError, "version must be an Integer" unless version.is_a?(Integer)
123
+ raise ArgumentError, "version must differ from the current key version" if version == CURRENT_KEY_VERSION
124
+ raise ArgumentError, "bytes must be 32 raw bytes" unless bytes.is_a?(String) && bytes.bytesize == 32
125
+
126
+ @prior_keys[version] = bytes
127
+ self
128
+ end
129
+
130
+ # Opt this Computer into asymmetric state encryption (component envelope v3):
131
+ # keypair.pk is submitted at the next attach, the platform seals every
132
+ # capture to it, and the bind runs the two-round restore (this SDK
133
+ # unwraps the per-component secrets with keypair.sk). Becomes the
134
+ # CURRENT generation; previously set generations stay registered for
135
+ # restores of pre-rotation state. Returns self for chaining.
136
+ def set_capture_keypair(keypair)
137
+ raise ArgumentError, "keypair must be a PineSandbox::CaptureKeypair" unless keypair.is_a?(CaptureKeypair)
138
+ if keypair.generation < @capture_generation
139
+ raise ArgumentError, "capture keypair generation #{keypair.generation} below the current generation #{@capture_generation} (rotations only move forward)"
140
+ end
141
+ existing = @capture_keypairs[keypair.generation]
142
+ if existing && (existing.pk != keypair.pk || existing.sk != keypair.sk)
143
+ raise ArgumentError, "capture keypair generation #{keypair.generation} is already registered with different key material"
144
+ end
145
+
146
+ @capture_keypairs[keypair.generation] = keypair
147
+ @capture_generation = keypair.generation
148
+ self
149
+ end
150
+
151
+ def capture_keypair
152
+ @capture_keypairs[@capture_generation]
153
+ end
154
+
155
+ # Caller-owned credentials, independent of this live handle. Save them before
156
+ # Create, and again only if you change keys. Start reads the binding itself.
157
+ def credentials
158
+ current = capture_keypair
159
+ Credentials.new(
160
+ id: @id.dup, key: @key.dup, ephemeral: @ephemeral,
161
+ capture_keypair: current&.dup,
162
+ prior_capture_keypairs: @capture_keypairs.values.reject { |pair| pair.equal?(current) }.map(&:dup),
163
+ prior_keys: @prior_keys.to_h { |version, bytes| [version, bytes.dup] }
164
+ )
165
+ end
166
+
167
+ # Register a SUPERSEDED capture keypair so a restore of state sealed
168
+ # under it can still unwrap its secrets. Never changes the current
169
+ # generation. Returns self for chaining.
170
+ def add_prior_capture_keypair(keypair)
171
+ raise ArgumentError, "keypair must be a PineSandbox::CaptureKeypair" unless keypair.is_a?(CaptureKeypair)
172
+ if @capture_generation.positive? && keypair.generation >= @capture_generation
173
+ raise ArgumentError, "prior capture keypair generation #{keypair.generation} must be below the current generation #{@capture_generation}"
174
+ end
175
+ existing = @capture_keypairs[keypair.generation]
176
+ if existing && (existing.pk != keypair.pk || existing.sk != keypair.sk)
177
+ raise ArgumentError, "capture keypair generation #{keypair.generation} is already registered with different key material"
178
+ end
179
+
180
+ @capture_keypairs[keypair.generation] = keypair
181
+ self
182
+ end
183
+
184
+ # Start this Computer and bind to it, restoring its state.
185
+ # Returns self (now powered-on). Safe across its internal retries —
186
+ # convergence retries re-send the SAME minted envelope, so a
187
+ # committed-but-lost bind replays idempotently. A change of pod_uid or
188
+ # coord_boot_id is terminal because Portal has already committed the one
189
+ # authorization allowed for this Computer sandbox. Raises if this Computer is already
190
+ # attached (call #stop or #kill first — re-attaching live must not
191
+ # silently discard state); on any bind failure the SDK shuts the
192
+ # just-started Computer down and leaves it powered-off before a typed error.
193
+ #
194
+ # @param config [PineSandbox::Connection]
195
+ # timeout is how long the Computer keeps running without use. ready_timeout
196
+ # bounds how long it takes to start; bind_ready_timeout separately bounds the
197
+ # bind/restore handshake.
198
+ # @param pod_env [nil, Hash] Deprecated; only nil or an empty hash is accepted.
199
+ def attach(config, timeout: 300, ready_timeout: DEFAULT_READY_TIMEOUT, pod_env: nil, metadata: nil,
200
+ location: nil,
201
+ max_bind_attempts: DEFAULT_MAX_BIND_ATTEMPTS,
202
+ bind_ready_timeout: DEFAULT_BIND_READY_TIMEOUT)
203
+ unless config.respond_to?(:attach_provider) && config.respond_to?(:control_plane) && config.respond_to?(:zone)
204
+ raise ArgumentError, "config must be a PineSandbox::Connection (got #{config.class})"
205
+ end
206
+ unless max_bind_attempts.is_a?(Integer) && max_bind_attempts.positive?
207
+ raise ArgumentError, "max_bind_attempts must be a positive integer"
208
+ end
209
+ unless pod_env.nil? || pod_env == {}
210
+ raise ArgumentError, "pod_env is unsupported for Computer attach; omit it or pass an empty hash"
211
+ end
212
+ unless (ready_timeout.is_a?(Integer) || ready_timeout.is_a?(Float)) && ready_timeout.finite? && ready_timeout.positive?
213
+ raise ArgumentError, "ready_timeout must be a positive finite number of seconds"
214
+ end
215
+
216
+ # Refuse to re-attach a live Computer: silently tearing down the prior pod
217
+ # here would force-kill it (ungraceful) and lose state since the last
218
+ # checkpoint. Make the teardown an explicit caller choice — #stop (graceful,
219
+ # persist) or #kill (discard) — then attach again.
220
+ raise BindError.new("Computer already attached — call #stop (graceful) or #kill first") if @sandbox
221
+ # A persistent attach requires the capture keypair (it seals every capture
222
+ # to it); an ephemeral Computer persists nothing, so it binds without one.
223
+ if @capture_generation.zero? && !@ephemeral
224
+ raise ArgumentError, "v3 attach requires a capture_keypair (persist both halves)"
225
+ end
226
+ # Preserve omission as omission. @location is only the last Portal
227
+ # receipt, not authority to overwrite a newer country another client may
228
+ # have committed after this instance stopped.
229
+ requested_location = normalize_location(location) if location
230
+ @last_authorized_sandbox_id = nil
231
+ @connection = config
232
+ @clock = config.respond_to?(:clock) ? config.clock : PineSandbox::SYSTEM_CLOCK
233
+
234
+ # Provision via the project-scoped control plane (api.<zone>, project JWS).
235
+ create_id = SecureRandom.uuid
236
+ ready_deadline = monotonic_now + ready_timeout
237
+ info = config.control_plane.create_computer(
238
+ body: build_computer_create_body(timeout: timeout, metadata: metadata).merge(create_id: create_id),
239
+ request_timeout: ready_timeout
240
+ )
241
+ @sandbox = SandboxHandle.new(control_plane: config.control_plane, sandbox_id: info.id, status: info.status)
242
+ # Wait for the pod to report Running (a CUA pod's readiness; the bind handshake
243
+ # below is the functional coord-readiness check). A pod that fails or never
244
+ # becomes Ready is destroyed before the typed error leaves #attach.
245
+ begin
246
+ @sandbox.wait_until_running(timeout: [ready_deadline - monotonic_now, 0].max)
247
+ rescue StandardError
248
+ reset_bound_state
249
+ raise
250
+ end
251
+ # Drive the Computer at its gateway host `https://<sandbox_id>.computer.<zone>`
252
+ # — port-less, header-less, role-by-path; coord gates every route on the bearer.
253
+ @coordinator = Adapters::CoordinatorAdapter.new(sandbox_id: info.id, zone: config.zone, clock: @clock)
254
+
255
+ # Two retry budgets converge on the bind handshake:
256
+ # - readiness (DEADLINE-bounded): the Computer sandbox was just started, so the
257
+ # gateway can't reach a serving Computer yet — 404 (not yet routable),
258
+ # 503 (not yet ready), a transient transport error, or an in-flight
259
+ # bind settling (409 bind-in-progress). All clear as the Computer
260
+ # starts, so retry until a wall-clock DEADLINE — a cold start or a
261
+ # slow restore can outlast a few tries, and attempt-counting gives up early.
262
+ # - restore restart (ATTEMPT-bounded): if coord's short-lived two-round
263
+ # challenge expires, clear only restore_secrets and restart round one
264
+ # with the same committed token/JTI/ciphertext. No Portal re-mint.
265
+ # Terminal bind errors (pod identity shift, epoch conflict, pod-tainted, auth, coord's own
266
+ # problem+json 503 bind-not-configured) never retry: the pod is
267
+ # single-use, so recovery is a fresh #attach on a fresh pod.
268
+ deadline = monotonic_now + bind_ready_timeout
269
+ restore_attempts = 1
270
+ converge_waits = 0
271
+ begin
272
+ raise BindTimeoutError.new("Computer did not become bindable within its bind budget") if monotonic_now >= deadline
273
+ run_bind(config, requested_location: requested_location)
274
+ rescue PineSandbox::ApiError => e
275
+ if coord_not_ready_via_ingress?(e) || transient_bind_restore?(e)
276
+ if monotonic_now < deadline
277
+ converge_waits += 1
278
+ sleep(bind_convergence_backoff(converge_waits))
279
+ retry
280
+ end
281
+ reset_bound_state
282
+ raise BindTimeoutError.new("Computer did not become bindable within #{bind_ready_timeout}s (last: HTTP #{e.status}): #{e.message}", cause: e)
283
+ elsif restore_no_challenge?(e) && restore_attempts < max_bind_attempts
284
+ # Only coord's short-lived restore challenge expired. Reuse the one
285
+ # Portal authorization committed for this sandbox and restart round one.
286
+ restore_attempts += 1
287
+ @bind_envelope&.delete(:restore_secrets)
288
+ sleep(0.5 * (restore_attempts - 1))
289
+ retry
290
+ end
291
+ reset_bound_state
292
+ raise map_bind_error(e)
293
+ rescue *TRANSIENT_BIND_ERRORS => e
294
+ # A just-created pod's coord may not be listening yet (connection
295
+ # refused / TLS not up) — the same convergence window as 404/503,
296
+ # so bound by the same deadline rather than a few attempts.
297
+ if monotonic_now < deadline
298
+ converge_waits += 1
299
+ sleep(bind_convergence_backoff(converge_waits))
300
+ retry
301
+ end
302
+ reset_bound_state
303
+ raise BindTimeoutError.new("bind handshake did not complete within #{bind_ready_timeout}s: #{e.class}: #{e.message}", cause: e)
304
+ rescue StandardError
305
+ reset_bound_state
306
+ raise
307
+ end
308
+
309
+ self
310
+ end
311
+
312
+ # attach + auto-stop. Yields self; stops the Computer (saving supported
313
+ # state) when the block returns or raises.
314
+ def with_attached(config, **opts)
315
+ PineSandbox.deprecated_name("Computer#with_attached", "Client#with_computer")
316
+ attach(config, **opts)
317
+ yield self
318
+ ensure
319
+ # Graceful by default: persist state on the way out (no-op if attach
320
+ # already cleaned up after a bind failure — @sandbox is nil then).
321
+ stop
322
+ end
323
+
324
+ # Adopt an ALREADY-attached Computer by its `sandbox_id` + `ct_`, WITHOUT starting
325
+ # or re-binding. For a trusted backend that persisted `{id, key, sandbox_id,
326
+ # ct_}` and needs to drive across processes/requests (e.g. re-mint a desktop
327
+ # `dt_` for a reconnect). No bind handshake runs, so the state `key` is unused
328
+ # here — it matters only for #attach (restore on the next start). Refuses if
329
+ # already attached; use Client#start_computer to start it again with state restored.
330
+ def adopt(config, sandbox_id:, computer_token:, status: :running,
331
+ binding_revision: @binding_revision)
332
+ raise BindError.new("Computer already attached — call #stop or #kill first") if @sandbox
333
+ unless config.respond_to?(:control_plane) && config.respond_to?(:zone)
334
+ raise ArgumentError, "config must be a PineSandbox::Connection (control_plane + zone)"
335
+ end
336
+ raise ArgumentError, "sandbox_id required" if sandbox_id.to_s.empty?
337
+ raise ArgumentError, "computer_token required" if computer_token.to_s.empty?
338
+ unless binding_revision.is_a?(Integer) && binding_revision >= 0
339
+ raise ArgumentError, "binding_revision must be a non-negative integer"
340
+ end
341
+
342
+ @connection = config
343
+ @clock = config.respond_to?(:clock) ? config.clock : PineSandbox::SYSTEM_CLOCK
344
+ @coordinator = Adapters::CoordinatorAdapter.new(sandbox_id: sandbox_id, zone: config.zone,
345
+ discovery_token: computer_token, clock: @clock)
346
+ @sandbox = SandboxHandle.new(control_plane: config.control_plane, sandbox_id: sandbox_id, status: status)
347
+ @computer_token = computer_token
348
+ @binding_revision = binding_revision
349
+ self
350
+ end
351
+
352
+ # The Computer-tier token (`ct_*`) delivered in the bind response. Raises
353
+ # if called before #attach (or after #stop).
354
+ def computer_token
355
+ @computer_token || raise(BindError.new("Computer is not attached — call #attach first"))
356
+ end
357
+
358
+ # INFORMATIONAL ONLY: the `expires_at` returned with the ct_ in the bind
359
+ # response. Deprecated: ct_ is binding-lifetime (no idle expiry, no
360
+ # renewal), so current Computers omit the field and this is nil.
361
+ # The SDK keeps no timers and never refreshes either way. nil on adopted
362
+ # Computers too (the bind response isn't available there).
363
+ attr_reader :computer_token_expires_at
364
+
365
+ # The persistence mode recorded for this binding, read back from the
366
+ # bind response ("ephemeral" for an access-lease-only Computer). A response that
367
+ # omits the field leaves this nil; adopted Computers (no bind response) are
368
+ # nil too. Informational — the SDK acts on its own @ephemeral intent.
369
+ attr_reader :persistence_mode
370
+
371
+ # Whether this Computer was bound as ephemeral (access-lease only, no state
372
+ # persistence). Set at construction; the persistent default is false.
373
+ def ephemeral?
374
+ @ephemeral
375
+ end
376
+
377
+ # --- driving the powered-on Computer (sessions hold the `ps_*` token) ---
378
+
379
+ # Creates a session and returns it holding its session token. Save its
380
+ # name and token, with #binding_revision, when this returns: no other call
381
+ # returns the token, and #adopt_session needs both to drive the session from
382
+ # another request or process. The session ends when the Computer stops or
383
+ # is recovered; delete the record then.
384
+ #
385
+ # `name` is optional: omit it (or pass nil/"") to let the Computer mint a
386
+ # friendly adjective-noun-hex name (e.g. "brave-otter-9f3a1c") — read it back
387
+ # off the returned {Session}. `label` is an optional human-meaningful tag,
388
+ # distinct from the addressing name. A duplicate name raises
389
+ # PineSandbox::ConflictError. The SDK default is `browser: false` (shell
390
+ # only), unlike the raw API's; pass `browser: true` for a browser.
391
+ def create_session(name: nil, label: nil, browser: false, blind: false)
392
+ rebind_on_unauthorized do
393
+ payload = coordinator!.create_session(
394
+ token: computer_token, name: name, label: label, browser: browser, blind: blind
395
+ )
396
+ Session.new(parent: self, payload: payload)
397
+ end
398
+ end
399
+
400
+ # Desktop delegation in one call:
401
+ # computer.delegate_desktop(name: session.name)
402
+ # Ensures the named (browser) desktop session exists, then returns its
403
+ # browser-safe {DelegatedConnection} (host + session name + spec major +
404
+ # a fresh short-TTL `dt_`; no `ct_`/`ps_`/JWS). `create: true` (default) creates
405
+ # the session if absent; `create: false` requires it. To refresh the `dt_`
406
+ # before a reconnect, call `session(name).delegate` again (server-side).
407
+ def delegate_desktop(name:, create: true, browser: true)
408
+ require_session_name!(name)
409
+ s =
410
+ begin
411
+ session(name)
412
+ rescue PineSandbox::NotFoundError
413
+ raise unless create
414
+
415
+ create_session(name: name, browser: browser)
416
+ end
417
+ s.delegate
418
+ end
419
+
420
+ def session(name)
421
+ rebind_on_unauthorized do
422
+ Session.new(parent: self, payload: coordinator!.get_session(token: computer_token, name: name))
423
+ end
424
+ end
425
+
426
+ # Rebuilds a session from the name and token saved when it was created,
427
+ # under this Computer's current binding.
428
+ # No request, session creation or token refresh; authorization is checked
429
+ # on first use. Metadata remains available through #session(name).
430
+ def adopt_session(name, session_token)
431
+ coordinator!
432
+ unless name.is_a?(String) && !name.empty? && session_token.is_a?(String) && !session_token.empty?
433
+ raise ArgumentError, "session name and token are required"
434
+ end
435
+
436
+ Session.new(parent: self, payload: { "name" => name, "token" => session_token })
437
+ end
438
+
439
+ # computer.skills.* — the Computer's learned-skill lifecycle (Computer Skills): list/get the
440
+ # served skills, review pending drafts (with bodies), activate/deactivate. Skills are
441
+ # computer-global; the authoring TRIGGERS are session-scoped — see Session#learn / #teach.
442
+ def skills
443
+ @skills ||= Skills.new(self)
444
+ end
445
+
446
+ # capabilities — the product features this Computer can use for its binding, and the limits
447
+ # it enforces on your calls.
448
+ def capabilities
449
+ rebind_on_unauthorized { coordinator!.capabilities(token: computer_token) }
450
+ end
451
+
452
+ # Qualify the current public agent contract before a workflow changes
453
+ # control or submits work. A Computer started on an older release raises
454
+ # AgentProtocolUnsupported;
455
+ # binding-auth and transport failures retain their ordinary SDK meaning.
456
+ def require_agent_protocol!
457
+ rebind_on_unauthorized { coordinator!.require_agent_protocol!(token: computer_token) }
458
+ self
459
+ end
460
+
461
+ def sessions
462
+ rebind_on_unauthorized do
463
+ coordinator!.list_sessions(token: computer_token).map { |payload| Session.new(parent: self, payload: payload) }
464
+ end
465
+ end
466
+
467
+ def destroy_session(name, clean: false)
468
+ rebind_on_unauthorized do
469
+ coordinator!.destroy_session(token: computer_token, name: name, clean: clean)
470
+ end
471
+ end
472
+
473
+ # --- control lease (drive ⇔ view), Computer-level facade ---
474
+ # The desktop stream forwards input only while a person holds control.
475
+ # A viewer (browser, dt_) can't take it; your backend brokers it here
476
+ # with the ct_. Hides the session lookup + etag handshake + actor_type so callers
477
+ # don't reach into Session. ct_-only, so it works on an adopted Computer.
478
+ # Returns the new ControlState.
479
+ def take_control(name:, actor_type: "user_click")
480
+ require_session_name!(name)
481
+ rebind_on_unauthorized { session(name).take_control(actor_type: actor_type) }
482
+ end
483
+
484
+ def release_control(name:, actor_type: "user_click")
485
+ require_session_name!(name)
486
+ rebind_on_unauthorized { session(name).release_control(actor_type: actor_type) }
487
+ end
488
+
489
+ # Lazily-built snapshot reader (GET /state, ct_-gated). Bound to the
490
+ # CURRENT attach; reset on every (re-)attach so it never points at a
491
+ # stale one.
492
+ def snapshot
493
+ @snapshot ||= Adapters::SnapshotAdapter.new(coordinator: coordinator!, token_provider: -> { computer_token })
494
+ end
495
+
496
+ # Save a restore point NOW — a synchronous, durable capture of this
497
+ # Computer's cookie jar, returned once committed. This is the EXPLICIT
498
+ # durability primitive: call it when you want the current state guaranteed
499
+ # restorable on the next #attach without stopping the Computer. #stop
500
+ # tries a final save before shutting the Computer down, and shuts it down
501
+ # even when that save fails.
502
+ # Content-hash-gated: an unchanged state returns `{"skipped" => true}` and
503
+ # the prior snapshot stays latest. Returns the persisted snapshot
504
+ # descriptor.
505
+ #
506
+ # SCOPE: a live checkpoint captures the cookie jar (the identity carrier
507
+ # for most auth). localStorage is only captured with Chrome stopped
508
+ # (on #stop), so a mid-session #checkpoint does not
509
+ # refresh it — the last stopped-capture value is preserved and restored.
510
+ def checkpoint
511
+ rebind_on_unauthorized { coordinator!.capture(token: computer_token) }
512
+ end
513
+
514
+ # Metadata-only inventory of durable custodial passkeys held by this
515
+ # Computer. The private keys remain inside the Computer and its encrypted state.
516
+ def passkeys
517
+ rebind_on_unauthorized { coordinator!.list_passkeys(token: computer_token) }
518
+ end
519
+
520
+ def delete_passkey(credential_id)
521
+ raise ArgumentError, "credential_id is required" if credential_id.to_s.empty?
522
+
523
+ rebind_on_unauthorized do
524
+ coordinator!.delete_passkey(token: computer_token, credential_id: credential_id)
525
+ end
526
+ end
527
+
528
+ # Pending first-enrollment ceremonies require an explicit Computer-owner
529
+ # decision. Assertions for already-held credentials remain automatic.
530
+ def pending_passkey_ceremonies
531
+ rebind_on_unauthorized { coordinator!.list_passkey_ceremonies(token: computer_token) }
532
+ end
533
+
534
+ def approve_passkey_ceremony(ceremony_id)
535
+ decide_passkey_ceremony(ceremony_id, "approve_custodial")
536
+ end
537
+
538
+ def decline_passkey_ceremony(ceremony_id)
539
+ decide_passkey_ceremony(ceremony_id, "decline")
540
+ end
541
+
542
+ # Browser tabs no session owns (for example a page a site opened in a new
543
+ # window after its session ended). Returns the response Hash.
544
+ def orphan_tabs
545
+ rebind_on_unauthorized { coordinator!.list_orphan_tabs(token: computer_token) }
546
+ end
547
+
548
+ def close_orphan_tab(target_id)
549
+ raise ArgumentError, "target_id is required" if target_id.to_s.empty?
550
+
551
+ rebind_on_unauthorized { coordinator!.close_orphan_tab(token: computer_token, target_id: target_id) }
552
+ end
553
+
554
+ # Preserve the current sessions, capabilities and live process memory.
555
+ # These bounded calls wait for observed completion; neither rebinds.
556
+ def pause(timeout: 360)
557
+ raise ArgumentError, "Computer has no binding" unless @sandbox
558
+
559
+ @sandbox.pause(timeout: timeout)
560
+ self
561
+ end
562
+
563
+ def resume(timeout: 360)
564
+ raise ArgumentError, "Computer has no binding" unless @sandbox
565
+
566
+ @sandbox.resume(timeout: timeout)
567
+ self
568
+ end
569
+
570
+ # Stop is owned by the server after acceptance. A timeout only ends this
571
+ # wait; another backend using the saved handle can observe or retry it.
572
+ # true confirms the Computer has shut down; if it was already gone, its
573
+ # final save is unknown.
574
+ def stop(wait_timeout: 390)
575
+ return true unless @sandbox
576
+
577
+ gone = @sandbox.stop(wait_timeout: wait_timeout)
578
+ clear_bound_state if gone
579
+ gone
580
+ end
581
+
582
+ # Force-terminate the Computer: UNGRACEFUL, no wait. The escape hatch for a stuck
583
+ # Computer — it may lose state written since the last checkpoint. #stop
584
+ # is the normal path. (Computers should always terminate gracefully; reach
585
+ # for #kill only to recover.)
586
+ #
587
+ # NOTE: there is intentionally no #destroy alias. The lifecycle verbs are
588
+ # distinct by intent: #stop saves supported state before shutdown;
589
+ # #kill is ungraceful force-recovery. The "done forever" verb —
590
+ # permanent delete, which ends the identity and purges saved state — is
591
+ # Client#delete_computer (it kills a still-attached Computer first).
592
+ def kill
593
+ return true unless @sandbox
594
+
595
+ deleted = @sandbox.kill
596
+ clear_bound_state if deleted
597
+ deleted
598
+ end
599
+
600
+ # PERMANENTLY delete this Computer: its identity and saved state end (see
601
+ # Client#delete_computer). A running Computer is killed first (no
602
+ # final save). Needs the connection this Computer was attached or adopted
603
+ # through; for an id alone use Client#delete_computer. Returns true.
604
+ def delete
605
+ provider = @connection&.attach_provider
606
+ unless provider.respond_to?(:delete_computer)
607
+ raise ComputerDeletionError,
608
+ @connection ? "computer deletion is unavailable from the custom attach credentials provider" :
609
+ "this Computer was never attached; delete it with Client#delete_computer(id)"
610
+ end
611
+
612
+ kill
613
+ provider.delete_computer(id)
614
+ true
615
+ end
616
+
617
+ def alive?
618
+ @sandbox ? @sandbox.alive? : false
619
+ end
620
+
621
+ # Observe the saved binding through the lifecycle service. Unlike #alive?,
622
+ # absence and transport/auth failures remain distinct errors. This never
623
+ # clears a binding, resumes memory or starts the Computer again.
624
+ def status
625
+ raise BindError, "Computer has no binding" unless @sandbox
626
+
627
+ @sandbox.status
628
+ end
629
+
630
+ # Observe the Computer this binding names through the lifecycle API,
631
+ # including while paused. If it has stopped, no Portal lookup, rebind or
632
+ # restart follows.
633
+ def sandbox_details
634
+ raise BindError, "Computer has no binding" unless @sandbox
635
+
636
+ info = @sandbox.sandbox_details
637
+ SandboxDetails.from_info(info)
638
+ end
639
+
640
+ # An unchanged value is not a heartbeat. On conflict or a lost reply,
641
+ # observe sandbox_details before deciding whether another edit is needed.
642
+ def set_idle_timeout(seconds:)
643
+ raise BindError, "Computer has no binding" unless @sandbox
644
+ raise ArgumentError, "idle timeout must be an integer of at least 60 seconds" unless seconds.is_a?(Integer) && seconds >= 60
645
+
646
+ SandboxDetails.from_info(@sandbox.set_idle_timeout(seconds: seconds))
647
+ end
648
+
649
+ # Save after Create/Start, alongside the durable credentials. Never log JSON:
650
+ # it contains the live Computer token. Stop clears this object's binding.
651
+ def binding
652
+ raise BindError, "Computer has no binding" unless @sandbox && @computer_token
653
+
654
+ Binding.new(computer_id: id.dup, sandbox_id: @sandbox.id.dup,
655
+ computer_token: @computer_token.dup, binding_revision: @binding_revision).validate!
656
+ end
657
+
658
+ # @api private — internal plumbing, NOT a supported end-user surface. Public
659
+ # only because the Computer's facade objects (e.g. Skills) wrap their own
660
+ # ct_-authenticated calls in the same 401→TokenRejectedError mapping (Ruby
661
+ # needs a public method for that cross-object call).
662
+ #
663
+ # A 401 on a bound Computer means the binding-lifetime ct_ is no longer
664
+ # recognized (Computer sandbox replaced or its binding lost). There is ONE recovery path —
665
+ # rebind through portal attach-credentials, i.e. a plain re-attach — so
666
+ # surface a typed TokenRejectedError rather than a generic auth error.
667
+ # Deliberately NOT an automatic rebind: a rebind always lands on a FRESH
668
+ # Computer sandbox (each one binds once), invalidating every session/ps_ the
669
+ # caller holds, so the re-attach must be the caller's explicit move.
670
+ # The data-plane client already classifies its own responses by token
671
+ # (a ps_ 401 stays UnauthorizedError); this maps a 401 that a custom
672
+ # coordinator raises for the ct_ the block used.
673
+ def rebind_on_unauthorized
674
+ yield
675
+ rescue PineSandbox::UnauthorizedError => e
676
+ raise unless e.status.to_i == 401
677
+
678
+ raise TokenRejectedError.for_api_error(e)
679
+ end
680
+
681
+ private
682
+
683
+ # A Computer has no default session: its session helpers need the name.
684
+ def require_session_name!(name)
685
+ raise ArgumentError, "session name is required" if name.to_s.empty?
686
+ end
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. Everything else about how it
730
+ # starts belongs to 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_sandbox_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-started Computer sandbox isn't routable through the gateway YET —
976
+ # NOT a bind rejection. These are the not-ready signals (all clear as the
977
+ # Computer 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 Computer sandbox 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 Computer sandbox cannot be attached: #{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 Computer 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
+ SandboxUnusableError.new("this Computer sandbox cannot be attached again; start the Computer again (#{problem}): #{message}", status: status, cause: err)
1053
+ when "/errors/bind-restore-failed"
1054
+ if message.include?("epoch")
1055
+ ComputerAlreadyAttachedError.new("this Computer is already attached elsewhere: #{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"