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.
- checksums.yaml +4 -4
- data/LICENSE +201 -0
- data/README.md +222 -161
- data/lib/pinesandbox/adapters/coordinator_adapter.rb +90 -114
- data/lib/pinesandbox/agent_events.rb +2 -2
- data/lib/pinesandbox/agent_turn.rb +25 -2
- data/lib/pinesandbox/artifacts.rb +776 -0
- data/lib/pinesandbox/attach_credentials_source.rb +58 -16
- data/lib/pinesandbox/bind_hpke.rb +4 -4
- data/lib/pinesandbox/client.rb +75 -37
- data/lib/pinesandbox/computer.rb +146 -116
- data/lib/pinesandbox/computer_binding.rb +14 -10
- data/lib/pinesandbox/control_plane.rb +10 -10
- data/lib/pinesandbox/error_taxonomy.rb +3 -0
- data/lib/pinesandbox/errors.rb +83 -76
- data/lib/pinesandbox/models/artifact.rb +1 -1
- data/lib/pinesandbox/models/artifact_file.rb +40 -0
- data/lib/pinesandbox/models/computer_session.rb +7 -3
- data/lib/pinesandbox/operations.rb +2 -3
- data/lib/pinesandbox/{runtime_details.rb → sandbox_details.rb} +4 -4
- data/lib/pinesandbox/{runtime_handle.rb → sandbox_handle.rb} +16 -20
- data/lib/pinesandbox/session.rb +39 -4
- data/lib/pinesandbox/spec_version_middleware.rb +1 -1
- data/lib/pinesandbox/streamed_read.rb +80 -0
- data/lib/pinesandbox/version.rb +3 -1
- data/lib/pinesandbox/zone.rb +8 -11
- data/lib/pinesandbox.rb +4 -2
- data/pinesandbox.gemspec +5 -3
- metadata +11 -6
data/lib/pinesandbox/computer.rb
CHANGED
|
@@ -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 (
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
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
|
|
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 (
|
|
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
|
-
#
|
|
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-
|
|
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.
|
|
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-
|
|
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, :
|
|
60
|
+
attr_reader :id, :binding_revision, :last_authorized_sandbox_id, :location
|
|
60
61
|
|
|
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
|
|
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
|
-
#
|
|
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
|
|
192
|
-
#
|
|
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
|
|
196
|
-
#
|
|
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
|
|
200
|
-
#
|
|
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
|
-
@
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
263
|
-
# starts, so retry until a wall-clock DEADLINE — a cold
|
|
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
|
|
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-
|
|
328
|
-
# or re-binding. For a trusted backend that persisted `{id, key,
|
|
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
|
|
332
|
-
# already attached; use Client#start_computer to
|
|
333
|
-
|
|
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
|
-
|
|
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(
|
|
345
|
+
@coordinator = Adapters::CoordinatorAdapter.new(sandbox_id: sandbox_id, zone: config.zone,
|
|
350
346
|
discovery_token: computer_token, clock: @clock)
|
|
351
|
-
@sandbox =
|
|
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
|
-
|
|
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
|
-
#
|
|
399
|
-
#
|
|
400
|
-
# Ensures the named
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
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
|
|
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
|
|
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-
|
|
483
|
-
# CURRENT
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
589
|
-
#
|
|
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
|
|
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
|
|
653
|
+
# clears a binding, resumes memory or starts the Computer again.
|
|
630
654
|
def status
|
|
631
|
-
raise BindError, "Computer has no
|
|
655
|
+
raise BindError, "Computer has no binding" unless @sandbox
|
|
632
656
|
|
|
633
657
|
@sandbox.status
|
|
634
658
|
end
|
|
635
659
|
|
|
636
|
-
# Observe this
|
|
637
|
-
# paused.
|
|
638
|
-
|
|
639
|
-
|
|
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.
|
|
642
|
-
|
|
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
|
|
671
|
+
# observe sandbox_details before deciding whether another edit is needed.
|
|
647
672
|
def set_idle_timeout(seconds:)
|
|
648
|
-
raise BindError, "Computer has no
|
|
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
|
-
|
|
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
|
|
682
|
+
raise BindError, "Computer has no binding" unless @sandbox && @computer_token
|
|
658
683
|
|
|
659
|
-
Binding.new(computer_id: 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 (
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
@
|
|
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-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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("
|
|
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
|
|
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
|