pinesandbox 0.3.18 → 0.3.20

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.
@@ -8,20 +8,21 @@ require_relative "bind_hpke"
8
8
  require_relative "capture_keypair"
9
9
  require_relative "adapters/coordinator_adapter"
10
10
  require_relative "adapters/snapshot_adapter"
11
+ require_relative "artifacts"
11
12
  require_relative "session"
12
13
 
13
14
  module PineSandbox
14
15
  # A persistent Computer — the one object in this SDK. It is either
15
16
  # 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.
17
+ # or powered-on (started and attached). `attach` starts it, runs the
18
+ # bind/restore handshake so the Computer resumes its prior state, and
19
+ # returns `self`; you then drive it through `create_session`. `stop` saves
20
+ # supported state and shuts the Computer down, and you can `attach` again
21
+ # later to start it with state restored.
21
22
  #
22
23
  # computer = PineSandbox::Computer.new("0190…", key: secret_32_bytes)
23
24
  # computer.attach(connection)
24
- # session = computer.create_session(name: "main", browser: true)
25
+ # session = computer.create_session
25
26
  # session.create_tab(url: "https://example.com")
26
27
  # computer.stop
27
28
  #
@@ -30,25 +31,25 @@ module PineSandbox
30
31
  # attach-credentials provider) is set once on the connection. The API key
31
32
  # alone cannot read state (that needs the private key material).
32
33
  #
33
- # Not thread-safe: a single Computer instance carries one binding (runtime
34
- # + ct_); drive one Computer from one thread at a time.
34
+ # Not thread-safe: a single Computer instance carries one binding (sandbox
35
+ # id + ct_); drive one Computer from one thread at a time.
35
36
  class Computer
36
37
  CURRENT_KEY_VERSION = 1
37
38
  DEFAULT_MAX_BIND_ATTEMPTS = 3
38
- # Allocation and runtime readiness; independent of the sandbox lifetime.
39
+ # How long to wait for the Computer to start; independent of its idle timeout.
39
40
  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.
41
+ # Wall-clock budget for a just-started Computer to become bindable. Spans
42
+ # the gap between the lifecycle reporting "running" and the Computer
43
+ # actually serving; the bind handshake retries not-ready responses (404 /
44
+ # 503 / transient transport errors) within it. Generous — a healthy
45
+ # Computer clears it in seconds; this only bounds one that never comes up.
45
46
  DEFAULT_BIND_READY_TIMEOUT = 120
46
47
  # The Computer id is a lowercase UUIDv7; a non-matching id would be
47
48
  # rejected late as a BindAuthError, so reject it up front instead.
48
49
  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
 
50
51
  # 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
+ # both raw Faraday and SDK shapes. A just-started Computer may not be
52
53
  # accepting connections yet (connection refused / TLS not up) — the same
53
54
  # convergence window as 404/503.
54
55
  TRANSIENT_BIND_ERRORS = [
@@ -56,16 +57,12 @@ module PineSandbox
56
57
  PineSandbox::TimeoutError, PineSandbox::ConnectionError
57
58
  ].freeze
58
59
 
59
- attr_reader :id, :binding_revision, :last_authorized_runtime_id, :location
60
+ attr_reader :id, :binding_revision, :last_authorized_sandbox_id, :location
60
61
 
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
62
+ # The id of the Computer sandbox this Computer is attached to (the API's
63
+ # `sandbox_id`); it changes on every start.
64
+ # nil when it isn't attached.
65
+ def sandbox_id = @sandbox&.id
69
66
 
70
67
  # @api private — internal plumbing, NOT a supported end-user surface. It is
71
68
  # public only because `Session` reaches it via `parent.coordinator` (Ruby
@@ -185,19 +182,20 @@ module PineSandbox
185
182
  self
186
183
  end
187
184
 
188
- # Allocate a runtime and bind this Computer to it, restoring its state.
185
+ # Start this Computer and bind to it, restoring its state.
189
186
  # Returns self (now powered-on). Safe across its internal retries —
190
187
  # 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
188
+ # committed-but-lost bind replays idempotently. A change of pod_uid or
189
+ # coord_boot_id is terminal because Portal has already committed the one
190
+ # authorization allowed for this Computer sandbox. Raises if this Computer is already
194
191
  # 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.
