pinesandbox 0.0.1 → 0.3.18
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/Gemfile +3 -0
- data/README.md +668 -4
- data/Rakefile +5 -0
- data/lib/pinesandbox/adapters/coordinator_adapter.rb +1374 -0
- data/lib/pinesandbox/adapters/snapshot_adapter.rb +37 -0
- data/lib/pinesandbox/agent_events.rb +213 -0
- data/lib/pinesandbox/agent_mode.rb +252 -0
- data/lib/pinesandbox/agent_turn.rb +75 -0
- data/lib/pinesandbox/attach_credentials_source.rb +392 -0
- data/lib/pinesandbox/author_terminal_translator.rb +96 -0
- data/lib/pinesandbox/bind_hpke.rb +54 -0
- data/lib/pinesandbox/capture_keypair.rb +43 -0
- data/lib/pinesandbox/client.rb +446 -0
- data/lib/pinesandbox/clock.rb +51 -0
- data/lib/pinesandbox/computer.rb +1075 -0
- data/lib/pinesandbox/computer_binding.rb +47 -0
- data/lib/pinesandbox/computer_credentials.rb +175 -0
- data/lib/pinesandbox/connection.rb +26 -0
- data/lib/pinesandbox/control_events.rb +156 -0
- data/lib/pinesandbox/control_plane.rb +341 -0
- data/lib/pinesandbox/control_state.rb +31 -0
- data/lib/pinesandbox/control_token_source.rb +232 -0
- data/lib/pinesandbox/delegated_connection.rb +46 -0
- data/lib/pinesandbox/diagnostics.rb +83 -0
- data/lib/pinesandbox/drive_mode.rb +67 -0
- data/lib/pinesandbox/error_taxonomy.rb +104 -0
- data/lib/pinesandbox/errors.rb +525 -0
- data/lib/pinesandbox/exec_stream_parser.rb +90 -0
- data/lib/pinesandbox/models/artifact.rb +51 -0
- data/lib/pinesandbox/models/computer_session.rb +57 -0
- data/lib/pinesandbox/models/computer_tab.rb +30 -0
- data/lib/pinesandbox/models/file_entry.rb +38 -0
- data/lib/pinesandbox/operations.rb +134 -0
- data/lib/pinesandbox/response_handler.rb +88 -0
- data/lib/pinesandbox/runtime_details.rb +25 -0
- data/lib/pinesandbox/runtime_handle.rb +252 -0
- data/lib/pinesandbox/session.rb +627 -0
- data/lib/pinesandbox/skills.rb +142 -0
- data/lib/pinesandbox/spec_version_middleware.rb +34 -0
- data/lib/pinesandbox/sse_frame_parser.rb +95 -0
- data/lib/pinesandbox/state_hpke.rb +57 -0
- data/lib/pinesandbox/version.rb +3 -0
- data/lib/pinesandbox/zone.rb +176 -0
- data/lib/pinesandbox.rb +40 -2
- data/pinesandbox.gemspec +47 -0
- metadata +155 -10
|
@@ -0,0 +1,1075 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "json"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "digest"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "connection"
|
|
7
|
+
require_relative "bind_hpke"
|
|
8
|
+
require_relative "capture_keypair"
|
|
9
|
+
require_relative "adapters/coordinator_adapter"
|
|
10
|
+
require_relative "adapters/snapshot_adapter"
|
|
11
|
+
require_relative "session"
|
|
12
|
+
|
|
13
|
+
module PineSandbox
|
|
14
|
+
# A persistent Computer — the one object in this SDK. It is either
|
|
15
|
+
# powered-off (just an `id` + the per-Computer `key` you hold in your DB)
|
|
16
|
+
# or powered-on (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.
|
|
21
|
+
#
|
|
22
|
+
# computer = PineSandbox::Computer.new("0190…", key: secret_32_bytes)
|
|
23
|
+
# computer.attach(connection)
|
|
24
|
+
# session = computer.create_session(name: "main", browser: true)
|
|
25
|
+
# session.create_tab(url: "https://example.com")
|
|
26
|
+
# computer.stop
|
|
27
|
+
#
|
|
28
|
+
# `id`, `key`, and the asymmetric capture keypair are the caller-owned
|
|
29
|
+
# per-Computer inputs; the attach authorizer (the pk_-backed portal
|
|
30
|
+
# attach-credentials provider) is set once on the connection. The API key
|
|
31
|
+
# alone cannot read state (that needs the private key material).
|
|
32
|
+
#
|
|
33
|
+
# Not thread-safe: a single Computer instance carries one binding (runtime
|
|
34
|
+
# + ct_); drive one Computer from one thread at a time.
|
|
35
|
+
class Computer
|
|
36
|
+
CURRENT_KEY_VERSION = 1
|
|
37
|
+
DEFAULT_MAX_BIND_ATTEMPTS = 3
|
|
38
|
+
# Allocation and runtime readiness; independent of the sandbox lifetime.
|
|
39
|
+
DEFAULT_READY_TIMEOUT = 300
|
|
40
|
+
# Wall-clock budget for a just-allocated runtime to become bindable. Spans
|
|
41
|
+
# the gap between allocation reporting "running" and the Computer actually
|
|
42
|
+
# serving; the bind handshake retries not-ready responses (404 / 503 /
|
|
43
|
+
# transient transport errors) within it. Generous — a healthy runtime
|
|
44
|
+
# clears it in seconds; this only bounds one that never comes up.
|
|
45
|
+
DEFAULT_BIND_READY_TIMEOUT = 120
|
|
46
|
+
# The Computer id is a lowercase UUIDv7; a non-matching id would be
|
|
47
|
+
# rejected late as a BindAuthError, so reject it up front instead.
|
|
48
|
+
UUID_V7 = /\A[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/.freeze
|
|
49
|
+
|
|
50
|
+
# Transient transport failures worth retrying the bind handshake on, in
|
|
51
|
+
# both raw Faraday and SDK shapes. A just-allocated runtime may not be
|
|
52
|
+
# accepting connections yet (connection refused / TLS not up) — the same
|
|
53
|
+
# convergence window as 404/503.
|
|
54
|
+
TRANSIENT_BIND_ERRORS = [
|
|
55
|
+
Faraday::TimeoutError, Faraday::ConnectionFailed, Faraday::SSLError,
|
|
56
|
+
PineSandbox::TimeoutError, PineSandbox::ConnectionError
|
|
57
|
+
].freeze
|
|
58
|
+
|
|
59
|
+
attr_reader :id, :binding_revision, :last_authorized_runtime_id, :location
|
|
60
|
+
|
|
61
|
+
# Deprecated: use #last_authorized_runtime_id.
|
|
62
|
+
def last_authorized_sandbox_id
|
|
63
|
+
PineSandbox.deprecated_name("Computer#last_authorized_sandbox_id", "#last_authorized_runtime_id")
|
|
64
|
+
last_authorized_runtime_id
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The runtime this Computer is attached to, or nil when it has none.
|
|
68
|
+
def runtime_id = @sandbox&.id
|
|
69
|
+
|
|
70
|
+
# @api private — internal plumbing, NOT a supported end-user surface. It is
|
|
71
|
+
# public only because `Session` reaches it via `parent.coordinator` (Ruby
|
|
72
|
+
# needs a public accessor for that cross-object call) and the wire-level
|
|
73
|
+
# OpenAPI conformance test drives raw routes through it. Use the typed
|
|
74
|
+
# Session (ps_) / Computer (ct_) methods, which attach the right token.
|
|
75
|
+
# Exposing it is NOT a privilege bypass: the Computer authorizes every
|
|
76
|
+
# route by token class (`/exec` is ps_-only; ps_ is bound to its own
|
|
77
|
+
# session), so a raw call here can only do what the caller's own token
|
|
78
|
+
# already permits.
|
|
79
|
+
attr_reader :coordinator
|
|
80
|
+
|
|
81
|
+
# A fresh Computer identity: a UUIDv7 `id` + a 32-byte state `key` — the SAME
|
|
82
|
+
# pair create_computer generates internally, exposed as the SDK's built-in
|
|
83
|
+
# generator so a client backend can mint + PERSIST them up front (crash-safe,
|
|
84
|
+
# and the required id format is guaranteed) and pass them into
|
|
85
|
+
# create_computer. The SDK never stores them; the integrator owns both (the
|
|
86
|
+
# `id` to address/adopt the Computer, the `key` to decrypt restored state).
|
|
87
|
+
# `ephemeral: true` mints an access-lease-only identity — no capture
|
|
88
|
+
# keypair, since an ephemeral Computer persists nothing.
|
|
89
|
+
def self.generate_credentials(ephemeral: false)
|
|
90
|
+
Credentials.new(
|
|
91
|
+
id: SecureRandom.uuid_v7,
|
|
92
|
+
key: SecureRandom.random_bytes(32),
|
|
93
|
+
capture_keypair: ephemeral ? nil : CaptureKeypair.generate(generation: 1),
|
|
94
|
+
ephemeral: ephemeral
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @param id [String] lowercase UUIDv7 — a non-secret, stable handle.
|
|
99
|
+
# @param key [String] 32 raw bytes, the per-Computer secret (REQUIRED).
|
|
100
|
+
# @param ephemeral [Boolean] when true the Computer binds an access lease
|
|
101
|
+
# ONLY — no state persistence: it submits no capture public key, seals no
|
|
102
|
+
# computer_key into the bind payload, carries no key_assertion, and #stop
|
|
103
|
+
# skips the pre-stop checkpoint. Default false is the persistent behavior.
|
|
104
|
+
def initialize(id, key:, binding_revision: 0, ephemeral: false)
|
|
105
|
+
raise ArgumentError, "id required" if id.to_s.empty?
|
|
106
|
+
raise ArgumentError, "id must be a lowercase UUIDv7" unless id.to_s.match?(UUID_V7)
|
|
107
|
+
raise ArgumentError, "key must be 32 raw bytes" unless key.is_a?(String) && key.bytesize == 32
|
|
108
|
+
unless binding_revision.is_a?(Integer) && binding_revision >= 0
|
|
109
|
+
raise ArgumentError, "binding_revision must be a non-negative integer"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
@id = id.to_s
|
|
113
|
+
@key = key
|
|
114
|
+
@ephemeral = ephemeral ? true : false
|
|
115
|
+
@prior_keys = {} # version(Integer) => 32 raw bytes; rotation only
|
|
116
|
+
@capture_keypairs = {} # generation(Integer) => CaptureKeypair; asymmetric envelope v3
|
|
117
|
+
@capture_generation = 0
|
|
118
|
+
@binding_revision = binding_revision
|
|
119
|
+
reset_bound_state
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Register a prior key version so restore can decrypt a snapshot captured
|
|
123
|
+
# before a key rotation. Opt-in; steady state never calls
|
|
124
|
+
# it. Returns self for chaining.
|
|
125
|
+
def add_prior_key(version, bytes)
|
|
126
|
+
raise ArgumentError, "version must be an Integer" unless version.is_a?(Integer)
|
|
127
|
+
raise ArgumentError, "version must differ from the current key version" if version == CURRENT_KEY_VERSION
|
|
128
|
+
raise ArgumentError, "bytes must be 32 raw bytes" unless bytes.is_a?(String) && bytes.bytesize == 32
|
|
129
|
+
|
|
130
|
+
@prior_keys[version] = bytes
|
|
131
|
+
self
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Opt this Computer into asymmetric state encryption (component envelope v3):
|
|
135
|
+
# keypair.pk is submitted at the next attach, the platform seals every
|
|
136
|
+
# capture to it, and the bind runs the two-round restore (this SDK
|
|
137
|
+
# unwraps the per-component secrets with keypair.sk). Becomes the
|
|
138
|
+
# CURRENT generation; previously set generations stay registered for
|
|
139
|
+
# restores of pre-rotation state. Returns self for chaining.
|
|
140
|
+
def set_capture_keypair(keypair)
|
|
141
|
+
raise ArgumentError, "keypair must be a PineSandbox::CaptureKeypair" unless keypair.is_a?(CaptureKeypair)
|
|
142
|
+
if keypair.generation < @capture_generation
|
|
143
|
+
raise ArgumentError, "capture keypair generation #{keypair.generation} below the current generation #{@capture_generation} (rotations only move forward)"
|
|
144
|
+
end
|
|
145
|
+
existing = @capture_keypairs[keypair.generation]
|
|
146
|
+
if existing && (existing.pk != keypair.pk || existing.sk != keypair.sk)
|
|
147
|
+
raise ArgumentError, "capture keypair generation #{keypair.generation} is already registered with different key material"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
@capture_keypairs[keypair.generation] = keypair
|
|
151
|
+
@capture_generation = keypair.generation
|
|
152
|
+
self
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def capture_keypair
|
|
156
|
+
@capture_keypairs[@capture_generation]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Caller-owned credentials, independent of this live handle. Save them before
|
|
160
|
+
# Create, and again only if you change keys. Start reads the binding itself.
|
|
161
|
+
def credentials
|
|
162
|
+
current = capture_keypair
|
|
163
|
+
Credentials.new(
|
|
164
|
+
id: @id.dup, key: @key.dup, ephemeral: @ephemeral,
|
|
165
|
+
capture_keypair: current&.dup,
|
|
166
|
+
prior_capture_keypairs: @capture_keypairs.values.reject { |pair| pair.equal?(current) }.map(&:dup),
|
|
167
|
+
prior_keys: @prior_keys.to_h { |version, bytes| [version, bytes.dup] }
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Register a SUPERSEDED capture keypair so a restore of state sealed
|
|
172
|
+
# under it can still unwrap its secrets. Never changes the current
|
|
173
|
+
# generation. Returns self for chaining.
|
|
174
|
+
def add_prior_capture_keypair(keypair)
|
|
175
|
+
raise ArgumentError, "keypair must be a PineSandbox::CaptureKeypair" unless keypair.is_a?(CaptureKeypair)
|
|
176
|
+
if @capture_generation.positive? && keypair.generation >= @capture_generation
|
|
177
|
+
raise ArgumentError, "prior capture keypair generation #{keypair.generation} must be below the current generation #{@capture_generation}"
|
|
178
|
+
end
|
|
179
|
+
existing = @capture_keypairs[keypair.generation]
|
|
180
|
+
if existing && (existing.pk != keypair.pk || existing.sk != keypair.sk)
|
|
181
|
+
raise ArgumentError, "capture keypair generation #{keypair.generation} is already registered with different key material"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
@capture_keypairs[keypair.generation] = keypair
|
|
185
|
+
self
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Allocate a runtime and bind this Computer to it, restoring its state.
|
|
189
|
+
# Returns self (now powered-on). Safe across its internal retries —
|
|
190
|
+
# 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
|
|
194
|
+
# 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.
|
|
197
|
+
#
|
|
198
|
+
# @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.
|
|
201
|
+
# @param pod_env [nil, Hash] Deprecated; only nil or an empty hash is accepted.
|
|
202
|
+
def attach(config, timeout: 300, ready_timeout: DEFAULT_READY_TIMEOUT, pod_env: nil, metadata: nil,
|
|
203
|
+
location: nil,
|
|
204
|
+
max_bind_attempts: DEFAULT_MAX_BIND_ATTEMPTS,
|
|
205
|
+
bind_ready_timeout: DEFAULT_BIND_READY_TIMEOUT)
|
|
206
|
+
unless config.respond_to?(:attach_provider) && config.respond_to?(:control_plane) && config.respond_to?(:zone)
|
|
207
|
+
raise ArgumentError, "config must be a PineSandbox::Connection (got #{config.class})"
|
|
208
|
+
end
|
|
209
|
+
unless max_bind_attempts.is_a?(Integer) && max_bind_attempts.positive?
|
|
210
|
+
raise ArgumentError, "max_bind_attempts must be a positive integer"
|
|
211
|
+
end
|
|
212
|
+
unless pod_env.nil? || pod_env == {}
|
|
213
|
+
raise ArgumentError, "pod_env is unsupported for Computer attach; omit it or pass an empty hash"
|
|
214
|
+
end
|
|
215
|
+
unless (ready_timeout.is_a?(Integer) || ready_timeout.is_a?(Float)) && ready_timeout.finite? && ready_timeout.positive?
|
|
216
|
+
raise ArgumentError, "ready_timeout must be a positive finite number of seconds"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Refuse to re-attach a live Computer: silently tearing down the prior pod
|
|
220
|
+
# here would force-kill it (ungraceful) and lose state since the last
|
|
221
|
+
# checkpoint. Make the teardown an explicit caller choice — #stop (graceful,
|
|
222
|
+
# persist) or #kill (discard) — then attach again.
|
|
223
|
+
raise BindError.new("Computer already attached — call #stop (graceful) or #kill first") if @sandbox
|
|
224
|
+
# A persistent attach requires the capture keypair (it seals every capture
|
|
225
|
+
# to it); an ephemeral Computer persists nothing, so it binds without one.
|
|
226
|
+
if @capture_generation.zero? && !@ephemeral
|
|
227
|
+
raise ArgumentError, "v3 attach requires a capture_keypair (persist both halves)"
|
|
228
|
+
end
|
|
229
|
+
# Preserve omission as omission. @location is only the last Portal
|
|
230
|
+
# receipt, not authority to overwrite a newer country another client may
|
|
231
|
+
# have committed after this instance stopped.
|
|
232
|
+
requested_location = normalize_location(location) if location
|
|
233
|
+
@last_authorized_runtime_id = nil
|
|
234
|
+
@connection = config
|
|
235
|
+
@clock = config.respond_to?(:clock) ? config.clock : PineSandbox::SYSTEM_CLOCK
|
|
236
|
+
|
|
237
|
+
# Provision via the project-scoped control plane (api.<zone>, project JWS).
|
|
238
|
+
create_id = SecureRandom.uuid
|
|
239
|
+
ready_deadline = monotonic_now + ready_timeout
|
|
240
|
+
info = config.control_plane.create_computer(
|
|
241
|
+
body: build_computer_create_body(timeout: timeout, metadata: metadata).merge(create_id: create_id),
|
|
242
|
+
request_timeout: ready_timeout
|
|
243
|
+
)
|
|
244
|
+
@sandbox = RuntimeHandle.new(control_plane: config.control_plane, runtime_id: info.id, status: info.status)
|
|
245
|
+
# Wait for the pod to report Running (a CUA pod's readiness; the bind handshake
|
|
246
|
+
# below is the functional coord-readiness check). A pod that fails or never
|
|
247
|
+
# becomes Ready is destroyed before the typed error leaves #attach.
|
|
248
|
+
begin
|
|
249
|
+
@sandbox.wait_until_running(timeout: [ready_deadline - monotonic_now, 0].max)
|
|
250
|
+
rescue StandardError
|
|
251
|
+
reset_bound_state
|
|
252
|
+
raise
|
|
253
|
+
end
|
|
254
|
+
# Drive the Computer at its runtime's gateway host `https://<runtime_id>.computer.<zone>`
|
|
255
|
+
# — 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)
|
|
257
|
+
|
|
258
|
+
# Two retry budgets converge on the bind handshake:
|
|
259
|
+
# - readiness (DEADLINE-bounded): the runtime was just allocated, so the
|
|
260
|
+
# gateway can't reach a serving Computer yet — 404 (not yet routable),
|
|
261
|
+
# 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
|
|
264
|
+
# slow restore can outlast a few tries, and attempt-counting gives up early.
|
|
265
|
+
# - restore restart (ATTEMPT-bounded): if coord's short-lived two-round
|
|
266
|
+
# challenge expires, clear only restore_secrets and restart round one
|
|
267
|
+
# with the same committed token/JTI/ciphertext. No Portal re-mint.
|
|
268
|
+
# Terminal bind errors (pod identity shift, epoch conflict, pod-tainted, auth, coord's own
|
|
269
|
+
# problem+json 503 bind-not-configured) never retry: the pod is
|
|
270
|
+
# single-use, so recovery is a fresh #attach on a fresh pod.
|
|
271
|
+
deadline = monotonic_now + bind_ready_timeout
|
|
272
|
+
restore_attempts = 1
|
|
273
|
+
converge_waits = 0
|
|
274
|
+
begin
|
|
275
|
+
raise BindTimeoutError.new("Computer did not become bindable within its bind budget") if monotonic_now >= deadline
|
|
276
|
+
run_bind(config, requested_location: requested_location)
|
|
277
|
+
rescue PineSandbox::ApiError => e
|
|
278
|
+
if coord_not_ready_via_ingress?(e) || transient_bind_restore?(e)
|
|
279
|
+
if monotonic_now < deadline
|
|
280
|
+
converge_waits += 1
|
|
281
|
+
sleep(bind_convergence_backoff(converge_waits))
|
|
282
|
+
retry
|
|
283
|
+
end
|
|
284
|
+
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)
|
|
286
|
+
elsif restore_no_challenge?(e) && restore_attempts < max_bind_attempts
|
|
287
|
+
# Only coord's short-lived restore challenge expired. Reuse the one
|
|
288
|
+
# Portal authorization committed for this sandbox and restart round one.
|
|
289
|
+
restore_attempts += 1
|
|
290
|
+
@bind_envelope&.delete(:restore_secrets)
|
|
291
|
+
sleep(0.5 * (restore_attempts - 1))
|
|
292
|
+
retry
|
|
293
|
+
end
|
|
294
|
+
reset_bound_state
|
|
295
|
+
raise map_bind_error(e)
|
|
296
|
+
rescue *TRANSIENT_BIND_ERRORS => e
|
|
297
|
+
# A just-created pod's coord may not be listening yet (connection
|
|
298
|
+
# refused / TLS not up) — the same convergence window as 404/503,
|
|
299
|
+
# so bound by the same deadline rather than a few attempts.
|
|
300
|
+
if monotonic_now < deadline
|
|
301
|
+
converge_waits += 1
|
|
302
|
+
sleep(bind_convergence_backoff(converge_waits))
|
|
303
|
+
retry
|
|
304
|
+
end
|
|
305
|
+
reset_bound_state
|
|
306
|
+
raise BindTimeoutError.new("bind handshake did not complete within #{bind_ready_timeout}s: #{e.class}: #{e.message}", cause: e)
|
|
307
|
+
rescue StandardError
|
|
308
|
+
reset_bound_state
|
|
309
|
+
raise
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
self
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# attach + auto-stop. Yields self; stops the Computer (saving supported
|
|
316
|
+
# state) when the block returns or raises.
|
|
317
|
+
def with_attached(config, **opts)
|
|
318
|
+
PineSandbox.deprecated_name("Computer#with_attached", "Client#with_computer")
|
|
319
|
+
attach(config, **opts)
|
|
320
|
+
yield self
|
|
321
|
+
ensure
|
|
322
|
+
# Graceful by default: persist state on the way out (no-op if attach
|
|
323
|
+
# already cleaned up after a bind failure — @sandbox is nil then).
|
|
324
|
+
stop
|
|
325
|
+
end
|
|
326
|
+
|
|
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,
|
|
329
|
+
# ct_}` and needs to drive across processes/requests (e.g. re-mint a desktop
|
|
330
|
+
# `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,
|
|
335
|
+
binding_revision: @binding_revision)
|
|
336
|
+
raise BindError.new("Computer already attached — call #stop or #kill first") if @sandbox
|
|
337
|
+
unless config.respond_to?(:control_plane) && config.respond_to?(:zone)
|
|
338
|
+
raise ArgumentError, "config must be a PineSandbox::Connection (control_plane + zone)"
|
|
339
|
+
end
|
|
340
|
+
runtime_id ||= sandbox_id
|
|
341
|
+
raise ArgumentError, "runtime_id required" if runtime_id.to_s.empty?
|
|
342
|
+
raise ArgumentError, "computer_token required" if computer_token.to_s.empty?
|
|
343
|
+
unless binding_revision.is_a?(Integer) && binding_revision >= 0
|
|
344
|
+
raise ArgumentError, "binding_revision must be a non-negative integer"
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
@connection = config
|
|
348
|
+
@clock = config.respond_to?(:clock) ? config.clock : PineSandbox::SYSTEM_CLOCK
|
|
349
|
+
@coordinator = Adapters::CoordinatorAdapter.new(runtime_id: runtime_id, zone: config.zone,
|
|
350
|
+
discovery_token: computer_token, clock: @clock)
|
|
351
|
+
@sandbox = RuntimeHandle.new(control_plane: config.control_plane, runtime_id: runtime_id, status: status)
|
|
352
|
+
@computer_token = computer_token
|
|
353
|
+
@binding_revision = binding_revision
|
|
354
|
+
self
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# The Computer-tier token (`ct_*`) delivered in the bind response. Raises
|
|
358
|
+
# if called before #attach (or after #stop).
|
|
359
|
+
def computer_token
|
|
360
|
+
@computer_token || raise(BindError.new("Computer is not attached — call #attach first"))
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# INFORMATIONAL ONLY: the `expires_at` returned with the ct_ in the bind
|
|
364
|
+
# response. Deprecated: ct_ is binding-lifetime (no idle expiry, no
|
|
365
|
+
# renewal), so current Computers omit the field and this is nil.
|
|
366
|
+
# The SDK keeps no timers and never refreshes either way. nil on adopted
|
|
367
|
+
# Computers too (the bind response isn't available there).
|
|
368
|
+
attr_reader :computer_token_expires_at
|
|
369
|
+
|
|
370
|
+
# The persistence mode recorded for this binding, read back from the
|
|
371
|
+
# bind response ("ephemeral" for an access-lease-only Computer). A response that
|
|
372
|
+
# omits the field leaves this nil; adopted Computers (no bind response) are
|
|
373
|
+
# nil too. Informational — the SDK acts on its own @ephemeral intent.
|
|
374
|
+
attr_reader :persistence_mode
|
|
375
|
+
|
|
376
|
+
# Whether this Computer was bound as ephemeral (access-lease only, no state
|
|
377
|
+
# persistence). Set at construction; the persistent default is false.
|
|
378
|
+
def ephemeral?
|
|
379
|
+
@ephemeral
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# --- driving the powered-on Computer (sessions hold the `ps_*` token) ---
|
|
383
|
+
|
|
384
|
+
# `name` is optional: omit it (or pass nil/"") to let the Computer mint a
|
|
385
|
+
# friendly adjective-noun-hex name (e.g. "brave-otter-9f3a1c") — read it back
|
|
386
|
+
# off the returned {Session}. `label` is an optional human-meaningful tag,
|
|
387
|
+
# 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
|
+
rebind_on_unauthorized do
|
|
391
|
+
payload = coordinator!.create_session(
|
|
392
|
+
token: computer_token, name: name, label: label, browser: browser, blind: blind
|
|
393
|
+
)
|
|
394
|
+
Session.new(parent: self, payload: payload)
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
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
|
|
401
|
+
# browser-safe {DelegatedConnection} (host + session name + spec major +
|
|
402
|
+
# a fresh short-TTL `dt_`; no `ct_`/`ps_`/JWS). `create: true` (default) creates
|
|
403
|
+
# the session if absent; `create: false` requires it. To refresh the `dt_`
|
|
404
|
+
# before a reconnect, call `session(name).delegate` again (server-side).
|
|
405
|
+
def delegate_desktop(name: "main", create: true, browser: true)
|
|
406
|
+
s =
|
|
407
|
+
begin
|
|
408
|
+
session(name)
|
|
409
|
+
rescue PineSandbox::NotFoundError
|
|
410
|
+
raise unless create
|
|
411
|
+
|
|
412
|
+
create_session(name: name, browser: browser)
|
|
413
|
+
end
|
|
414
|
+
s.delegate
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def session(name)
|
|
418
|
+
rebind_on_unauthorized do
|
|
419
|
+
Session.new(parent: self, payload: coordinator!.get_session(token: computer_token, name: name))
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Reconstruct the same session from credentials saved with this binding.
|
|
424
|
+
# No request, session creation or token refresh; authorization is checked
|
|
425
|
+
# on first use. Metadata remains available through #session(name).
|
|
426
|
+
def adopt_session(name, session_token)
|
|
427
|
+
coordinator!
|
|
428
|
+
unless name.is_a?(String) && !name.empty? && session_token.is_a?(String) && !session_token.empty?
|
|
429
|
+
raise ArgumentError, "session name and token are required"
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
Session.new(parent: self, payload: { "name" => name, "token" => session_token })
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# computer.skills.* — the Computer's learned-skill lifecycle (Computer Skills): list/get the
|
|
436
|
+
# served skills, review pending drafts (with bodies), activate/deactivate. Skills are
|
|
437
|
+
# computer-global; the authoring TRIGGERS are session-scoped — see Session#learn / #teach.
|
|
438
|
+
def skills
|
|
439
|
+
@skills ||= Skills.new(self)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# capabilities — the product features this Computer can use for its binding, and the limits
|
|
443
|
+
# it enforces on your calls.
|
|
444
|
+
def capabilities
|
|
445
|
+
rebind_on_unauthorized { coordinator!.capabilities(token: computer_token) }
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Qualify the current public agent contract before a workflow changes
|
|
449
|
+
# control or submits work. Unsupported runtimes raise AgentProtocolUnsupported;
|
|
450
|
+
# binding-auth and transport failures retain their ordinary SDK meaning.
|
|
451
|
+
def require_agent_protocol!
|
|
452
|
+
rebind_on_unauthorized { coordinator!.require_agent_protocol!(token: computer_token) }
|
|
453
|
+
self
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def sessions
|
|
457
|
+
rebind_on_unauthorized do
|
|
458
|
+
coordinator!.list_sessions(token: computer_token).map { |payload| Session.new(parent: self, payload: payload) }
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def destroy_session(name, clean: false)
|
|
463
|
+
rebind_on_unauthorized do
|
|
464
|
+
coordinator!.destroy_session(token: computer_token, name: name, clean: clean)
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# --- control lease (drive ⇔ view), Computer-level facade ---
|
|
469
|
+
# The desktop stream forwards input only while a person holds control.
|
|
470
|
+
# A viewer (browser, dt_) can't take it; your backend brokers it here
|
|
471
|
+
# with the ct_. Hides the session lookup + etag handshake + actor_type so callers
|
|
472
|
+
# don't reach into Session. ct_-only, so it works on an adopted Computer.
|
|
473
|
+
# Returns the new ControlState.
|
|
474
|
+
def take_control(name: "main", actor_type: "user_click")
|
|
475
|
+
rebind_on_unauthorized { session(name).take_control(actor_type: actor_type) }
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def release_control(name: "main", actor_type: "user_click")
|
|
479
|
+
rebind_on_unauthorized { session(name).release_control(actor_type: actor_type) }
|
|
480
|
+
end
|
|
481
|
+
|
|
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
|
|
484
|
+
# stale one.
|
|
485
|
+
def snapshot
|
|
486
|
+
@snapshot ||= Adapters::SnapshotAdapter.new(coordinator: coordinator!, token_provider: -> { computer_token })
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Save a restore point NOW — a synchronous, durable capture of this
|
|
490
|
+
# Computer's cookie jar, returned once committed. This is the EXPLICIT
|
|
491
|
+
# durability primitive: call it when you want the current state guaranteed
|
|
492
|
+
# 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.
|
|
495
|
+
# Content-hash-gated: an unchanged state returns `{"skipped" => true}` and
|
|
496
|
+
# the prior snapshot stays latest. Returns the persisted snapshot
|
|
497
|
+
# descriptor.
|
|
498
|
+
#
|
|
499
|
+
# SCOPE: a live checkpoint captures the cookie jar (the identity carrier
|
|
500
|
+
# for most auth). localStorage is only captured with Chrome stopped
|
|
501
|
+
# (#stop / restart onto a fresh runtime), so a mid-session #checkpoint does not
|
|
502
|
+
# refresh it — the last stopped-capture value is preserved and restored.
|
|
503
|
+
def checkpoint
|
|
504
|
+
rebind_on_unauthorized { coordinator!.capture(token: computer_token) }
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Metadata-only inventory of durable custodial passkeys held by this
|
|
508
|
+
# Computer. The private keys remain inside the Computer and its encrypted state.
|
|
509
|
+
def passkeys
|
|
510
|
+
rebind_on_unauthorized { coordinator!.list_passkeys(token: computer_token) }
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def delete_passkey(credential_id)
|
|
514
|
+
raise ArgumentError, "credential_id is required" if credential_id.to_s.empty?
|
|
515
|
+
|
|
516
|
+
rebind_on_unauthorized do
|
|
517
|
+
coordinator!.delete_passkey(token: computer_token, credential_id: credential_id)
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Pending first-enrollment ceremonies require an explicit Computer-owner
|
|
522
|
+
# decision. Assertions for already-held credentials remain automatic.
|
|
523
|
+
def pending_passkey_ceremonies
|
|
524
|
+
rebind_on_unauthorized { coordinator!.list_passkey_ceremonies(token: computer_token) }
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def approve_passkey_ceremony(ceremony_id)
|
|
528
|
+
decide_passkey_ceremony(ceremony_id, "approve_custodial")
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def decline_passkey_ceremony(ceremony_id)
|
|
532
|
+
decide_passkey_ceremony(ceremony_id, "decline")
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# Browser tabs no session owns (for example a page a site opened in a new
|
|
536
|
+
# window after its session ended). Returns the response Hash.
|
|
537
|
+
def orphan_tabs
|
|
538
|
+
rebind_on_unauthorized { coordinator!.list_orphan_tabs(token: computer_token) }
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def close_orphan_tab(target_id)
|
|
542
|
+
raise ArgumentError, "target_id is required" if target_id.to_s.empty?
|
|
543
|
+
|
|
544
|
+
rebind_on_unauthorized { coordinator!.close_orphan_tab(token: computer_token, target_id: target_id) }
|
|
545
|
+
end
|
|
546
|
+
|
|
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
|
+
# Preserve the current sessions, capabilities and live process memory.
|
|
562
|
+
# These bounded calls wait for observed completion; neither rebinds.
|
|
563
|
+
def pause(timeout: 360)
|
|
564
|
+
raise ArgumentError, "Computer has no runtime" unless @sandbox
|
|
565
|
+
|
|
566
|
+
@sandbox.pause(timeout: timeout)
|
|
567
|
+
self
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def resume(timeout: 360)
|
|
571
|
+
raise ArgumentError, "Computer has no retained runtime" unless @sandbox
|
|
572
|
+
|
|
573
|
+
@sandbox.resume(timeout: timeout)
|
|
574
|
+
self
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# Stop is owned by the server after acceptance. A timeout only ends this
|
|
578
|
+
# wait; another backend using the saved handle can observe or retry it.
|
|
579
|
+
# true confirms compute release; final save is unknown after record removal.
|
|
580
|
+
def stop(wait_timeout: 390)
|
|
581
|
+
return true unless @sandbox
|
|
582
|
+
|
|
583
|
+
gone = @sandbox.stop(wait_timeout: wait_timeout)
|
|
584
|
+
clear_bound_state if gone
|
|
585
|
+
gone
|
|
586
|
+
end
|
|
587
|
+
|
|
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
|
|
590
|
+
# is the normal path. (Computers should always terminate gracefully; reach
|
|
591
|
+
# for #kill only to recover.)
|
|
592
|
+
#
|
|
593
|
+
# NOTE: there is intentionally no #destroy alias. The lifecycle verbs are
|
|
594
|
+
# distinct by intent: #stop saves supported state before shutdown;
|
|
595
|
+
# #kill is ungraceful force-recovery. The "done forever" verb —
|
|
596
|
+
# permanent delete, which ends the identity and purges saved state — is
|
|
597
|
+
# Client#delete_computer (it kills a still-attached Computer first).
|
|
598
|
+
def kill
|
|
599
|
+
return true unless @sandbox
|
|
600
|
+
|
|
601
|
+
deleted = @sandbox.kill
|
|
602
|
+
clear_bound_state if deleted
|
|
603
|
+
deleted
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# PERMANENTLY delete this Computer: its identity and saved state end (see
|
|
607
|
+
# Client#delete_computer). A still-attached runtime is killed first (no
|
|
608
|
+
# final save). Needs the connection this Computer was attached or adopted
|
|
609
|
+
# through; for an id alone use Client#delete_computer. Returns true.
|
|
610
|
+
def delete
|
|
611
|
+
provider = @connection&.attach_provider
|
|
612
|
+
unless provider.respond_to?(:delete_computer)
|
|
613
|
+
raise ComputerDeletionError,
|
|
614
|
+
@connection ? "computer deletion is unavailable from the custom attach credentials provider" :
|
|
615
|
+
"this Computer was never attached; delete it with Client#delete_computer(id)"
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
kill
|
|
619
|
+
provider.delete_computer(id)
|
|
620
|
+
true
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def alive?
|
|
624
|
+
@sandbox ? @sandbox.alive? : false
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Observe the saved binding through the lifecycle service. Unlike #alive?,
|
|
628
|
+
# absence and transport/auth failures remain distinct errors. This never
|
|
629
|
+
# clears a binding, resumes memory or allocates a replacement runtime.
|
|
630
|
+
def status
|
|
631
|
+
raise BindError, "Computer has no runtime binding" unless @sandbox
|
|
632
|
+
|
|
633
|
+
@sandbox.status
|
|
634
|
+
end
|
|
635
|
+
|
|
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
|
|
640
|
+
|
|
641
|
+
info = @sandbox.runtime_details
|
|
642
|
+
RuntimeDetails.from_runtime(info)
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
# An unchanged value is not a heartbeat. On conflict or a lost reply,
|
|
646
|
+
# observe runtime_details before deciding whether another edit is needed.
|
|
647
|
+
def set_idle_timeout(seconds:)
|
|
648
|
+
raise BindError, "Computer has no runtime binding" unless @sandbox
|
|
649
|
+
raise ArgumentError, "idle timeout must be an integer of at least 60 seconds" unless seconds.is_a?(Integer) && seconds >= 60
|
|
650
|
+
|
|
651
|
+
RuntimeDetails.from_runtime(@sandbox.set_idle_timeout(seconds: seconds))
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
# Save after Create/Start, alongside the durable credentials. Never log JSON:
|
|
655
|
+
# it contains the live Computer token. Stop clears this object's binding.
|
|
656
|
+
def binding
|
|
657
|
+
raise BindError, "Computer has no runtime binding" unless @sandbox && @computer_token
|
|
658
|
+
|
|
659
|
+
Binding.new(computer_id: id.dup, runtime_id: @sandbox.id.dup,
|
|
660
|
+
computer_token: @computer_token.dup, binding_revision: @binding_revision).validate!
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
# @api private — internal plumbing, NOT a supported end-user surface. Public
|
|
664
|
+
# only because the Computer's facade objects (e.g. Skills) wrap their own
|
|
665
|
+
# ct_-authenticated calls in the same 401→TokenRejectedError mapping (Ruby
|
|
666
|
+
# needs a public method for that cross-object call).
|
|
667
|
+
#
|
|
668
|
+
# 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 —
|
|
670
|
+
# rebind through portal attach-credentials, i.e. a plain re-attach — so
|
|
671
|
+
# surface a typed TokenRejectedError rather than a generic auth error.
|
|
672
|
+
# Deliberately NOT an automatic rebind: a rebind always lands on a FRESH
|
|
673
|
+
# runtime (each runtime binds once), invalidating every session/ps_ the
|
|
674
|
+
# caller holds, so the re-attach must be the caller's explicit move.
|
|
675
|
+
# The data-plane client already classifies its own responses by token
|
|
676
|
+
# (a ps_ 401 stays UnauthorizedError); this maps a 401 that a custom
|
|
677
|
+
# coordinator raises for the ct_ the block used.
|
|
678
|
+
def rebind_on_unauthorized
|
|
679
|
+
yield
|
|
680
|
+
rescue PineSandbox::UnauthorizedError => e
|
|
681
|
+
raise unless e.status.to_i == 401
|
|
682
|
+
|
|
683
|
+
raise TokenRejectedError.for_api_error(e)
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
private
|
|
687
|
+
|
|
688
|
+
def decide_passkey_ceremony(ceremony_id, decision)
|
|
689
|
+
raise ArgumentError, "ceremony_id is required" if ceremony_id.to_s.empty?
|
|
690
|
+
|
|
691
|
+
rebind_on_unauthorized do
|
|
692
|
+
coordinator!.decide_passkey_ceremony(
|
|
693
|
+
token: computer_token, ceremony_id: ceremony_id, decision: decision
|
|
694
|
+
)
|
|
695
|
+
end
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# The bound coordinator, or a clear error if powered-off. Keeps every
|
|
699
|
+
# drive method from NoMethodError-ing on a nil coordinator.
|
|
700
|
+
def coordinator!
|
|
701
|
+
@coordinator || raise(BindError.new("Computer is not attached — call #attach first"))
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# Ungraceful tear-down: best-effort kill (no wait) + drop the local binding.
|
|
705
|
+
# Used at attach-entry (re-attach), on bind failure, and by #kill. The nils
|
|
706
|
+
# always run so the SDK object never holds a half-binding. (#stop uses the
|
|
707
|
+
# graceful Sandbox#terminate instead, then clear_bound_state.)
|
|
708
|
+
def reset_bound_state
|
|
709
|
+
begin
|
|
710
|
+
@sandbox&.kill
|
|
711
|
+
rescue StandardError
|
|
712
|
+
nil
|
|
713
|
+
end
|
|
714
|
+
clear_bound_state
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
# Drop the local binding WITHOUT touching the pod — used after a graceful
|
|
718
|
+
# #stop has already terminated it (so we don't double-DELETE).
|
|
719
|
+
def clear_bound_state
|
|
720
|
+
@sandbox = nil
|
|
721
|
+
@coordinator = nil
|
|
722
|
+
@computer_token = nil
|
|
723
|
+
@computer_token_expires_at = nil
|
|
724
|
+
@persistence_mode = nil
|
|
725
|
+
@bind_envelope = nil
|
|
726
|
+
@snapshot = nil
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
# The caller-owned part of a Computer create. Runtime selection belongs to
|
|
730
|
+
# the project policy and is never serialized by this SDK.
|
|
731
|
+
def build_computer_create_body(timeout:, metadata:)
|
|
732
|
+
body = { timeout: timeout }
|
|
733
|
+
body[:metadata] = metadata if metadata
|
|
734
|
+
body
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
# Client bind sequence (AUTH_REDESIGN attach flow): fetch pubkey → call
|
|
738
|
+
# the portal attach-credentials provider with the pod coordinates →
|
|
739
|
+
# HPKE-seal {key, broker_grant} → POST the portal bind_token (no
|
|
740
|
+
# client-side signing, no hash attestations) → store ct_ + expires_at.
|
|
741
|
+
#
|
|
742
|
+
# Re-runnable, with one load-bearing rule: a CONVERGENCE retry (lost
|
|
743
|
+
# response, transient transport, bind-in-progress) re-POSTs the SAME
|
|
744
|
+
# minted envelope byte-for-byte. coord keys bind idempotency on
|
|
745
|
+
# (jti, ciphertext) — re-sending the identical pair after a committed
|
|
746
|
+
# bind replays the cached ct_, while a fresh mint (new jti) on a bound
|
|
747
|
+
# pod is a terminal 409. Portal authorizes a sandbox exactly once, so
|
|
748
|
+
# this method never re-mints after an envelope has been committed.
|
|
749
|
+
# Bounds the two-round restore's challenge-answer loop client-side.
|
|
750
|
+
# Coord's own generation cap (4) refuses first in any legitimate run;
|
|
751
|
+
# this is the local backstop against a server challenging forever.
|
|
752
|
+
MAX_RESTORE_ROUNDS = 6
|
|
753
|
+
|
|
754
|
+
def run_bind(config, requested_location:)
|
|
755
|
+
envelope = @bind_envelope ||= mint_bind_envelope(
|
|
756
|
+
config, requested_location: requested_location
|
|
757
|
+
)
|
|
758
|
+
@bind_envelope = envelope
|
|
759
|
+
|
|
760
|
+
restore_secrets = envelope[:restore_secrets]
|
|
761
|
+
rounds = 0
|
|
762
|
+
loop do
|
|
763
|
+
bind_kwargs = {
|
|
764
|
+
bind_token: envelope.fetch(:bind_token), pod_uid: envelope.fetch(:pod_uid),
|
|
765
|
+
coord_boot_id: envelope.fetch(:coord_boot_id), ciphertext: envelope.fetch(:ciphertext)
|
|
766
|
+
}
|
|
767
|
+
bind_kwargs[:key_assertion] = envelope[:key_assertion] if envelope[:key_assertion]
|
|
768
|
+
bind_kwargs[:restore_secrets] = restore_secrets if restore_secrets
|
|
769
|
+
response = @coordinator.bind(**bind_kwargs)
|
|
770
|
+
challenge = response["restore_challenge"]
|
|
771
|
+
unless challenge
|
|
772
|
+
@bind_envelope = nil
|
|
773
|
+
@computer_token = response.fetch("computer_token")
|
|
774
|
+
@computer_token_expires_at = response["expires_at"]
|
|
775
|
+
@persistence_mode = response["persistence_mode"]
|
|
776
|
+
return
|
|
777
|
+
end
|
|
778
|
+
# The two-round v3 restore: unwrap each component's secret with the
|
|
779
|
+
# matching keypair generation and re-send the SAME envelope with the
|
|
780
|
+
# sealed answer. Every resumable outcome (initial challenge,
|
|
781
|
+
# rechallenge after latest moved, a superseded answer) is another
|
|
782
|
+
# challenge on this same loop. The built answer is held ON the
|
|
783
|
+
# envelope so a convergence retry after a transport fault re-sends
|
|
784
|
+
# it verbatim (idempotent replay).
|
|
785
|
+
rounds += 1
|
|
786
|
+
raise BindError.new("bind restore did not converge after #{MAX_RESTORE_ROUNDS} challenges") if rounds > MAX_RESTORE_ROUNDS
|
|
787
|
+
|
|
788
|
+
restore_secrets = answer_restore_challenge(envelope, challenge)
|
|
789
|
+
envelope[:restore_secrets] = restore_secrets
|
|
790
|
+
end
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# Unwrap each challenged component's secret and seal the set to the
|
|
794
|
+
# pod's bind key under the RESTORE domain. Failures are terminal — a
|
|
795
|
+
# missing superseded generation or corrupt material never self-heals.
|
|
796
|
+
def answer_restore_challenge(envelope, challenge)
|
|
797
|
+
secrets = {}
|
|
798
|
+
challenge.fetch("components").each do |comp|
|
|
799
|
+
generation = comp.fetch("recipient_key_version")
|
|
800
|
+
keypair = @capture_keypairs[generation]
|
|
801
|
+
unless keypair
|
|
802
|
+
raise BindError.new(
|
|
803
|
+
"restore needs capture keypair generation #{generation} for component #{comp["component"]} — " \
|
|
804
|
+
"register the superseded keypair (add_prior_capture_keypair) or its state cannot be restored"
|
|
805
|
+
)
|
|
806
|
+
end
|
|
807
|
+
if keypair.fingerprint != comp.fetch("pk_fingerprint")
|
|
808
|
+
raise BindError.new(
|
|
809
|
+
"restore challenge for component #{comp["component"]} names fingerprint #{comp["pk_fingerprint"]} " \
|
|
810
|
+
"but keypair generation #{generation} has #{keypair.fingerprint} — wrong keypair registered"
|
|
811
|
+
)
|
|
812
|
+
end
|
|
813
|
+
begin
|
|
814
|
+
s = StateHPKE.open_component_secret(
|
|
815
|
+
keypair.sk,
|
|
816
|
+
Base64.urlsafe_decode64(comp.fetch("hpke_enc")),
|
|
817
|
+
Base64.urlsafe_decode64(comp.fetch("hpke_sealed_s")),
|
|
818
|
+
cid: @id, component: comp.fetch("component"), id: comp.fetch("id"),
|
|
819
|
+
attach_epoch: comp.fetch("attach_epoch"), key_version: generation,
|
|
820
|
+
fingerprint: comp.fetch("pk_fingerprint")
|
|
821
|
+
)
|
|
822
|
+
rescue KeyError
|
|
823
|
+
raise
|
|
824
|
+
rescue StandardError => e
|
|
825
|
+
raise BindError.new(
|
|
826
|
+
"restore challenge for component #{comp["component"]} could not be unwrapped " \
|
|
827
|
+
"with capture keypair generation #{generation}",
|
|
828
|
+
cause: e
|
|
829
|
+
)
|
|
830
|
+
end
|
|
831
|
+
secrets[comp.fetch("component")] = Base64.urlsafe_encode64(s, padding: false)
|
|
832
|
+
rescue KeyError => e
|
|
833
|
+
raise BindError.new("restore challenge malformed: #{e.message}")
|
|
834
|
+
end
|
|
835
|
+
plaintext = JSON.generate(challenge_id: challenge.fetch("challenge_id"), secrets: secrets)
|
|
836
|
+
sealed = BindHPKE.seal(
|
|
837
|
+
Base64.urlsafe_decode64(envelope.fetch(:ephem_pub)), plaintext,
|
|
838
|
+
BindHPKE.restore_info(envelope.fetch(:pod_uid), envelope.fetch(:coord_boot_id))
|
|
839
|
+
)
|
|
840
|
+
Base64.urlsafe_encode64(sealed, padding: false)
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
# Fetch the pod's bind identity, mint single-use portal attach
|
|
844
|
+
# credentials for it, and HPKE-seal the payload — everything a bind
|
|
845
|
+
# POST needs, held together so retries can re-send it verbatim.
|
|
846
|
+
def mint_bind_envelope(config, requested_location:)
|
|
847
|
+
pubkey = @coordinator.bind_pubkey
|
|
848
|
+
ephem_pub = Base64.urlsafe_decode64(pubkey.fetch("ephem_pub_x25519"))
|
|
849
|
+
pod_uid = pubkey.fetch("pod_uid")
|
|
850
|
+
coord_boot_id = pubkey.fetch("coord_boot_id")
|
|
851
|
+
|
|
852
|
+
# sandbox_id rides along so the portal can attribute the sandbox to
|
|
853
|
+
# this computer/project at the ONE moment it learns the pairing — the
|
|
854
|
+
# renew-relay revocation gate keys off that attribution.
|
|
855
|
+
mint_kwargs = {
|
|
856
|
+
computer_id: @id, pod_uid: pod_uid, coord_boot_id: coord_boot_id,
|
|
857
|
+
sandbox_id: @sandbox.id,
|
|
858
|
+
expected_binding_revision: @binding_revision,
|
|
859
|
+
idempotency_key: attach_idempotency_key(@sandbox.id, pod_uid, coord_boot_id)
|
|
860
|
+
}
|
|
861
|
+
mint_kwargs[:location] = requested_location if requested_location
|
|
862
|
+
# A persistent attach submits the capture public key so the portal advances
|
|
863
|
+
# its per-Computer key floor and returns a signed key_assertion. An
|
|
864
|
+
# ephemeral Computer has no capture keypair and persists nothing, so both
|
|
865
|
+
# pk_computer and key_generation are omitted.
|
|
866
|
+
unless @ephemeral
|
|
867
|
+
current = @capture_keypairs.fetch(@capture_generation)
|
|
868
|
+
mint_kwargs[:pk_computer] = Base64.urlsafe_encode64(current.pk, padding: false)
|
|
869
|
+
mint_kwargs[:key_generation] = current.generation
|
|
870
|
+
end
|
|
871
|
+
creds = config.attach_provider.credentials(**mint_kwargs)
|
|
872
|
+
committed_revision = Integer(creds.fetch("binding_revision"))
|
|
873
|
+
unless committed_revision > @binding_revision
|
|
874
|
+
raise AttachCredentialsError.new(
|
|
875
|
+
"attach_credentials_provider returned a non-advancing binding_revision"
|
|
876
|
+
)
|
|
877
|
+
end
|
|
878
|
+
# Record the committed CAS winner before validating the rest of the
|
|
879
|
+
# provider response. If an in-process/custom issuer returns an incomplete
|
|
880
|
+
# receipt, the caller still needs this revision to reconcile safely.
|
|
881
|
+
@binding_revision = committed_revision
|
|
882
|
+
@last_authorized_runtime_id = @sandbox.id
|
|
883
|
+
if creds.key?("location")
|
|
884
|
+
begin
|
|
885
|
+
committed_location = normalize_location(creds.fetch("location"))
|
|
886
|
+
rescue ArgumentError => e
|
|
887
|
+
raise AttachCredentialsError.new(
|
|
888
|
+
"attach_credentials_provider returned an invalid location: #{e.message}"
|
|
889
|
+
)
|
|
890
|
+
end
|
|
891
|
+
if requested_location && committed_location != requested_location
|
|
892
|
+
raise AttachCredentialsError.new(
|
|
893
|
+
"attach_credentials_provider returned a location different from the requested Computer location"
|
|
894
|
+
)
|
|
895
|
+
end
|
|
896
|
+
@location = committed_location
|
|
897
|
+
end
|
|
898
|
+
# key_assertion accompanies the capture public key on a persistent attach;
|
|
899
|
+
# an ephemeral attach neither submits pk_computer nor requires it back.
|
|
900
|
+
if creds["bind_token"].to_s.empty? || creds["broker_grant"].to_s.empty? ||
|
|
901
|
+
(!@ephemeral && creds["key_assertion"].to_s.empty?)
|
|
902
|
+
raise AttachCredentialsError.new(
|
|
903
|
+
"attach_credentials_provider result missing bind_token/broker_grant/key_assertion"
|
|
904
|
+
)
|
|
905
|
+
end
|
|
906
|
+
# The HPKE-sealed bind payload. A persistent Computer ships its state key
|
|
907
|
+
# (current + optional prior-for-restore) alongside the broker grant; an
|
|
908
|
+
# ephemeral Computer seals ONLY the broker grant — it holds no state to
|
|
909
|
+
# decrypt. broker_grant is always present.
|
|
910
|
+
#
|
|
911
|
+
# Only integrator-custodied material belongs here. Platform-internal
|
|
912
|
+
# credentials — the coordinator's accounting grant among them — are
|
|
913
|
+
# obtained by coord over its own internal rail, because routing one
|
|
914
|
+
# through this payload made metering depend on the SDK version an
|
|
915
|
+
# integrator had pinned.
|
|
916
|
+
plaintext =
|
|
917
|
+
if @ephemeral
|
|
918
|
+
JSON.generate(broker_grant: creds.fetch("broker_grant"))
|
|
919
|
+
else
|
|
920
|
+
restore_version, restore_bytes = @prior_keys.max_by { |version, _| version }
|
|
921
|
+
JSON.generate(
|
|
922
|
+
computer_key_current: wire_key(CURRENT_KEY_VERSION, @key),
|
|
923
|
+
computer_key_for_restore: restore_bytes ? wire_key(restore_version, restore_bytes) : nil,
|
|
924
|
+
broker_grant: creds.fetch("broker_grant")
|
|
925
|
+
)
|
|
926
|
+
end
|
|
927
|
+
ciphertext = BindHPKE.seal(ephem_pub, plaintext, BindHPKE.info(pod_uid, coord_boot_id))
|
|
928
|
+
|
|
929
|
+
{
|
|
930
|
+
bind_token: creds.fetch("bind_token"),
|
|
931
|
+
pod_uid: pod_uid,
|
|
932
|
+
coord_boot_id: coord_boot_id,
|
|
933
|
+
ciphertext: Base64.urlsafe_encode64(ciphertext, padding: false),
|
|
934
|
+
key_assertion: @ephemeral ? nil : creds["key_assertion"],
|
|
935
|
+
ephem_pub: pubkey.fetch("ephem_pub_x25519")
|
|
936
|
+
}
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
def attach_idempotency_key(sandbox_id, pod_uid, coord_boot_id)
|
|
940
|
+
material = [ @id, sandbox_id, pod_uid, coord_boot_id ].join("\0")
|
|
941
|
+
"attach-#{Digest::SHA256.hexdigest(material)}"
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
def wire_key(version, bytes)
|
|
945
|
+
{version: version, bytes: Base64.urlsafe_encode64(bytes, padding: false)}
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
def normalize_location(value)
|
|
949
|
+
unless value.is_a?(Hash)
|
|
950
|
+
raise ArgumentError, "location must be a Hash with country: or mode: \"direct\""
|
|
951
|
+
end
|
|
952
|
+
normalized = value.transform_keys(&:to_s)
|
|
953
|
+
return { mode: "direct" }.freeze if normalized == { "mode" => "direct" }
|
|
954
|
+
unless normalized.keys == [ "country" ] &&
|
|
955
|
+
normalized["country"].is_a?(String) &&
|
|
956
|
+
normalized["country"].match?(/\A[A-Z]{2}\z/)
|
|
957
|
+
raise ArgumentError, "location must contain an ISO alpha-2 country or mode: \"direct\", exclusively"
|
|
958
|
+
end
|
|
959
|
+
{ country: normalized.fetch("country") }.freeze
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
def restore_no_challenge?(err)
|
|
963
|
+
problem = err.respond_to?(:problem_type) ? err.problem_type : nil
|
|
964
|
+
problem == "/errors/bind-restore-no-challenge"
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
# A broker/restore 5xx can converge by replaying the SAME committed
|
|
968
|
+
# envelope. An epoch conflict is terminal because another pod won.
|
|
969
|
+
def transient_bind_restore?(err)
|
|
970
|
+
problem = err.respond_to?(:problem_type) ? err.problem_type : nil
|
|
971
|
+
status = err.respond_to?(:status) ? err.status.to_i : 0
|
|
972
|
+
problem == "/errors/bind-restore-failed" && status >= 500 && !err.message.to_s.include?("epoch")
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
# The just-allocated runtime isn't routable through the gateway YET —
|
|
976
|
+
# NOT a bind rejection. These are the not-ready signals (all clear as the
|
|
977
|
+
# runtime starts serving, so retry within the attach deadline):
|
|
978
|
+
# 404 — not yet routable (brief post-create window);
|
|
979
|
+
# 503 — routable but not yet ready;
|
|
980
|
+
# 409 /errors/bind-in-progress — an in-flight bind settling.
|
|
981
|
+
# The gateway now types its two routing decisions. They remain readiness
|
|
982
|
+
# signals ONLY inside this attach deadline; on ordinary data-plane calls a
|
|
983
|
+
# sandbox-not-found response means the bound sandbox is gone. Other typed
|
|
984
|
+
# coord failures (bind-not-configured, epoch-conflict, pod-tainted, auth)
|
|
985
|
+
# are bind verdicts and fall through to transient_bind_restore?/
|
|
986
|
+
# map_bind_error. Plain 404/503 remain compatible with older gateways.
|
|
987
|
+
def coord_not_ready_via_ingress?(err)
|
|
988
|
+
return true if bind_in_progress?(err)
|
|
989
|
+
return false unless err.respond_to?(:status)
|
|
990
|
+
|
|
991
|
+
problem = err.respond_to?(:problem_type) ? err.problem_type : nil
|
|
992
|
+
return true if [
|
|
993
|
+
"/errors/sandbox-not-found",
|
|
994
|
+
"/errors/sandbox-not-ready",
|
|
995
|
+
"/errors/egress-activation-failed"
|
|
996
|
+
].include?(problem)
|
|
997
|
+
return false unless problem.nil?
|
|
998
|
+
|
|
999
|
+
status = err.status.to_i
|
|
1000
|
+
status == 404 || status == 503
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
# coord's bindGuard is mid-flight completing a bind for this pod (a prior
|
|
1004
|
+
# attempt of THIS attach whose response we lost, or a racing caller); coord
|
|
1005
|
+
# returns 409 + Retry-After. It settles once the in-flight bind/restore
|
|
1006
|
+
# finishes — which can outlast a few attempts — so it rides the attach
|
|
1007
|
+
# DEADLINE, not max_bind_attempts. Re-running run_bind is safe: the guard
|
|
1008
|
+
# serializes binds per runtime and a settled bind answers consistently.
|
|
1009
|
+
def bind_in_progress?(err)
|
|
1010
|
+
(err.respond_to?(:problem_type) ? err.problem_type : nil) == "/errors/bind-in-progress"
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
# Monotonic seconds — immune to wall-clock jumps during a long attach.
|
|
1014
|
+
def monotonic_now
|
|
1015
|
+
(@clock || PineSandbox::SYSTEM_CLOCK).monotonic
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
# Every attach wait goes through the connection's clock.
|
|
1019
|
+
def sleep(seconds)
|
|
1020
|
+
(@clock || PineSandbox::SYSTEM_CLOCK).sleep(seconds)
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
# Capped linear backoff (0.5s..3s) for the convergence wait, so a slow
|
|
1024
|
+
# cold pod is polled steadily without hammering the ingress.
|
|
1025
|
+
def bind_convergence_backoff(wait_count)
|
|
1026
|
+
[0.5 * wait_count, 3.0].min
|
|
1027
|
+
end
|
|
1028
|
+
|
|
1029
|
+
# Maps a terminal bind failure to the SDK's typed bind errors. Prefer the
|
|
1030
|
+
# RFC 9457 problem-type slug (coord sets it on the
|
|
1031
|
+
# problem+json body); fall back to the HTTP status tier for older coords
|
|
1032
|
+
# or client-side faults (malformed-request / hpke-decrypt-failed / etc).
|
|
1033
|
+
def map_bind_error(err)
|
|
1034
|
+
status = err.respond_to?(:status) ? err.status : nil
|
|
1035
|
+
problem = err.respond_to?(:problem_type) ? err.problem_type : nil
|
|
1036
|
+
message = err.message.to_s
|
|
1037
|
+
|
|
1038
|
+
case problem
|
|
1039
|
+
when "/errors/bind-rejected"
|
|
1040
|
+
BindAuthError.new("the Computer rejected the bind_token: #{message}", status: status, cause: err)
|
|
1041
|
+
when "/errors/bind-not-configured"
|
|
1042
|
+
BindError.new("this runtime cannot be bound: #{message}", status: status, cause: err)
|
|
1043
|
+
when "/errors/bind-lease-unenforceable"
|
|
1044
|
+
# Structural deployment misconfiguration (lease relay unconfigured on
|
|
1045
|
+
# a portal-trusting pod) — a bind retry cannot fix it, and it must
|
|
1046
|
+
# not masquerade as a broker outage.
|
|
1047
|
+
BindError.new("this runtime cannot enforce the access lease: #{message}", status: status, cause: err)
|
|
1048
|
+
when "/errors/already-bound-different-cid", "/errors/pod-tainted"
|
|
1049
|
+
# Terminal: a prior bind tainted this pod (bound to another Computer,
|
|
1050
|
+
# or failed after restore). The pod is single-use, so recovery is a
|
|
1051
|
+
# fresh #attach on a fresh pod — never a same-pod replay.
|
|
1052
|
+
RuntimeUnusableError.new("this runtime cannot be bound again; attach a fresh one (#{problem}): #{message}", status: status, cause: err)
|
|
1053
|
+
when "/errors/bind-restore-failed"
|
|
1054
|
+
if message.include?("epoch")
|
|
1055
|
+
ComputerAlreadyAttachedError.new("another runtime holds this Computer's epoch lease: #{message}", status: status, cause: err)
|
|
1056
|
+
else
|
|
1057
|
+
SavedStateUnavailableError.new("restoring saved state failed during bind: #{message}", status: status, cause: err)
|
|
1058
|
+
end
|
|
1059
|
+
else
|
|
1060
|
+
case status
|
|
1061
|
+
when 401, 403
|
|
1062
|
+
BindAuthError.new("the Computer rejected the bind_token (#{status}): #{message}", status: status, cause: err)
|
|
1063
|
+
when 409
|
|
1064
|
+
BindError.new("bind rejected (409): #{message}", status: status, cause: err)
|
|
1065
|
+
when 503
|
|
1066
|
+
SavedStateUnavailableError.new("saved state was unreachable during bind: #{message}", status: status, cause: err)
|
|
1067
|
+
else
|
|
1068
|
+
BindError.new("bind failed (status=#{status || 'n/a'}): #{message}", status: status, cause: err)
|
|
1069
|
+
end
|
|
1070
|
+
end
|
|
1071
|
+
end
|
|
1072
|
+
end
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
require_relative "computer_credentials"
|