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,446 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require_relative "zone"
|
|
3
|
+
require_relative "delegated_connection"
|
|
4
|
+
require_relative "control_token_source"
|
|
5
|
+
require_relative "attach_credentials_source"
|
|
6
|
+
require_relative "control_plane"
|
|
7
|
+
require_relative "connection"
|
|
8
|
+
require_relative "computer"
|
|
9
|
+
require_relative "diagnostics"
|
|
10
|
+
|
|
11
|
+
module PineSandbox
|
|
12
|
+
# The SDK entry point. Holds the project credential (`pk_`) + the endpoint
|
|
13
|
+
# ONCE, and returns ready Computers. Construction details (token mint+cache,
|
|
14
|
+
# attach-credential provider, control-plane client, host derivation) are
|
|
15
|
+
# internal — the operational API (Computer / Session) is unchanged.
|
|
16
|
+
#
|
|
17
|
+
# Attach authorization is portal-issued (AUTH_REDESIGN: portal-as-issuer) —
|
|
18
|
+
# the SDK exchanges the pk_ for per-attach authorization at attach time;
|
|
19
|
+
# Portal lazily creates only its minimal ownership row in that transaction:
|
|
20
|
+
#
|
|
21
|
+
# client = PineSandbox::Client.new(
|
|
22
|
+
# api_key: ENV.fetch("PINE_SANDBOX_API_KEY"), # "pk_…"
|
|
23
|
+
# endpoint: ENV.fetch("PINE_SANDBOX_ENDPOINT"), # URL or domain the portal shows
|
|
24
|
+
# )
|
|
25
|
+
# computer = client.create_computer # NEW Computer — persist computer.id + computer.key
|
|
26
|
+
# computer = client.attach_computer(id, key:) # re-attach an existing one (state restored)
|
|
27
|
+
#
|
|
28
|
+
# `attach_credentials_provider:` / `control_token_provider:` are the seams
|
|
29
|
+
# for in-process issuers (e.g. the portal's own playground, which IS the
|
|
30
|
+
# issuer and skips the self-HTTP hop).
|
|
31
|
+
class Client
|
|
32
|
+
# Wraps a caller-supplied `-> { jws }` provider as a token source for shops that
|
|
33
|
+
# centralize minting (or a future local-sign path). The SDK does no caching here
|
|
34
|
+
# — the provider owns expiry/refresh.
|
|
35
|
+
class CallableTokenSource
|
|
36
|
+
def initialize(callable)
|
|
37
|
+
@callable = callable
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def token(force_refresh: false)
|
|
41
|
+
@callable.call
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def inspect
|
|
45
|
+
"#<PineSandbox::Client::CallableTokenSource [provider redacted]>"
|
|
46
|
+
end
|
|
47
|
+
alias_method :to_s, :inspect
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Wraps a caller-supplied `->(computer_id:, pod_uid:, coord_boot_id:) { … }`
|
|
51
|
+
# provider as an attach-credential source for in-process issuers (the
|
|
52
|
+
# portal's own playground mints without a self-HTTP hop). The callable owns
|
|
53
|
+
# authorization; it receives the full v3 mint keyword set and must return
|
|
54
|
+
# bind_token, broker_grant, key_assertion, and binding_revision.
|
|
55
|
+
class CallableAttachCredentialsSource
|
|
56
|
+
def initialize(callable)
|
|
57
|
+
@callable = callable
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def credentials(computer_id:, pod_uid:, coord_boot_id:, sandbox_id:,
|
|
61
|
+
expected_binding_revision:, idempotency_key:,
|
|
62
|
+
pk_computer: nil, key_generation: nil, location: nil)
|
|
63
|
+
kwargs = { computer_id: computer_id, pod_uid: pod_uid, coord_boot_id: coord_boot_id,
|
|
64
|
+
sandbox_id: sandbox_id, pk_computer: pk_computer,
|
|
65
|
+
key_generation: key_generation,
|
|
66
|
+
expected_binding_revision: expected_binding_revision,
|
|
67
|
+
idempotency_key: idempotency_key }
|
|
68
|
+
kwargs[:location] = location if location
|
|
69
|
+
result = @callable.call(**kwargs)
|
|
70
|
+
unless result.is_a?(Hash)
|
|
71
|
+
raise AttachCredentialsError.new("attach_credentials_provider must return a Hash with bind_token + broker_grant")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
normalized = result.transform_keys(&:to_s)
|
|
75
|
+
# key_assertion is required only for a persistent (v3) attach — the same
|
|
76
|
+
# attach that submits pk_computer. An ephemeral attach omits both.
|
|
77
|
+
key_assertion_missing = !pk_computer.to_s.empty? && normalized["key_assertion"].to_s.empty?
|
|
78
|
+
if normalized["bind_token"].to_s.empty? || normalized["broker_grant"].to_s.empty? ||
|
|
79
|
+
key_assertion_missing || normalized["binding_revision"].to_i <= expected_binding_revision
|
|
80
|
+
raise AttachCredentialsError.new(
|
|
81
|
+
"attach_credentials_provider result missing bind_token/broker_grant/key_assertion/binding_revision"
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
normalized
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def inspect
|
|
88
|
+
"#<PineSandbox::Client::CallableAttachCredentialsSource [provider redacted]>"
|
|
89
|
+
end
|
|
90
|
+
alias_method :to_s, :inspect
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# `clock:` (@api private) replaces the SDK's view of time — every wait,
|
|
94
|
+
# deadline and jitter — for tests that run retries on a virtual clock.
|
|
95
|
+
def initialize(endpoint: nil, api_key: nil,
|
|
96
|
+
control_host: nil, spec_version: DelegatedConnection::SPEC_VERSION,
|
|
97
|
+
control_token_provider: nil, attach_credentials_provider: nil,
|
|
98
|
+
gateway_url: nil, clock: PineSandbox::SYSTEM_CLOCK)
|
|
99
|
+
raise ArgumentError, "endpoint required (e.g. \"https://pinesandbox.io\")" if endpoint.nil?
|
|
100
|
+
|
|
101
|
+
@zone =
|
|
102
|
+
if endpoint.is_a?(PineSandbox::Zone)
|
|
103
|
+
raise ArgumentError, "pass gateway_url to the Zone, not with one" unless gateway_url.nil?
|
|
104
|
+
|
|
105
|
+
endpoint
|
|
106
|
+
else
|
|
107
|
+
PineSandbox::Zone.new(endpoint, control_host: control_host, gateway_url: gateway_url)
|
|
108
|
+
end
|
|
109
|
+
@spec_version = spec_version
|
|
110
|
+
@diagnostics = Diagnostics.new(api_key: api_key, zone: @zone) unless api_key.to_s.empty?
|
|
111
|
+
|
|
112
|
+
token_source =
|
|
113
|
+
if control_token_provider
|
|
114
|
+
CallableTokenSource.new(control_token_provider)
|
|
115
|
+
else
|
|
116
|
+
raise ArgumentError, "api_key required (a pk_ client key) unless control_token_provider is given" if api_key.to_s.empty?
|
|
117
|
+
|
|
118
|
+
ControlTokenSource.new(api_key: api_key, host: @zone.control_host, protocol: @zone.http_scheme,
|
|
119
|
+
gateway_url: @zone.gateway_url, clock: -> { clock.now },
|
|
120
|
+
sleeper: ->(seconds) { clock.sleep(seconds) }, random: -> { clock.rand })
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Attach authorization. Default: the pk_-backed portal attach-credentials
|
|
124
|
+
# provider; `attach_credentials_provider:` is the in-process issuer seam.
|
|
125
|
+
attach_provider =
|
|
126
|
+
if attach_credentials_provider
|
|
127
|
+
if attach_credentials_provider.respond_to?(:credentials)
|
|
128
|
+
attach_credentials_provider
|
|
129
|
+
else
|
|
130
|
+
CallableAttachCredentialsSource.new(attach_credentials_provider)
|
|
131
|
+
end
|
|
132
|
+
elsif !api_key.to_s.empty?
|
|
133
|
+
AttachCredentialsSource.new(api_key: api_key, host: @zone.control_host, protocol: @zone.http_scheme,
|
|
134
|
+
gateway_url: @zone.gateway_url)
|
|
135
|
+
else
|
|
136
|
+
raise ArgumentError, "attach credentials unavailable: pass api_key (a pk_ client key) or attach_credentials_provider:"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
@control_plane = ControlPlane.new(control_host: @zone.control_host, token_source: token_source,
|
|
140
|
+
protocol: @zone.http_scheme, spec_version: @spec_version,
|
|
141
|
+
gateway_url: @zone.gateway_url, clock: clock)
|
|
142
|
+
@connection = Connection.new(attach_provider: attach_provider, zone: @zone, control_plane: @control_plane,
|
|
143
|
+
clock: clock)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @api private — retained for compatibility; applications use Computer operations.
|
|
147
|
+
attr_reader :control_plane
|
|
148
|
+
attr_reader :zone
|
|
149
|
+
|
|
150
|
+
# Queue a read-only check. The returned Hash contains a support reference;
|
|
151
|
+
# it does not open a support ticket or repeat customer work. Reuse an explicit
|
|
152
|
+
# idempotency_key after an uncertain response to retrieve the same request.
|
|
153
|
+
def create_diagnostic(computer_id:, session: nil, request_id: nil, description: nil, idempotency_key: SecureRandom.uuid)
|
|
154
|
+
raise ArgumentError, "diagnostics requires a project API key" unless @diagnostics
|
|
155
|
+
|
|
156
|
+
@diagnostics.create(computer_id: computer_id, session: session, request_id: request_id,
|
|
157
|
+
description: description, idempotency_key: idempotency_key)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Read the saved result without scheduling work. The Hash uses string keys.
|
|
161
|
+
def get_diagnostic(id)
|
|
162
|
+
raise ArgumentError, "diagnostics requires a project API key" unless @diagnostics
|
|
163
|
+
|
|
164
|
+
@diagnostics.get(id)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Both successful diagnostic responses and PortalError expose Retry-After.
|
|
168
|
+
def diagnostic_response_metadata? = true
|
|
169
|
+
|
|
170
|
+
# Discover project-visible country intents before creating a Computer.
|
|
171
|
+
def available_locations
|
|
172
|
+
provider = @connection.attach_provider
|
|
173
|
+
unless provider.respond_to?(:available_locations)
|
|
174
|
+
raise LocationDiscoveryError, "location discovery is unavailable from the custom attach credentials provider"
|
|
175
|
+
end
|
|
176
|
+
provider.available_locations
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Optional configuration discovery, not a runtime operation prerequisite.
|
|
180
|
+
def project_policy
|
|
181
|
+
provider = @connection.attach_provider
|
|
182
|
+
raise ArgumentError, "project policy requires a project API key provider" unless provider.respond_to?(:project_policy)
|
|
183
|
+
|
|
184
|
+
provider.project_policy
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# PERMANENTLY delete a Computer: its identity and saved state end (stop
|
|
188
|
+
# and kill keep both). Accepts a PineSandbox::Computer or its id String. A
|
|
189
|
+
# Computer that is still attached is force-terminated first (#kill — no
|
|
190
|
+
# final capture, the state is being deleted). The portal tombstones the id
|
|
191
|
+
# immediately, destroys any remaining sandboxes asynchronously, purges saved
|
|
192
|
+
# state after 24 hours, and never accepts the id again. Idempotent: unknown,
|
|
193
|
+
# already-deleted, and other projects' ids all return true. No undelete.
|
|
194
|
+
def delete_computer(computer)
|
|
195
|
+
computer_id =
|
|
196
|
+
case computer
|
|
197
|
+
when Computer then computer.id
|
|
198
|
+
when String then computer
|
|
199
|
+
else raise ArgumentError, "delete_computer expects a PineSandbox::Computer or a computer_id String"
|
|
200
|
+
end
|
|
201
|
+
raise ArgumentError, "computer_id required" if computer_id.empty?
|
|
202
|
+
|
|
203
|
+
provider = @connection.attach_provider
|
|
204
|
+
unless provider.respond_to?(:delete_computer)
|
|
205
|
+
raise ComputerDeletionError, "computer deletion is unavailable from the custom attach credentials provider"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
computer.kill if computer.is_a?(Computer)
|
|
209
|
+
provider.delete_computer(computer_id)
|
|
210
|
+
true
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Provision a NEW persistent Computer (fresh UUIDv7 id + random 32-byte state key),
|
|
214
|
+
# bind it, and return it ready. Pass saved Computer::Credentials for crash-safe
|
|
215
|
+
# creation, or save computer.credentials from the returned Computer.
|
|
216
|
+
#
|
|
217
|
+
# `ephemeral: true` provisions an access-lease-only Computer that persists
|
|
218
|
+
# NOTHING — no capture keypair is generated or required, no state key is
|
|
219
|
+
# sealed into the bind, and #stop skips the pre-stop checkpoint.
|
|
220
|
+
def create_computer(credentials = nil, **options)
|
|
221
|
+
options = credentials_options(credentials, options) unless credentials.nil?
|
|
222
|
+
create_new_computer(**options)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def create_new_computer(id: nil, key: nil, capture_keypair: nil, prior_capture_keypairs: [],
|
|
226
|
+
prior_keys: {}, ephemeral: false, location: nil, **attach_opts)
|
|
227
|
+
# An ephemeral Computer never carries a capture keypair, so only its id/key
|
|
228
|
+
# are ever generated; a persistent Computer also generates the keypair.
|
|
229
|
+
needs_generation = id.nil? || key.nil? || (!ephemeral && capture_keypair.nil?)
|
|
230
|
+
generated = Computer.generate_credentials(ephemeral: ephemeral) if needs_generation
|
|
231
|
+
cid = id || generated.id
|
|
232
|
+
state_key = key || generated.key
|
|
233
|
+
computer = Computer.new(cid, key: state_key, **(ephemeral ? { ephemeral: true } : {}))
|
|
234
|
+
capture_keypair ||= generated&.capture_keypair
|
|
235
|
+
configure_capture_keypairs(computer, capture_keypair, prior_capture_keypairs)
|
|
236
|
+
prior_keys.each { |version, bytes| computer.add_prior_key(version, bytes) }
|
|
237
|
+
recovery_credentials = computer.credentials
|
|
238
|
+
attach_opts = attach_opts.merge(location: location) if location
|
|
239
|
+
attach_with_recovery_context(
|
|
240
|
+
computer, initial_revision: 0, recovery_credentials: recovery_credentials, **attach_opts
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private :create_new_computer
|
|
245
|
+
|
|
246
|
+
# Compatibility helper for integrations that explicitly pre-register a
|
|
247
|
+
# computer_id. Normal create/attach does not call this: first attach lazily
|
|
248
|
+
# creates Portal's minimal authorization row atomically with the binding.
|
|
249
|
+
def register_computer(computer_id, location: nil)
|
|
250
|
+
provider = @connection.attach_provider
|
|
251
|
+
return unless provider.respond_to?(:register_computer)
|
|
252
|
+
|
|
253
|
+
location ? provider.register_computer(computer_id, location: location) : provider.register_computer(computer_id)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Re-attach an EXISTING persistent Computer by its stored id + state key. A fresh
|
|
257
|
+
# runtime is allocated and bound with the Computer's prior state restored.
|
|
258
|
+
# The id may be given positionally or as `id:` (matching #create_computer).
|
|
259
|
+
def start_computer(*args, **options)
|
|
260
|
+
if args.first.is_a?(Computer::Credentials)
|
|
261
|
+
unless @connection.attach_provider.respond_to?(:current_binding)
|
|
262
|
+
raise ArgumentError, "Start with saved credentials requires a provider with current_binding"
|
|
263
|
+
end
|
|
264
|
+
credentials, *args = args
|
|
265
|
+
options = credentials_options(credentials, options)
|
|
266
|
+
end
|
|
267
|
+
start_existing_computer(*args, **options, recover_runtime: false)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Gracefully stop the binding observed for this ID, even when a caller lost
|
|
271
|
+
# its Start response before saving the runtime token. Never follow a newer
|
|
272
|
+
# binding during this invocation; project authorization is sufficient.
|
|
273
|
+
def stop_computer(computer_id, **wait_options)
|
|
274
|
+
raise ArgumentError, "id must be a lowercase UUIDv7" unless computer_id.to_s.match?(Computer::UUID_V7)
|
|
275
|
+
provider = @connection.attach_provider
|
|
276
|
+
unless provider.respond_to?(:current_binding)
|
|
277
|
+
raise ArgumentError, "Stop by ID requires a provider with current_binding"
|
|
278
|
+
end
|
|
279
|
+
reference = provider.current_binding(computer_id)
|
|
280
|
+
runtime_id = reference["sandbox_id"]
|
|
281
|
+
return true if runtime_id.nil?
|
|
282
|
+
|
|
283
|
+
RuntimeHandle.new(control_plane: @connection.control_plane, runtime_id: runtime_id).stop(**wait_options)
|
|
284
|
+
rescue UnknownComputerError
|
|
285
|
+
true
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Explicitly discard a failed runtime, wait for release, then Start from
|
|
289
|
+
# committed state. This loses unsaved memory; it never replaces a healthy
|
|
290
|
+
# paused or running Computer and never silently follows failed Resume.
|
|
291
|
+
def recover_computer(*args, **options)
|
|
292
|
+
if args.first.is_a?(Computer::Credentials)
|
|
293
|
+
credentials, *args = args
|
|
294
|
+
options = credentials_options(credentials, options)
|
|
295
|
+
end
|
|
296
|
+
start_existing_computer(*args, **options, recover_runtime: true)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def start_existing_computer(positional_id = nil, id: nil, key:, capture_keypair: nil, prior_capture_keypairs: [],
|
|
300
|
+
binding_revision: 0, prior_keys: {}, ephemeral: false, location: nil, recover_runtime: false, **attach_opts)
|
|
301
|
+
if positional_id && id && positional_id != id
|
|
302
|
+
raise ArgumentError, "attach_computer got two different ids (positional and id:)"
|
|
303
|
+
end
|
|
304
|
+
id ||= positional_id
|
|
305
|
+
raise ArgumentError, "attach_computer requires the Computer id" if id.nil?
|
|
306
|
+
|
|
307
|
+
provider = @connection.attach_provider
|
|
308
|
+
if recover_runtime && !provider.respond_to?(:current_binding)
|
|
309
|
+
raise ArgumentError, "saved-state recovery requires a provider with current_binding"
|
|
310
|
+
end
|
|
311
|
+
if provider.respond_to?(:current_binding)
|
|
312
|
+
reference = provider.current_binding(id)
|
|
313
|
+
binding_revision = reference.fetch("binding_revision")
|
|
314
|
+
if (runtime_id = reference["sandbox_id"])
|
|
315
|
+
begin
|
|
316
|
+
runtime = @connection.control_plane.get(runtime_id)
|
|
317
|
+
unless runtime.status == :terminated && runtime.reason == "COMPUTE_RELEASED"
|
|
318
|
+
unless recover_runtime || runtime.status == :stopping
|
|
319
|
+
raise RuntimeRetainedError.new(runtime_id: runtime_id, state: runtime.status)
|
|
320
|
+
end
|
|
321
|
+
handle = RuntimeHandle.new(control_plane: @connection.control_plane, runtime_id: runtime_id)
|
|
322
|
+
unless handle.release_failed_runtime(runtime)
|
|
323
|
+
raise TimeoutError, "Old runtime release is unconfirmed; no replacement was allocated"
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
rescue NotFoundError
|
|
327
|
+
# Authoritative project-scoped absence allows ordinary Start.
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
computer = Computer.new(id, key: key, binding_revision: binding_revision,
|
|
333
|
+
**(ephemeral ? { ephemeral: true } : {}))
|
|
334
|
+
configure_capture_keypairs(computer, capture_keypair, prior_capture_keypairs)
|
|
335
|
+
prior_keys.each { |version, bytes| computer.add_prior_key(version, bytes) }
|
|
336
|
+
attach_opts = attach_opts.merge(location: location) if location
|
|
337
|
+
attach_with_recovery_context(computer, initial_revision: binding_revision, **attach_opts)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
private :start_existing_computer
|
|
341
|
+
|
|
342
|
+
# Compatibility name for saved-state Start; never a memory Resume.
|
|
343
|
+
alias_method :attach_computer, :start_computer
|
|
344
|
+
|
|
345
|
+
# Adopt an ALREADY-bound, still-live Computer from its persisted `{id, key,
|
|
346
|
+
# runtime_id, ct_}` — NO new runtime, NO re-bind. For driving a running Computer
|
|
347
|
+
# across requests (e.g. re-minting a desktop `dt_` for a reconnect) from a
|
|
348
|
+
# backend that cached the `ct_`. Use #start_computer to restore onto a fresh runtime.
|
|
349
|
+
# `sandbox_id:` is the deprecated spelling of `runtime_id:`.
|
|
350
|
+
def adopt_existing(id:, key:, computer_token:, runtime_id: nil, sandbox_id: nil, status: :running,
|
|
351
|
+
binding_revision: 0, ephemeral: false)
|
|
352
|
+
Computer.new(id, key: key, binding_revision: binding_revision, ephemeral: ephemeral)
|
|
353
|
+
.adopt(@connection, runtime_id: runtime_id || sandbox_id, computer_token: computer_token,
|
|
354
|
+
status: status, binding_revision: binding_revision)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Reconstruct exactly the saved binding, without network I/O. A subsequent
|
|
358
|
+
# status/action reports whether it still exists; reconnect never allocates.
|
|
359
|
+
def reconnect_computer(credentials, binding:)
|
|
360
|
+
unless credentials.is_a?(Computer::Credentials) && binding.is_a?(Computer::Binding)
|
|
361
|
+
raise ArgumentError, "reconnect requires Computer credentials and binding"
|
|
362
|
+
end
|
|
363
|
+
credentials.validate!
|
|
364
|
+
binding.validate!
|
|
365
|
+
unless credentials.id == binding.computer_id
|
|
366
|
+
raise ArgumentError, "credentials and binding belong to different Computers"
|
|
367
|
+
end
|
|
368
|
+
computer = adopt_existing(id: credentials.id.dup, key: credentials.key.dup,
|
|
369
|
+
runtime_id: binding.runtime_id.dup, computer_token: binding.computer_token.dup,
|
|
370
|
+
binding_revision: binding.binding_revision, ephemeral: credentials.ephemeral)
|
|
371
|
+
configure_capture_keypairs(computer, credentials.capture_keypair, credentials.prior_capture_keypairs)
|
|
372
|
+
credentials.prior_keys.each { |version, key| computer.add_prior_key(version, key.dup) }
|
|
373
|
+
computer
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Block form: yields a ready Computer and stops it on exit.
|
|
377
|
+
# Failed saves and unconfirmed deletion are raised to the caller.
|
|
378
|
+
def with_computer(**opts)
|
|
379
|
+
computer = create_computer(**opts)
|
|
380
|
+
yield computer
|
|
381
|
+
ensure
|
|
382
|
+
if computer && !computer.stop
|
|
383
|
+
raise TimeoutError, "Computer #{computer.id} stop is unconfirmed; retry Stop with its saved binding"
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
private
|
|
388
|
+
|
|
389
|
+
def credentials_options(credentials, options)
|
|
390
|
+
unless credentials.is_a?(Computer::Credentials)
|
|
391
|
+
raise ArgumentError, "credentials must be a PineSandbox::Computer::Credentials"
|
|
392
|
+
end
|
|
393
|
+
credentials.validate!
|
|
394
|
+
if (options.keys & credentials.members).any?
|
|
395
|
+
raise ArgumentError, "pass credentials or individual identity/key options, not both"
|
|
396
|
+
end
|
|
397
|
+
credentials.to_h.merge(options)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def attach_with_recovery_context(computer, initial_revision:, recovery_credentials: nil, **attach_opts)
|
|
401
|
+
computer.attach(@connection, **attach_opts)
|
|
402
|
+
rescue StandardError => e
|
|
403
|
+
revision = computer.binding_revision
|
|
404
|
+
runtime_id = computer.last_authorized_runtime_id
|
|
405
|
+
raise if revision <= initial_revision || runtime_id.to_s.empty?
|
|
406
|
+
|
|
407
|
+
raise AttachAuthorizationCommittedError.new(
|
|
408
|
+
"attach authorization committed at revision #{revision} for runtime #{runtime_id}, " \
|
|
409
|
+
"but the bind failed: #{e.message}",
|
|
410
|
+
binding_revision: revision, runtime_id: runtime_id,
|
|
411
|
+
credentials: recovery_credentials,
|
|
412
|
+
status: (e.respond_to?(:status) ? e.status : nil), cause: e
|
|
413
|
+
)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def configure_capture_keypairs(computer, current, prior)
|
|
417
|
+
prior = Array(prior)
|
|
418
|
+
unless current.nil? || current.is_a?(CaptureKeypair)
|
|
419
|
+
raise ArgumentError, "capture_keypair must be a PineSandbox::CaptureKeypair"
|
|
420
|
+
end
|
|
421
|
+
unless prior.all? { |keypair| keypair.is_a?(CaptureKeypair) }
|
|
422
|
+
raise ArgumentError, "prior_capture_keypairs must contain only PineSandbox::CaptureKeypair values"
|
|
423
|
+
end
|
|
424
|
+
if prior.any? && current.nil?
|
|
425
|
+
raise ArgumentError, "prior_capture_keypairs require capture_keypair"
|
|
426
|
+
end
|
|
427
|
+
if current
|
|
428
|
+
generations = prior.map(&:generation)
|
|
429
|
+
duplicate = generations.tally.find { |_generation, count| count > 1 }&.first
|
|
430
|
+
raise ArgumentError, "duplicate prior capture keypair generation #{duplicate}" if duplicate
|
|
431
|
+
|
|
432
|
+
invalid = prior.find { |keypair| keypair.generation >= current.generation }
|
|
433
|
+
if invalid
|
|
434
|
+
raise ArgumentError,
|
|
435
|
+
"prior capture keypair generation #{invalid.generation} must be below the current generation #{current.generation}"
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Apply only after the complete option set passes validation. This keeps
|
|
440
|
+
# create_computer side-effect-free on bad input: no partial key setup and
|
|
441
|
+
# no ownership registration request.
|
|
442
|
+
computer.set_capture_keypair(current) if current
|
|
443
|
+
prior.each { |keypair| computer.add_prior_capture_keypair(keypair) }
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module PineSandbox
|
|
2
|
+
# @api private — the SDK's view of time. Every wait, deadline and jitter the
|
|
3
|
+
# SDK uses goes through one of these, so a test can substitute a virtual
|
|
4
|
+
# clock (a sleep returns at once, advances the clock and is recorded) and
|
|
5
|
+
# assert the exact waits. Production uses SystemClock.
|
|
6
|
+
class SystemClock
|
|
7
|
+
# Seconds on a monotonic clock, for deadlines and elapsed time.
|
|
8
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
9
|
+
|
|
10
|
+
# Wall-clock time, for token expiry.
|
|
11
|
+
def now = Time.now
|
|
12
|
+
|
|
13
|
+
def sleep(seconds)
|
|
14
|
+
Kernel.sleep(seconds) if seconds.positive?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A uniform random Float in [0, 1), for jitter.
|
|
18
|
+
def rand = Kernel.rand
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @api private
|
|
22
|
+
SYSTEM_CLOCK = SystemClock.new
|
|
23
|
+
|
|
24
|
+
# @api private — the shared client policy (computer-api.yaml x-pine-retry.client).
|
|
25
|
+
module RetryPolicy
|
|
26
|
+
# A replay-safe request is sent at most this many times after a transport fault.
|
|
27
|
+
TRANSIENT_ATTEMPTS = 3
|
|
28
|
+
TRANSIENT_BASE = 0.2
|
|
29
|
+
TRANSIENT_JITTER = 0.1
|
|
30
|
+
TRANSIENT_CAP = 2.0
|
|
31
|
+
|
|
32
|
+
# An event stream reconnects at most this many times in a row without
|
|
33
|
+
# delivering an event, then raises StreamLostError.
|
|
34
|
+
STREAM_RECONNECT_BUDGET = 5
|
|
35
|
+
STREAM_BASE = 0.5
|
|
36
|
+
STREAM_JITTER = 0.2
|
|
37
|
+
STREAM_CAP = 5.0
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# The wait before transient retry `attempt` (1-based).
|
|
42
|
+
def transient_backoff(attempt, clock)
|
|
43
|
+
[TRANSIENT_BASE * (2**(attempt - 1)) + clock.rand * TRANSIENT_JITTER, TRANSIENT_CAP].min
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The wait before stream reconnect `attempt` (1-based).
|
|
47
|
+
def stream_backoff(attempt, clock)
|
|
48
|
+
[STREAM_BASE * (2**(attempt - 1)) + clock.rand * STREAM_JITTER, STREAM_CAP].min
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|