pinesandbox 0.3.18 → 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.
@@ -13,15 +13,15 @@ require_relative "session"
13
13
  module PineSandbox
14
14
  # A persistent Computer — the one object in this SDK. It is either
15
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.
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
21
  #
22
22
  # computer = PineSandbox::Computer.new("0190…", key: secret_32_bytes)
23
23
  # computer.attach(connection)
24
- # session = computer.create_session(name: "main", browser: true)
24
+ # session = computer.create_session(browser: true)
25
25
  # session.create_tab(url: "https://example.com")
26
26
  # computer.stop
27
27
  #
@@ -30,25 +30,25 @@ module PineSandbox
30
30
  # attach-credentials provider) is set once on the connection. The API key
31
31
  # alone cannot read state (that needs the private key material).
32
32
  #
33
- # Not thread-safe: a single Computer instance carries one binding (runtime
34
- # + ct_); drive one Computer from one thread at a time.
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
35
  class Computer
36
36
  CURRENT_KEY_VERSION = 1
37
37
  DEFAULT_MAX_BIND_ATTEMPTS = 3
38
- # Allocation and runtime readiness; independent of the sandbox lifetime.
38
+ # How long to wait for the Computer to start; independent of its idle timeout.
39
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.
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
45
  DEFAULT_BIND_READY_TIMEOUT = 120
46
46
  # The Computer id is a lowercase UUIDv7; a non-matching id would be
47
47
  # rejected late as a BindAuthError, so reject it up front instead.
48
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
49
 
50
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
51
+ # both raw Faraday and SDK shapes. A just-started Computer may not be
52
52
  # accepting connections yet (connection refused / TLS not up) — the same
53
53
  # convergence window as 404/503.
54
54
  TRANSIENT_BIND_ERRORS = [
@@ -56,16 +56,12 @@ module PineSandbox
56
56
  PineSandbox::TimeoutError, PineSandbox::ConnectionError
57
57
  ].freeze
58
58
 
59
- attr_reader :id, :binding_revision, :last_authorized_runtime_id, :location
59
+ attr_reader :id, :binding_revision, :last_authorized_sandbox_id, :location
60
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
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
69
65
 
70
66
  # @api private — internal plumbing, NOT a supported end-user surface. It is
71
67
  # public only because `Session` reaches it via `parent.coordinator` (Ruby
@@ -185,19 +181,20 @@ module PineSandbox
185
181
  self
186
182
  end
187
183
 
188
- # Allocate a runtime and bind this Computer to it, restoring its state.
184
+ # Start this Computer and bind to it, restoring its state.
189
185
  # Returns self (now powered-on). Safe across its internal retries —
190
186
  # 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
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
194
190
  # 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.
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.
197
193
  #
198
194
  # @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.
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.
201
198
  # @param pod_env [nil, Hash] Deprecated; only nil or an empty hash is accepted.
202
199
  def attach(config, timeout: 300, ready_timeout: DEFAULT_READY_TIMEOUT, pod_env: nil, metadata: nil,
203
200
  location: nil,
@@ -230,7 +227,7 @@ module PineSandbox
230
227
  # receipt, not authority to overwrite a newer country another client may
231
228
  # have committed after this instance stopped.
232
229
  requested_location = normalize_location(location) if location
233
- @last_authorized_runtime_id = nil
230
+ @last_authorized_sandbox_id = nil
234
231
  @connection = config
235
232
  @clock = config.respond_to?(:clock) ? config.clock : PineSandbox::SYSTEM_CLOCK
236
233
 
@@ -241,7 +238,7 @@ module PineSandbox
241
238
  body: build_computer_create_body(timeout: timeout, metadata: metadata).merge(create_id: create_id),
242
239
  request_timeout: ready_timeout
243
240
  )
244
- @sandbox = RuntimeHandle.new(control_plane: config.control_plane, runtime_id: info.id, status: info.status)
241
+ @sandbox = SandboxHandle.new(control_plane: config.control_plane, sandbox_id: info.id, status: info.status)
245
242
  # Wait for the pod to report Running (a CUA pod's readiness; the bind handshake
246
243
  # below is the functional coord-readiness check). A pod that fails or never
247
244
  # becomes Ready is destroyed before the typed error leaves #attach.
@@ -251,16 +248,16 @@ module PineSandbox
251
248
  reset_bound_state
