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.
- checksums.yaml +4 -4
- data/LICENSE +201 -0
- data/README.md +159 -157
- data/lib/pinesandbox/adapters/coordinator_adapter.rb +99 -74
- data/lib/pinesandbox/agent_events.rb +2 -2
- data/lib/pinesandbox/agent_turn.rb +24 -2
- data/lib/pinesandbox/attach_credentials_source.rb +13 -13
- data/lib/pinesandbox/bind_hpke.rb +4 -4
- data/lib/pinesandbox/client.rb +59 -36
- data/lib/pinesandbox/computer.rb +108 -108
- data/lib/pinesandbox/computer_binding.rb +14 -10
- data/lib/pinesandbox/control_plane.rb +10 -10
- data/lib/pinesandbox/error_taxonomy.rb +1 -0
- data/lib/pinesandbox/errors.rb +56 -76
- data/lib/pinesandbox/models/artifact.rb +1 -1
- data/lib/pinesandbox/operations.rb +0 -3
- data/lib/pinesandbox/{runtime_details.rb → sandbox_details.rb} +4 -4
- data/lib/pinesandbox/{runtime_handle.rb → sandbox_handle.rb} +14 -19
- data/lib/pinesandbox/session.rb +22 -2
- data/lib/pinesandbox/spec_version_middleware.rb +1 -1
- data/lib/pinesandbox/version.rb +1 -1
- data/lib/pinesandbox/zone.rb +8 -11
- data/lib/pinesandbox.rb +2 -2
- data/pinesandbox.gemspec +5 -3
- metadata +8 -6
data/lib/pinesandbox/computer.rb
CHANGED
|
@@ -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 (
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
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(
|
|
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 (
|
|
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
|
-
#
|
|
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-
|
|
41
|
-
# the gap between
|
|
42
|
-
# serving; the bind handshake retries not-ready responses (404 /
|
|
43
|
-
# transient transport errors) within it. Generous — a healthy
|
|
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-
|
|
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, :
|
|
59
|
+
attr_reader :id, :binding_revision, :last_authorized_sandbox_id, :location
|
|
60
60
|
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
#
|
|
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
|
|
192
|
-
#
|
|
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
|
|
196
|
-
#
|
|
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
|
|
200
|
-
#
|
|
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
|
-
@
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
263
|
-
# starts, so retry until a wall-clock DEADLINE — a cold
|
|
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
|
|
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-
|
|
328
|
-
# or re-binding. For a trusted backend that persisted `{id, key,
|
|
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
|
|
332
|
-
# already attached; use Client#start_computer to
|
|
333
|
-
|
|
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
|
-
|
|
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(
|
|
344
|
+
@coordinator = Adapters::CoordinatorAdapter.new(sandbox_id: sandbox_id, zone: config.zone,
|
|
350
345
|
discovery_token: computer_token, clock: @clock)
|
|
351
|
-
@sandbox =
|
|
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
|
-
#
|
|
399
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
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
|
|
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
|
|
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-
|
|
483
|
-
# CURRENT
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
589
|
-
#
|
|
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
|
|
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
|
|
623
|
+
# clears a binding, resumes memory or starts the Computer again.
|
|
630
624
|
def status
|
|
631
|
-
raise BindError, "Computer has no
|
|
625
|
+
raise BindError, "Computer has no binding" unless @sandbox
|
|
632
626
|
|
|
633
627
|
@sandbox.status
|
|
634
628
|
end
|
|
635
629
|
|
|
636
|
-
# Observe this
|
|
637
|
-
# paused.
|
|
638
|
-
|
|
639
|
-
|
|
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.
|
|
642
|
-
|
|
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
|
|
641
|
+
# observe sandbox_details before deciding whether another edit is needed.
|
|
647
642
|
def set_idle_timeout(seconds:)
|
|
648
|
-
raise BindError, "Computer has no
|
|
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
|
-
|
|
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
|
|
652
|
+
raise BindError, "Computer has no binding" unless @sandbox && @computer_token
|
|
658
653
|
|
|
659
|
-
Binding.new(computer_id: 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 (
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
@
|
|
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-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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("
|
|
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
|
|
6
|
-
# application storage. Importing it neither contacts nor
|
|
7
|
-
|
|
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 =
|
|
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"]
|
|
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"],
|
|
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
|
|
27
|
-
raise ArgumentError, "binding
|
|
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:,
|
|
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}
|
|
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 "
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
228
|
-
entrypoint: payload["entrypoint"], status:
|
|
229
|
-
expires_at:
|
|
230
|
-
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
|
|
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
|
|
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,
|