192
+ # silently discard state); on any bind failure the SDK shuts the
193
+ # just-started Computer down and leaves it powered-off before a typed error.
197
194
  #
198
195
  # @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.
196
+ # timeout is how long the Computer keeps running without use. ready_timeout
197
+ # bounds how long it takes to start; bind_ready_timeout separately bounds the
198
+ # bind/restore handshake.
201
199
  # @param pod_env [nil, Hash] Deprecated; only nil or an empty hash is accepted.
202
200
  def attach(config, timeout: 300, ready_timeout: DEFAULT_READY_TIMEOUT, pod_env: nil, metadata: nil,
203
201
  location: nil,
@@ -230,7 +228,7 @@ module PineSandbox
230
228
  # receipt, not authority to overwrite a newer country another client may
231
229
  # have committed after this instance stopped.
232
230
  requested_location = normalize_location(location) if location
233
- @last_authorized_runtime_id = nil
231
+ @last_authorized_sandbox_id = nil
234
232
  @connection = config
235
233
  @clock = config.respond_to?(:clock) ? config.clock : PineSandbox::SYSTEM_CLOCK
236
234
 
@@ -241,7 +239,7 @@ module PineSandbox
241
239
  body: build_computer_create_body(timeout: timeout, metadata: metadata).merge(create_id: create_id),
242
240
  request_timeout: ready_timeout
243
241
  )
244
- @sandbox = RuntimeHandle.new(control_plane: config.control_plane, runtime_id: info.id, status: info.status)
242
+ @sandbox = SandboxHandle.new(control_plane: config.control_plane, sandbox_id: info.id, status: info.status)
245
243
  # Wait for the pod to report Running (a CUA pod's readiness; the bind handshake
246
244
  # below is the functional coord-readiness check). A pod that fails or never
247
245
  # becomes Ready is destroyed before the typed error leaves #attach.
@@ -251,16 +249,16 @@ module PineSandbox
251
249
  reset_bound_state
252
250
  raise
253
251
  end
254
- # Drive the Computer at its runtime's gateway host `https://<runtime_id>.computer.<zone>`
252
+ # Drive the Computer at its gateway host `https://<sandbox_id>.computer.<zone>`
255
253
  # — 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)
254
+ @coordinator = Adapters::CoordinatorAdapter.new(sandbox_id: info.id, zone: config.zone, clock: @clock)
257
255
 
258
256
  # Two retry budgets converge on the bind handshake:
259
- # - readiness (DEADLINE-bounded): the runtime was just allocated, so the
257
+ # - readiness (DEADLINE-bounded): the Computer sandbox was just started, so the
260
258
  # gateway can't reach a serving Computer yet — 404 (not yet routable),
261
259
  # 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
260
+ # bind settling (409 bind-in-progress). All clear as the Computer
261
+ # starts, so retry until a wall-clock DEADLINE — a cold start or a
264
262
  # slow restore can outlast a few tries, and attempt-counting gives up early.
265
263
  # - restore restart (ATTEMPT-bounded): if coord's short-lived two-round
266
264
  # challenge expires, clear only restore_secrets and restart round one
@@ -282,7 +280,7 @@ module PineSandbox
282
280
  retry
283
281
  end
284
282
  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)
283
+ raise BindTimeoutError.new("Computer did not become bindable within #{bind_ready_timeout}s (last: HTTP #{e.status}): #{e.message}", cause: e)
286
284
  elsif restore_no_challenge?(e) && restore_attempts < max_bind_attempts
287
285
  # Only coord's short-lived restore challenge expired. Reuse the one
288
286
  # Portal authorization committed for this sandbox and restart round one.
@@ -324,21 +322,19 @@ module PineSandbox
324
322
  stop
325
323
  end
326
324
 
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,
325
+ # Adopt an ALREADY-attached Computer by its `sandbox_id` + `ct_`, WITHOUT starting
326
+ # or re-binding. For a trusted backend that persisted `{id, key, sandbox_id,
329
327
  # ct_}` and needs to drive across processes/requests (e.g. re-mint a desktop
330
328
  # `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,