252
249
  raise
253
250
  end
254
- # Drive the Computer at its runtime's gateway host `https://<runtime_id>.computer.<zone>`
251
+ # Drive the Computer at its gateway host `https://<sandbox_id>.computer.<zone>`
255
252
  # — 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)
253
+ @coordinator = Adapters::CoordinatorAdapter.new(sandbox_id: info.id, zone: config.zone, clock: @clock)
257
254
 
258
255
  # Two retry budgets converge on the bind handshake:
259
- # - readiness (DEADLINE-bounded): the runtime was just allocated, so the
256
+ # - readiness (DEADLINE-bounded): the Computer sandbox was just started, so the
260
257
  # gateway can't reach a serving Computer yet — 404 (not yet routable),
261
258
  # 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
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
264
261
  # slow restore can outlast a few tries, and attempt-counting gives up early.
265
262
  # - restore restart (ATTEMPT-bounded): if coord's short-lived two-round
266
263
  # challenge expires, clear only restore_secrets and restart round one
@@ -282,7 +279,7 @@ module PineSandbox
282
279
  retry
283
280
  end
284
281
  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)
282
+ raise BindTimeoutError.new("Computer did not become bindable within #{bind_ready_timeout}s (last: HTTP #{e.status}): #{e.message}", cause: e)
286
283
  elsif restore_no_challenge?(e) && restore_attempts < max_bind_attempts
287
284
  # Only coord's short-lived restore challenge expired. Reuse the one
288
285
  # Portal authorization committed for this sandbox and restart round one.
@@ -324,21 +321,19 @@ module PineSandbox
324
321
  stop
325
322
  end
326
323
 
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,
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,
329
326
  # ct_}` and needs to drive across processes/requests (e.g. re-mint a desktop
330
327
  # `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,
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,
335
331
  binding_revision: @binding_revision)
336
332
  raise BindError.new("Computer already attached — call #stop or #kill first") if @sandbox
337
333
  unless config.respond_to?(:control_plane) && config.respond_to?(:zone)
338
334
  raise ArgumentError, "config must be a PineSandbox::Connection (control_plane + zone)"
339
335
  end
340
- runtime_id ||= sandbox_id
341
- raise ArgumentError, "runtime_id required" if runtime_id.to_s.empty?
336
+ raise ArgumentError, "sandbox_id required" if sandbox_id.to_s.empty?
342
337
  raise ArgumentError, "computer_token required" if computer_token.to_s.empty?
343
338
  unless binding_revision.is_a?(Integer) && binding_revision >= 0
344
339
  raise ArgumentError, "binding_revision must be a non-negative integer"
@@ -346,9 +341,9 @@ module PineSandbox
346
341
 
347
342
  @connection = config
348
343
  @clock = config.respond_to?(:clock) ? config.clock : PineSandbox::SYSTEM_CLOCK
349
- @coordinator = Adapters::CoordinatorAdapter.new(runtime_id: runtime_id, zone: config.zone,
344
+ @coordinator = Adapters::CoordinatorAdapter.new(sandbox_id: sandbox_id, zone: config.zone,
350
345
  discovery_token: computer_token, clock: @clock)
351
- @sandbox = RuntimeHandle.new(control_plane: config.control_plane, runtime_id: runtime_id, status: status)
346
+ @sandbox = SandboxHandle.new(control_plane: config.control_plane, sandbox_id: sandbox_id, status: status)
352
347
  @computer_token = computer_token
353
348
  @binding_revision = binding_revision
354
349
  self
@@ -381,11 +376,18 @@ module PineSandbox
381
376
 
382
377
  # --- driving the powered-on Computer (sessions hold the `ps_*` token) ---
383
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
+ #
384
385
  # `name` is optional: omit it (or pass nil/"") to let the Computer mint a
385
386
  # friendly adjective-noun-hex name (e.g. "brave-otter-9f3a1c") — read it back
386
387
  # off the returned {Session}. `label` is an optional human-meaningful tag,
387
388
  # distinct from the addressing name. A duplicate name raises
388
- # PineSandbox::ConflictError.
389
+ # PineSandbox::ConflictError. The SDK default is `browser: false` (shell
390
+ # only), unlike the raw API's; pass `browser: true` for a browser.
389
391
  def create_session(name: nil, label: nil, browser: false, blind: false)
390
392
  rebind_on_unauthorized do
391
393
  payload = coordinator!.create_session(
@@ -395,14 +397,15 @@ module PineSandbox
395
397
  end
396
398
  end
397
399
 
398
- # Ergonomic one-call desktop delegation — the common playground flow is
399
- # client.create_computer(...).delegate_desktop
400
+ # Desktop delegation in one call:
401
+ # computer.delegate_desktop(name: session.name)
400
402
  # Ensures the named (browser) desktop session exists, then returns its
401
403
  # browser-safe {DelegatedConnection} (host + session name + spec major +
402
404
  # a fresh short-TTL `dt_`; no `ct_`/`ps_`/JWS). `create: true` (default) creates
403
405
  # the session if absent; `create: false` requires it. To refresh the `dt_`
404
406
  # before a reconnect, call `session(name).delegate` again (server-side).
405
- def delegate_desktop(name: "main", create: true, browser: true)
407
+ def delegate_desktop(name:, create: true, browser: true)
408
+ require_session_name!(name)
406
409
  s =
407
410
  begin
408
411
  session(name)
@@ -420,7 +423,8 @@ module PineSandbox
420
423
  end
421
424
  end
422
425
 
423
- # Reconstruct the same session from credentials saved with this binding.
426
+ # Rebuilds a session from the name and token saved when it was created,
427
+ # under this Computer's current binding.
424
428
  # No request, session creation or token refresh; authorization is checked
425
429
  # on first use. Metadata remains available through #session(name).
426
430
  def adopt_session(name, session_token)
@@ -446,7 +450,8 @@ module PineSandbox
446
450
  end
447
451
 
448
452
  # Qualify the current public agent contract before a workflow changes
449
- # control or submits work. Unsupported runtimes raise AgentProtocolUnsupported;
453
+ # control or submits work. A Computer started on an older release raises
454
+ # AgentProtocolUnsupported;
450
455
  # binding-auth and transport failures retain their ordinary SDK meaning.
451
456
  def require_agent_protocol!
452
457
  rebind_on_unauthorized { coordinator!.require_agent_protocol!(token: computer_token) }
@@ -471,16 +476,18 @@ module PineSandbox
471
476
  # with the ct_. Hides the session lookup + etag handshake + actor_type so callers
472
477
  # don't reach into Session. ct_-only, so it works on an adopted Computer.
473
478
  # Returns the new ControlState.
474
- def take_control(name: "main", actor_type: "user_click")
479
+ def take_control(name:, actor_type: "user_click")
480
+ require_session_name!(name)
475
481
  rebind_on_unauthorized { session(name).take_control(actor_type: actor_type) }
476
482
  end
477
483
 
478
- def release_control(name: "main", actor_type: "user_click")
484
+ def release_control(name:, actor_type: "user_click")
485
+ require_session_name!(name)
479
486
  rebind_on_unauthorized { session(name).release_control(actor_type: actor_type) }
480
487
  end
481
488
 
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
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
484
491
  # stale one.
485
492
  def snapshot
486
493
  @snapshot ||= Adapters::SnapshotAdapter.new(coordinator: coordinator!, token_provider: -> { computer_token })
@@ -490,15 +497,15 @@ module PineSandbox
490
497
  # Computer's cookie jar, returned once committed. This is the EXPLICIT
491
498
  # durability primitive: call it when you want the current state guaranteed
492
499
  # 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.
500
+ # tries a final save before shutting the Computer down, and shuts it down
501
+ # even when that save fails.
495
502
  # Content-hash-gated: an unchanged state returns `{"skipped" => true}` and
496
503
  # the prior snapshot stays latest. Returns the persisted snapshot
497
504
  # descriptor.
498
505
  #
499
506
  # SCOPE: a live checkpoint captures the cookie jar (the identity carrier
500
507
  # for most auth). localStorage is only captured with Chrome stopped
501
- # (#stop / restart onto a fresh runtime), so a mid-session #checkpoint does not
508
+ # (on #stop), so a mid-session #checkpoint does not
502
509
  # refresh it — the last stopped-capture value is preserved and restored.
503
510
  def checkpoint
504
511
  rebind_on_unauthorized { coordinator!.capture(token: computer_token) }
@@ -544,31 +551,17 @@ module PineSandbox
544
551
  rebind_on_unauthorized { coordinator!.close_orphan_tab(token: computer_token, target_id: target_id) }
545
552
  end
546
553
 
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
554
  # Preserve the current sessions, capabilities and live process memory.
562
555
  # These bounded calls wait for observed completion; neither rebinds.
563
556
  def pause(timeout: 360)
564
- raise ArgumentError, "Computer has no runtime" unless @sandbox
557
+ raise ArgumentError, "Computer has no binding" unless @sandbox
565
558
 
566
559
  @sandbox.pause(timeout: timeout)
567
560
  self
568
561
  end
569
562
 
570
563
  def resume(timeout: 360)
571
- raise ArgumentError, "Computer has no retained runtime" unless @sandbox
564
+ raise ArgumentError, "Computer has no binding" unless @sandbox
572
565
 
573
566
  @sandbox.resume(timeout: timeout)
574
567
  self
@@ -576,7 +569,8 @@ module PineSandbox
576
569
 
577
570
  # Stop is owned by the server after acceptance. A timeout only ends this
578
571
  # wait; another backend using the saved handle can observe or retry it.
579
- # true confirms compute release; final save is unknown after record removal.
572
+ # true confirms the Computer has shut down; if it was already gone, its
573
+ # final save is unknown.
580
574
  def stop(wait_timeout: 390)
581
575
  return true unless @sandbox
582
576
 
@@ -585,8 +579,8 @@ module PineSandbox
585
579
  gone
586
580
  end
587
581
 
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
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
590
584
  # is the normal path. (Computers should always terminate gracefully; reach
591
585
  # for #kill only to recover.)
592
586
  #
@@ -604,7 +598,7 @@ module PineSandbox
604
598
  end
605
599
 
606
600
  # PERMANENTLY delete this Computer: its identity and saved state end (see
607
- # Client#delete_computer). A still-attached runtime is killed first (no
601
+ # Client#delete_computer). A running Computer is killed first (no
608
602
  # final save). Needs the connection this Computer was attached or adopted
609
603
  # through; for an id alone use Client#delete_computer. Returns true.
610
604
  def delete
@@ -626,37 +620,38 @@ module PineSandbox
626
620
 
627
621
  # Observe the saved binding through the lifecycle service. Unlike #alive?,
628
622
  # absence and transport/auth failures remain distinct errors. This never
629
- # clears a binding, resumes memory or allocates a replacement runtime.
623
+ # clears a binding, resumes memory or starts the Computer again.
630
624
  def status
631
- raise BindError, "Computer has no runtime binding" unless @sandbox
625
+ raise BindError, "Computer has no binding" unless @sandbox
632
626
 
633
627
  @sandbox.status
634
628
  end
635
629
 
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
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
640
635
 
641
- info = @sandbox.runtime_details
642
- RuntimeDetails.from_runtime(info)
636
+ info = @sandbox.sandbox_details
637
+ SandboxDetails.from_info(info)
643
638
  end
644
639
 
645
640
  # An unchanged value is not a heartbeat. On conflict or a lost reply,
646
- # observe runtime_details before deciding whether another edit is needed.
641
+ # observe sandbox_details before deciding whether another edit is needed.
647
642
  def set_idle_timeout(seconds:)
648
- raise BindError, "Computer has no runtime binding" unless @sandbox
643
+ raise BindError, "Computer has no binding" unless @sandbox
649
644
  raise ArgumentError, "idle timeout must be an integer of at least 60 seconds" unless seconds.is_a?(Integer) && seconds >= 60
650
645
 
651
- RuntimeDetails.from_runtime(@sandbox.set_idle_timeout(seconds: seconds))
646
+ SandboxDetails.from_info(@sandbox.set_idle_timeout(seconds: seconds))
652
647
  end
653
648
 
654
649
  # Save after Create/Start, alongside the durable credentials. Never log JSON:
655
650
  # it contains the live Computer token. Stop clears this object's binding.
656
651
  def binding
657
- raise BindError, "Computer has no runtime binding" unless @sandbox && @computer_token
652
+ raise BindError, "Computer has no binding" unless @sandbox && @computer_token
658
653
 
659
- Binding.new(computer_id: id.dup, runtime_id: @sandbox.id.dup,
654
+ Binding.new(computer_id: id.dup, sandbox_id: @sandbox.id.dup,
660
655
  computer_token: @computer_token.dup, binding_revision: @binding_revision).validate!
661
656
  end
662
657
 
@@ -666,11 +661,11 @@ module PineSandbox
666
661
  # needs a public method for that cross-object call).
667
662
  #
668
663
  # 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 —
664
+ # recognized (Computer sandbox replaced or its binding lost). There is ONE recovery path —
670
665
  # rebind through portal attach-credentials, i.e. a plain re-attach — so
671
666
  # surface a typed TokenRejectedError rather than a generic auth error.
672
667
  # Deliberately NOT an automatic rebind: a rebind always lands on a FRESH
673
- # runtime (each runtime binds once), invalidating every session/ps_ the
668
+ # Computer sandbox (each one binds once), invalidating every session/ps_ the
674
669
  # caller holds, so the re-attach must be the caller's explicit move.
675
670
  # The data-plane client already classifies its own responses by token
676
671
  # (a ps_ 401 stays UnauthorizedError); this maps a 401 that a custom
@@ -685,6 +680,11 @@ module PineSandbox
685
680
 
686
681
  private
687
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
688
  def decide_passkey_ceremony(ceremony_id, decision)
689
689
  raise ArgumentError, "ceremony_id is required" if ceremony_id.to_s.empty?
690
690
 
@@ -726,8 +726,8 @@ module PineSandbox
726
726
  @snapshot = nil
727
727
  end
728
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.
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
731
  def build_computer_create_body(timeout:, metadata:)
732
732
  body = { timeout: timeout }
733
733
  body[:metadata] = metadata if metadata
@@ -879,7 +879,7 @@ module PineSandbox
879
879
  # provider response. If an in-process/custom issuer returns an incomplete
880
880
  # receipt, the caller still needs this revision to reconcile safely.
881
881
  @binding_revision = committed_revision
882
- @last_authorized_runtime_id = @sandbox.id
882
+ @last_authorized_sandbox_id = @sandbox.id
883
883
  if creds.key?("location")
884
884
  begin
885
885
  committed_location = normalize_location(creds.fetch("location"))
@@ -972,9 +972,9 @@ module PineSandbox
972
972
  problem == "/errors/bind-restore-failed" && status >= 500 && !err.message.to_s.include?("epoch")
973
973
  end
974
974
 
975
- # The just-allocated runtime isn't routable through the gateway YET —
975
+ # The just-started Computer sandbox isn't routable through the gateway YET —
976
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):
977
+ # Computer starts serving, so retry within the attach deadline):
978
978
  # 404 — not yet routable (brief post-create window);
979
979
  # 503 — routable but not yet ready;
980
980
  # 409 /errors/bind-in-progress — an in-flight bind settling.
@@ -1005,7 +1005,7 @@ module PineSandbox
1005
1005
  # returns 409 + Retry-After. It settles once the in-flight bind/restore
1006
1006
  # finishes — which can outlast a few attempts — so it rides the attach
1007
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.
1008
+ # serializes binds per Computer sandbox and a settled bind answers consistently.
1009
1009
  def bind_in_progress?(err)
1010
1010
  (err.respond_to?(:problem_type) ? err.problem_type : nil) == "/errors/bind-in-progress"
1011
1011
  end
@@ -1039,20 +1039,20 @@ module PineSandbox
1039
1039
  when "/errors/bind-rejected"
1040
1040
  BindAuthError.new("the Computer rejected the bind_token: #{message}", status: status, cause: err)
1041
1041
  when "/errors/bind-not-configured"
1042
- BindError.new("this runtime cannot be bound: #{message}", status: status, cause: err)
1042
+ BindError.new("this Computer sandbox cannot be attached: #{message}", status: status, cause: err)
1043
1043
  when "/errors/bind-lease-unenforceable"
1044
1044
  # Structural deployment misconfiguration (lease relay unconfigured on
1045
1045
  # a portal-trusting pod) — a bind retry cannot fix it, and it must
1046
1046
  # not masquerade as a broker outage.
1047
- BindError.new("this runtime cannot enforce the access lease: #{message}", status: status, cause: err)
1047
+ BindError.new("this Computer cannot enforce the access lease: #{message}", status: status, cause: err)
1048
1048
  when "/errors/already-bound-different-cid", "/errors/pod-tainted"
1049
1049
  # Terminal: a prior bind tainted this pod (bound to another Computer,
1050
1050
  # or failed after restore). The pod is single-use, so recovery is a
1051
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)
1052
+ SandboxUnusableError.new("this Computer sandbox cannot be attached again; start the Computer again (#{problem}): #{message}", status: status, cause: err)
1053
1053
  when "/errors/bind-restore-failed"
1054
1054
  if message.include?("epoch")
1055
- ComputerAlreadyAttachedError.new("another runtime holds this Computer's epoch lease: #{message}", status: status, cause: err)
1055
+ ComputerAlreadyAttachedError.new("this Computer is already attached elsewhere: #{message}", status: status, cause: err)
1056
1056
  else
1057
1057
  SavedStateUnavailableError.new("restoring saved state failed during bind: #{message}", status: status, cause: err)
1058
1058
  end
@@ -2,18 +2,22 @@ require "json"
2
2
 
3
3
  module PineSandbox
4
4
  class Computer
5
- # A saved connection to one runtime. Store with Credentials in encrypted
6
- # application storage. Importing it neither contacts nor replaces the runtime.
7
- class Binding < Struct.new(:computer_id, :runtime_id, :computer_token,
5
+ # A saved connection to the running Computer. Store with Credentials in
6
+ # encrypted application storage. Importing it neither contacts nor restarts
7
+ # the Computer. Bindings saved as schema 1, which named the sandbox id
8
+ # `runtime_id`, still load.
9
+ class Binding < Struct.new(:computer_id, :sandbox_id, :computer_token,
8
10
  :binding_revision, keyword_init: true)
9
- SCHEMA = 1
11
+ SCHEMA = 2
12
+ # The key each schema stores the sandbox id under.
13
+ SANDBOX_ID_KEYS = { 1 => "runtime_id", SCHEMA => "sandbox_id" }.freeze
10
14
 
11
15
  def self.from_json(json)
12
16
  data = JSON.parse(json)
13
- unless data.is_a?(Hash) && data["schema"].is_a?(Integer) && data["schema"] == SCHEMA
17
+ unless data.is_a?(Hash) && data["schema"].is_a?(Integer) && SANDBOX_ID_KEYS.key?(data["schema"])
14
18
  raise ArgumentError, "unsupported Computer binding schema"
15
19
  end
16
- new(computer_id: data["computer_id"], runtime_id: data["runtime_id"],
20
+ new(computer_id: data["computer_id"], sandbox_id: data[SANDBOX_ID_KEYS.fetch(data["schema"])],
17
21
  computer_token: data["computer_token"], binding_revision: data["binding_revision"]).validate!
18
22
  rescue JSON::ParserError
19
23
  raise ArgumentError, "Computer binding must be valid JSON"
@@ -23,8 +27,8 @@ module PineSandbox
23
27
  unless computer_id.is_a?(String) && computer_id.match?(Computer::UUID_V7)
24
28
  raise ArgumentError, "binding computer_id must be a lowercase UUIDv7"
25
29
  end
26
- unless runtime_id.is_a?(String) && runtime_id.length <= 63 && runtime_id.match?(Zone::RUNTIME_ID_RE)
27
- raise ArgumentError, "binding runtime_id must be a runtime identifier"
30
+ unless sandbox_id.is_a?(String) && sandbox_id.length <= 63 && sandbox_id.match?(Zone::SANDBOX_ID_RE)
31
+ raise ArgumentError, "binding sandbox_id must be a sandbox id"
28
32
  end
29
33
  unless computer_token.is_a?(String) && computer_token.match?(/\Act_[!-~]+\z/)
30
34
  raise ArgumentError, "binding computer_token must be a Computer token"
@@ -37,10 +41,10 @@ module PineSandbox
37
41
 
38
42
  def to_json(*args)
39
43
  validate!
40
- { schema: SCHEMA, computer_id:, runtime_id:, computer_token:, binding_revision: }.to_json(*args)
44
+ { schema: SCHEMA, computer_id:, sandbox_id:, computer_token:, binding_revision: }.to_json(*args)
41
45
  end
42
46
 
43
- def inspect = "#<PineSandbox::Computer::Binding computer_id=#{computer_id} runtime_id=#{runtime_id} token=[redacted]>"
47
+ def inspect = "#<PineSandbox::Computer::Binding computer_id=#{computer_id} sandbox_id=#{sandbox_id} token=[redacted]>"
44
48
  alias_method :to_s, :inspect
45
49
  end
46
50
  end
@@ -4,7 +4,7 @@ require "time"
4
4
  require_relative "errors"
5
5
  require_relative "clock"
6
6
  require_relative "operations"
7
- require_relative "runtime_details"
7
+ require_relative "sandbox_details"
8
8
  require_relative "spec_version_middleware"
9
9
 
10
10
  module PineSandbox
@@ -14,7 +14,7 @@ module PineSandbox
14
14
  # specs/computer-api.yaml lifecycle routes (root, no /v1).
15
15
  # @api private
16
16
  class ControlPlane
17
- Runtime = Struct.new(:id, :image, :status, :metadata, :entrypoint,
17
+ SandboxInfo = Struct.new(:id, :image, :status, :metadata, :entrypoint,
18
18
  :expires_at, :created_at, :operation_id,
19
19
  :retryable, :reason, :message, :etag, :lifecycle, :runtime_revision, keyword_init: true)
20
20
 
@@ -49,7 +49,7 @@ module PineSandbox
49
49
  # Internal adapter for the public Computer contract. No generic sandbox
50
50
  # create, image selection or task dispatch is available through this client.
51
51
  def create_computer(body:, request_timeout: nil)
52
- # A create ID permits identical retries without extending runtime expiry.
52
+ # A create ID permits identical retries without extending the Computer's expiry.
53
53
  keyed = !!body[:create_id]
54
54
  deadline = monotonic_now + request_timeout if request_timeout
55
55
  busy_repeats = 0
@@ -135,7 +135,7 @@ module PineSandbox
135
135
  true
136
136
  end
137
137
 
138
- # These methods report acceptance. RuntimeHandle waits for completion.
138
+ # These methods report acceptance. SandboxHandle waits for completion.
139
139
  # Keep the boolean result for integrations using older Docker servers.
140
140
  # Neither is replay-safe, so a transport fault raises instead of resending.
141
141
  def pause(sandbox_id, request_timeout: nil)
@@ -224,10 +224,10 @@ module PineSandbox
224
224
  def to_sandbox!(resp, ok:)
225
225
  raise_for_status!(resp) unless ok.include?(resp.status)
226
226
  payload = parse_json(resp)
227
- Runtime.new(id: payload["id"], image: payload["image"], metadata: payload["metadata"] || {},
228
- entrypoint: payload["entrypoint"], status: runtime_state(payload.dig("status", "state")),
229
- expires_at: runtime_time(payload["expiresAt"]), created_at: runtime_time(payload["createdAt"]),
230
- lifecycle: RuntimeLifetime.from_payload(payload["lifecycle"]),
227
+ SandboxInfo.new(id: payload["id"], image: payload["image"], metadata: payload["metadata"] || {},
228
+ entrypoint: payload["entrypoint"], status: sandbox_state(payload.dig("status", "state")),
229
+ expires_at: parse_time(payload["expiresAt"]), created_at: parse_time(payload["createdAt"]),
230
+ lifecycle: SandboxLifetime.from_payload(payload["lifecycle"]),
231
231
  runtime_revision: payload["runtime_revision"],
232
232
  operation_id: payload.dig("status", "operation_id"),
233
233
  retryable: payload.dig("status", "retryable"),
@@ -235,11 +235,11 @@ module PineSandbox
235
235
  end
236
236
 
237
237
  # "Running" → :running, "PendingResume" → :pending_resume.
238
- def runtime_state(state)
238
+ def sandbox_state(state)
239
239
  state&.to_s&.gsub(/([a-z\d])([A-Z])/, '\1_\2')&.downcase&.to_sym
240
240
  end
241
241
 
242
- def runtime_time(value) = value.nil? || value.is_a?(Time) ? value : Time.iso8601(value)
242
+ def parse_time(value) = value.nil? || value.is_a?(Time) ? value : Time.iso8601(value)
243
243
 
244
244
  def raise_for_status!(resp)
245
245
  body = parse_json(resp)
@@ -9,6 +9,7 @@ module PineSandbox
9
9
  "/errors/sandbox-not-ready" => true,
10
10
  "/errors/unauthorized" => false,
11
11
  "/errors/forbidden" => false,
12
+ "/errors/feature-disabled" => false,
12
13
  "/errors/precondition-required" => false,
13
14
  "/errors/precondition-failed" => true,
14
15
  "/errors/malformed-json" => false,