329
+ # here — it matters only for #attach (restore on the next start). Refuses if
330
+ # already attached; use Client#start_computer to start it again with state restored.
331
+ def adopt(config, sandbox_id:, computer_token:, status: :running,
335
332
  binding_revision: @binding_revision)
336
333
  raise BindError.new("Computer already attached — call #stop or #kill first") if @sandbox
337
334
  unless config.respond_to?(:control_plane) && config.respond_to?(:zone)
338
335
  raise ArgumentError, "config must be a PineSandbox::Connection (control_plane + zone)"
339
336
  end
340
- runtime_id ||= sandbox_id
341
- raise ArgumentError, "runtime_id required" if runtime_id.to_s.empty?
337
+ raise ArgumentError, "sandbox_id required" if sandbox_id.to_s.empty?
342
338
  raise ArgumentError, "computer_token required" if computer_token.to_s.empty?
343
339
  unless binding_revision.is_a?(Integer) && binding_revision >= 0
344
340
  raise ArgumentError, "binding_revision must be a non-negative integer"
@@ -346,9 +342,9 @@ module PineSandbox
346
342
 
347
343
  @connection = config
348
344
  @clock = config.respond_to?(:clock) ? config.clock : PineSandbox::SYSTEM_CLOCK
349
- @coordinator = Adapters::CoordinatorAdapter.new(runtime_id: runtime_id, zone: config.zone,
345
+ @coordinator = Adapters::CoordinatorAdapter.new(sandbox_id: sandbox_id, zone: config.zone,
350
346
  discovery_token: computer_token, clock: @clock)
351
- @sandbox = RuntimeHandle.new(control_plane: config.control_plane, runtime_id: runtime_id, status: status)
347
+ @sandbox = SandboxHandle.new(control_plane: config.control_plane, sandbox_id: sandbox_id, status: status)
352
348
  @computer_token = computer_token
353
349
  @binding_revision = binding_revision
354
350
  self
@@ -381,35 +377,54 @@ module PineSandbox
381
377
 
382
378
  # --- driving the powered-on Computer (sessions hold the `ps_*` token) ---
383
379
 
380
+ # Creates a session and returns it holding its session token. Save its
381
+ # name and token, with #binding_revision, when this returns: no other call
382
+ # returns the token, and #adopt_session needs both to drive the session from
383
+ # another request or process. The session ends when the Computer stops or
384
+ # is recovered; delete the record then.
385
+ #
384
386
  # `name` is optional: omit it (or pass nil/"") to let the Computer mint a
385
387
  # friendly adjective-noun-hex name (e.g. "brave-otter-9f3a1c") — read it back
386
388
  # off the returned {Session}. `label` is an optional human-meaningful tag,
387
389
  # 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
+ # PineSandbox::ConflictError. Every session has its own browser window.
391
+ #
392
+ # `artifact_access` is what the session's agent may do with the Computer's
393
+ # artifacts (see Client#artifacts): "none" (the default: nothing, and the
394
+ # agent isn't told about them), "read_only" (list and read) or
395
+ # "read_write" (also save and delete files and make public links). A
396
+ # Symbol works too. Your own access with the project key is the same
397
+ # either way.
398
+ def create_session(name: nil, label: nil, artifact_access: nil)
399
+ unless artifact_access.nil? || ((artifact_access.is_a?(String) || artifact_access.is_a?(Symbol)) &&
400
+ Session::ARTIFACT_ACCESS.include?(artifact_access.to_s))
401
+ raise ArgumentError, "artifact_access must be one of #{Session::ARTIFACT_ACCESS.join(', ')}"
402
+ end
403
+
404
+ options = { name: name, label: label }
405
+ options[:artifact_access] = artifact_access.to_s unless artifact_access.nil?
390
406
  rebind_on_unauthorized do
391
- payload = coordinator!.create_session(
392
- token: computer_token, name: name, label: label, browser: browser, blind: blind
393
- )
407
+ payload = coordinator!.create_session(token: computer_token, **options)
394
408
  Session.new(parent: self, payload: payload)
395
409
  end
396
410
  end
397
411
 
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
412
+ # Desktop delegation in one call:
413
+ # computer.delegate_desktop(name: session.name)
414
+ # Ensures the named desktop session exists, then returns its
401
415
  # browser-safe {DelegatedConnection} (host + session name + spec major +
402
416
  # a fresh short-TTL `dt_`; no `ct_`/`ps_`/JWS). `create: true` (default) creates
403
417
  # the session if absent; `create: false` requires it. To refresh the `dt_`
404
418
  # before a reconnect, call `session(name).delegate` again (server-side).
405
- def delegate_desktop(name: "main", create: true, browser: true)
419
+ def delegate_desktop(name:, create: true)
420
+ require_session_name!(name)
406
421
  s =
407
422
  begin
408
423
  session(name)
409
424
  rescue PineSandbox::NotFoundError
410
425
  raise unless create
411
426
 
412
- create_session(name: name, browser: browser)
427
+ create_session(name: name)
413
428
  end
414
429
  s.delegate
415
430
  end
@@ -420,16 +435,18 @@ module PineSandbox
420
435
  end
421
436
  end
422
437
 
423
- # Reconstruct the same session from credentials saved with this binding.
438
+ # Rebuilds a session from the name and token saved when it was created,
439
+ # under this Computer's current binding.
424
440
  # No request, session creation or token refresh; authorization is checked
425
- # on first use. Metadata remains available through #session(name).
441
+ # on first use. Metadata remains available through #session(name); the
442
+ # rebuilt session's #artifact_access is nil, since only the Computer knows it.
426
443
  def adopt_session(name, session_token)
427
444
  coordinator!
428
445
  unless name.is_a?(String) && !name.empty? && session_token.is_a?(String) && !session_token.empty?
429
446
  raise ArgumentError, "session name and token are required"
430
447
  end
431
448
 
432
- Session.new(parent: self, payload: { "name" => name, "token" => session_token })
449
+ Session.new(parent: self, payload: { "name" => name, "token" => session_token }, artifact_access_known: false)
433
450
  end
434
451
 
435
452
  # computer.skills.* — the Computer's learned-skill lifecycle (Computer Skills): list/get the
@@ -439,6 +456,23 @@ module PineSandbox
439
456
  @skills ||= Skills.new(self)
440
457
  end
441
458
 
459
+ # A public link to one of the Computer's artifacts (see Client#artifacts),
460
+ # served by this running Computer: anyone with the URL can download the
461
+ # file as it is when they open it. The link stops working after
462
+ # `expires_in_seconds` (60 to 2,592,000) if given, when the file is
463
+ # deleted, and always when the Computer stops. Returns the response Hash,
464
+ # { "url" => …, "expires_at" => … }; "expires_at" only with an expiry.
465
+ def artifact_link(path, expires_in_seconds: nil)
466
+ path = ArtifactPath.check!(path)
467
+ unless expires_in_seconds.nil? || (expires_in_seconds.is_a?(Integer) && expires_in_seconds.between?(60, 2_592_000))
468
+ raise ArgumentError, "expires_in_seconds must be an integer from 60 to 2592000"
469
+ end
470
+
471
+ rebind_on_unauthorized do
472
+ coordinator!.create_artifact_link(token: computer_token, path: path, expires_in_seconds: expires_in_seconds)
473
+ end
474
+ end
475
+
442
476
  # capabilities — the product features this Computer can use for its binding, and the limits
443
477
  # it enforces on your calls.
444
478
  def capabilities
@@ -446,7 +480,8 @@ module PineSandbox
446
480
  end
447
481
 
448
482
  # Qualify the current public agent contract before a workflow changes
449
- # control or submits work. Unsupported runtimes raise AgentProtocolUnsupported;
483
+ # control or submits work. A Computer started on an older release raises
484
+ # AgentProtocolUnsupported;
450
485
  # binding-auth and transport failures retain their ordinary SDK meaning.
451
486
  def require_agent_protocol!
452
487
  rebind_on_unauthorized { coordinator!.require_agent_protocol!(token: computer_token) }
@@ -471,16 +506,18 @@ module PineSandbox
471
506
  # with the ct_. Hides the session lookup + etag handshake + actor_type so callers
472
507
  # don't reach into Session. ct_-only, so it works on an adopted Computer.
473
508
  # Returns the new ControlState.
474
- def take_control(name: "main", actor_type: "user_click")
509
+ def take_control(name:, actor_type: "user_click")
510
+ require_session_name!(name)
475
511
  rebind_on_unauthorized { session(name).take_control(actor_type: actor_type) }
476
512
  end
477
513
 
478
- def release_control(name: "main", actor_type: "user_click")
514
+ def release_control(name:, actor_type: "user_click")
515
+ require_session_name!(name)
479
516
  rebind_on_unauthorized { session(name).release_control(actor_type: actor_type) }
480
517
  end
481
518
 
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
519
+ # Lazily-built snapshot reader (GET /state, ct_-gated). Bound to the
520
+ # CURRENT attach; reset on every (re-)attach so it never points at a
484
521
  # stale one.
485
522
  def snapshot
486
523
  @snapshot ||= Adapters::SnapshotAdapter.new(coordinator: coordinator!, token_provider: -> { computer_token })
@@ -490,15 +527,15 @@ module PineSandbox
490
527
  # Computer's cookie jar, returned once committed. This is the EXPLICIT
491
528
  # durability primitive: call it when you want the current state guaranteed
492
529
  # 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.
530
+ # tries a final save before shutting the Computer down, and shuts it down
531
+ # even when that save fails.
495
532
  # Content-hash-gated: an unchanged state returns `{"skipped" => true}` and
496
533
  # the prior snapshot stays latest. Returns the persisted snapshot
497
534
  # descriptor.
498
535
  #
499
536
  # SCOPE: a live checkpoint captures the cookie jar (the identity carrier
500
537
  # for most auth). localStorage is only captured with Chrome stopped
501
- # (#stop / restart onto a fresh runtime), so a mid-session #checkpoint does not
538
+ # (on #stop), so a mid-session #checkpoint does not
502
539
  # refresh it — the last stopped-capture value is preserved and restored.
503
540
  def checkpoint
504
541
  rebind_on_unauthorized { coordinator!.capture(token: computer_token) }
@@ -544,31 +581,17 @@ module PineSandbox
544
581
  rebind_on_unauthorized { coordinator!.close_orphan_tab(token: computer_token, target_id: target_id) }
545
582
  end
546
583
 
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
584
  # Preserve the current sessions, capabilities and live process memory.
562
585
  # These bounded calls wait for observed completion; neither rebinds.
563
586
  def pause(timeout: 360)
564
- raise ArgumentError, "Computer has no runtime" unless @sandbox
587
+ raise ArgumentError, "Computer has no binding" unless @sandbox
565
588
 
566
589
  @sandbox.pause(timeout: timeout)
567
590
  self
568
591
  end
569
592
 
570
593
  def resume(timeout: 360)
571
- raise ArgumentError, "Computer has no retained runtime" unless @sandbox
594
+ raise ArgumentError, "Computer has no binding" unless @sandbox
572
595
 
573
596
  @sandbox.resume(timeout: timeout)
574
597
  self
@@ -576,7 +599,8 @@ module PineSandbox
576
599
 
577
600
  # Stop is owned by the server after acceptance. A timeout only ends this
578
601
  # wait; another backend using the saved handle can observe or retry it.
579
- # true confirms compute release; final save is unknown after record removal.
602
+ # true confirms the Computer has shut down; if it was already gone, its
603
+ # final save is unknown.
580
604
  def stop(wait_timeout: 390)
581
605
  return true unless @sandbox
582
606
 
@@ -585,8 +609,8 @@ module PineSandbox
585
609
  gone
586
610
  end
587
611
 
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
612
+ # Force-terminate the Computer: UNGRACEFUL, no wait. The escape hatch for a stuck
613
+ # Computer — it may lose state written since the last checkpoint. #stop
590
614
  # is the normal path. (Computers should always terminate gracefully; reach
591
615
  # for #kill only to recover.)
592
616
  #
@@ -604,7 +628,7 @@ module PineSandbox
604
628
  end
605
629
 
606
630
  # PERMANENTLY delete this Computer: its identity and saved state end (see
607
- # Client#delete_computer). A still-attached runtime is killed first (no
631
+ # Client#delete_computer). A running Computer is killed first (no
608
632
  # final save). Needs the connection this Computer was attached or adopted
609
633
  # through; for an id alone use Client#delete_computer. Returns true.
610
634
  def delete
@@ -626,37 +650,38 @@ module PineSandbox
626
650
 
627
651
  # Observe the saved binding through the lifecycle service. Unlike #alive?,
628
652
  # absence and transport/auth failures remain distinct errors. This never
629
- # clears a binding, resumes memory or allocates a replacement runtime.
653
+ # clears a binding, resumes memory or starts the Computer again.
630
654
  def status
631
- raise BindError, "Computer has no runtime binding" unless @sandbox
655
+ raise BindError, "Computer has no binding" unless @sandbox
632
656
 
633
657
  @sandbox.status
634
658
  end
635
659
 
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
660
+ # Observe the Computer this binding names through the lifecycle API,
661
+ # including while paused. If it has stopped, no Portal lookup, rebind or
662
+ # restart follows.
663
+ def sandbox_details
664
+ raise BindError, "Computer has no binding" unless @sandbox
640
665
 
641
- info = @sandbox.runtime_details
642
- RuntimeDetails.from_runtime(info)
666
+ info = @sandbox.sandbox_details
667
+ SandboxDetails.from_info(info)
643
668
  end
644
669
 
645
670
  # An unchanged value is not a heartbeat. On conflict or a lost reply,
646
- # observe runtime_details before deciding whether another edit is needed.
671
+ # observe sandbox_details before deciding whether another edit is needed.
647
672
  def set_idle_timeout(seconds:)
648
- raise BindError, "Computer has no runtime binding" unless @sandbox
673
+ raise BindError, "Computer has no binding" unless @sandbox
649
674
  raise ArgumentError, "idle timeout must be an integer of at least 60 seconds" unless seconds.is_a?(Integer) && seconds >= 60
650
675
 
651
- RuntimeDetails.from_runtime(@sandbox.set_idle_timeout(seconds: seconds))
676
+ SandboxDetails.from_info(@sandbox.set_idle_timeout(seconds: seconds))
652
677
  end
653
678
 
654
679
  # Save after Create/Start, alongside the durable credentials. Never log JSON:
655
680
  # it contains the live Computer token. Stop clears this object's binding.
656
681
  def binding
657
- raise BindError, "Computer has no runtime binding" unless @sandbox && @computer_token
682
+ raise BindError, "Computer has no binding" unless @sandbox && @computer_token
658
683
 
659
- Binding.new(computer_id: id.dup, runtime_id: @sandbox.id.dup,
684
+ Binding.new(computer_id: id.dup, sandbox_id: @sandbox.id.dup,
660
685
  computer_token: @computer_token.dup, binding_revision: @binding_revision).validate!
661
686
  end
662
687
 
@@ -666,11 +691,11 @@ module PineSandbox
666
691
  # needs a public method for that cross-object call).
667
692
  #
668
693
  # 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 —
694
+ # recognized (Computer sandbox replaced or its binding lost). There is ONE recovery path —
670
695
  # rebind through portal attach-credentials, i.e. a plain re-attach — so
671
696
  # surface a typed TokenRejectedError rather than a generic auth error.
672
697
  # Deliberately NOT an automatic rebind: a rebind always lands on a FRESH
673
- # runtime (each runtime binds once), invalidating every session/ps_ the
698
+ # Computer sandbox (each one binds once), invalidating every session/ps_ the
674
699
  # caller holds, so the re-attach must be the caller's explicit move.
675
700
  # The data-plane client already classifies its own responses by token
676
701
  # (a ps_ 401 stays UnauthorizedError); this maps a 401 that a custom
@@ -685,6 +710,11 @@ module PineSandbox
685
710
 
686
711
  private
687
712
 
713
+ # A Computer has no default session: its session helpers need the name.
714
+ def require_session_name!(name)
715
+ raise ArgumentError, "session name is required" if name.to_s.empty?
716
+ end
717
+
688
718
  def decide_passkey_ceremony(ceremony_id, decision)
689
719
  raise ArgumentError, "ceremony_id is required" if ceremony_id.to_s.empty?
690
720
 
@@ -726,8 +756,8 @@ module PineSandbox
726
756
  @snapshot = nil
727
757
  end
728
758
 
729
- # The caller-owned part of a Computer create. Runtime selection belongs to
730
- # the project policy and is never serialized by this SDK.
759
+ # The caller-owned part of a Computer create. Everything else about how it
760
+ # starts belongs to the project policy and is never serialized by this SDK.
731
761
  def build_computer_create_body(timeout:, metadata:)
732
762
  body = { timeout: timeout }
733
763
  body[:metadata] = metadata if metadata
@@ -879,7 +909,7 @@ module PineSandbox
879
909
  # provider response. If an in-process/custom issuer returns an incomplete
880
910
  # receipt, the caller still needs this revision to reconcile safely.
881
911
  @binding_revision = committed_revision
882
- @last_authorized_runtime_id = @sandbox.id
912
+ @last_authorized_sandbox_id = @sandbox.id
883
913
  if creds.key?("location")
884
914
  begin
885
915
  committed_location = normalize_location(creds.fetch("location"))
@@ -972,9 +1002,9 @@ module PineSandbox
972
1002
  problem == "/errors/bind-restore-failed" && status >= 500 && !err.message.to_s.include?("epoch")
973
1003
  end
974
1004
 
975
- # The just-allocated runtime isn't routable through the gateway YET —
1005
+ # The just-started Computer sandbox isn't routable through the gateway YET —
976
1006
  # NOT a bind rejection. These are the not-ready signals (all clear as the
977
- # runtime starts serving, so retry within the attach deadline):
1007
+ # Computer starts serving, so retry within the attach deadline):
978
1008
  # 404 — not yet routable (brief post-create window);
979
1009
  # 503 — routable but not yet ready;
980
1010
  # 409 /errors/bind-in-progress — an in-flight bind settling.
@@ -1005,7 +1035,7 @@ module PineSandbox
1005
1035
  # returns 409 + Retry-After. It settles once the in-flight bind/restore
1006
1036
  # finishes — which can outlast a few attempts — so it rides the attach
1007
1037
  # DEADLINE, not max_bind_attempts. Re-running run_bind is safe: the guard
1008
- # serializes binds per runtime and a settled bind answers consistently.
1038
+ # serializes binds per Computer sandbox and a settled bind answers consistently.
1009
1039
  def bind_in_progress?(err)
1010
1040
  (err.respond_to?(:problem_type) ? err.problem_type : nil) == "/errors/bind-in-progress"
1011
1041
  end
@@ -1039,20 +1069,20 @@ module PineSandbox
1039
1069
  when "/errors/bind-rejected"
1040
1070
  BindAuthError.new("the Computer rejected the bind_token: #{message}", status: status, cause: err)
1041
1071
  when "/errors/bind-not-configured"
1042
- BindError.new("this runtime cannot be bound: #{message}", status: status, cause: err)
1072
+ BindError.new("this Computer sandbox cannot be attached: #{message}", status: status, cause: err)
1043
1073
  when "/errors/bind-lease-unenforceable"
1044
1074
  # Structural deployment misconfiguration (lease relay unconfigured on
1045
1075
  # a portal-trusting pod) — a bind retry cannot fix it, and it must
1046
1076
  # not masquerade as a broker outage.
1047
- BindError.new("this runtime cannot enforce the access lease: #{message}", status: status, cause: err)
1077
+ BindError.new("this Computer cannot enforce the access lease: #{message}", status: status, cause: err)
1048
1078
  when "/errors/already-bound-different-cid", "/errors/pod-tainted"
1049
1079
  # Terminal: a prior bind tainted this pod (bound to another Computer,
1050
1080
  # or failed after restore). The pod is single-use, so recovery is a
1051
1081
  # 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)
1082
+ SandboxUnusableError.new("this Computer sandbox cannot be attached again; start the Computer again (#{problem}): #{message}", status: status, cause: err)
1053
1083
  when "/errors/bind-restore-failed"
1054
1084
  if message.include?("epoch")
1055
- ComputerAlreadyAttachedError.new("another runtime holds this Computer's epoch lease: #{message}", status: status, cause: err)
1085
+ ComputerAlreadyAttachedError.new("this Computer is already attached elsewhere: #{message}", status: status, cause: err)
1056
1086
  else
1057
1087
  SavedStateUnavailableError.new("restoring saved state failed during bind: #{message}", status: status, cause: err)
1058
1088
  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