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.
@@ -1,5 +1,6 @@
1
1
  require "faraday"
2
2
  require "json"
3
+ require "time"
3
4
  require_relative "errors"
4
5
 
5
6
  module PineSandbox
@@ -12,17 +13,19 @@ module PineSandbox
12
13
  # * POST /v1/computers — optional legacy
13
14
  # pre-registration helper. Normal attach does not require it.
14
15
  # * POST /v1/computers/:computer_id/attach-credentials — body carries the
15
- # runtime coordinates {pod_uid, coord_boot_id}; returns {bind_token,
16
+ # start's coordinates {pod_uid, coord_boot_id}; returns {bind_token,
16
17
  # broker_grant, key_assertion, binding_revision}. The portal authenticates
17
18
  # the pk_ and atomically creates/rechecks its minimal authorization row,
18
19
  # advances the key floor, supersedes the prior binding, and mints.
19
20
  # * DELETE /v1/computers/:computer_id — permanent delete.
20
21
  # 204 for deleted, already deleted, unknown, and cross-project ids alike.
22
+ # * POST /v1/computers/:computer_id/artifacts/token — a short-lived
23
+ # storage token for the Computer's artifacts (Artifacts).
21
24
  #
22
- # This source caches nothing. Computer#run_bind calls it exactly once for a
23
- # sandbox, then convergence retries re-send the held envelope verbatim
24
- # (the bind is idempotent on jti + ciphertext). A runtime-identity shift
25
- # requires a fresh sandbox; it never authorizes the same sandbox twice.
25
+ # This source caches nothing. Computer#run_bind calls it exactly once for each
26
+ # start, then convergence retries re-send the held envelope verbatim
27
+ # (the bind is idempotent on jti + ciphertext). A change of pod_uid or
28
+ # coord_boot_id requires a new start; it never authorizes the same start twice.
26
29
  # The pk_ never appears in #inspect.
27
30
  class AttachCredentialsSource
28
31
  REGISTER_PATH = "/v1/computers"
@@ -97,7 +100,31 @@ module PineSandbox
97
100
  payload
98
101
  end
99
102
 
100
- # One authorization-registry read before Start; never a runtime status poll.
103
+ # A short-lived storage token for the Computer's artifacts, and where they
104
+ # are: "access_token", "expires_at", "endpoint", "bucket", "prefix",
105
+ # "writable", "used_bytes" and "limit_bytes". With `copy_from` (other
106
+ # Computer ids of the project), it may also read their artifacts, and
107
+ # "copy_from" maps each id to its prefix. Works whether or not the
108
+ # Computer is running. Returns the response Hash.
109
+ def artifact_token(computer_id, copy_from: nil)
110
+ raise ArgumentError, "computer_id required" if computer_id.to_s.empty?
111
+
112
+ copy_from = Array(copy_from).map(&:to_s)
113
+ body = copy_from.empty? ? {} : { copy_from: copy_from }
114
+ resp = post("#{REGISTER_PATH}/#{computer_id}/artifacts/token", json: body, op: "artifact token")
115
+ if resp.status == 404
116
+ raise UnknownComputerError.new(error_message(resp, "Computer not found"), **error_attributes(resp))
117
+ end
118
+ raise PortalError.new(error_message(resp, "Artifact token unavailable"), **error_attributes(resp)) unless resp.status == 200
119
+
120
+ payload = parse_json(resp)
121
+ unless artifact_token?(payload, copy_from)
122
+ raise PortalError.new("Invalid artifact token response", **error_attributes(resp))
123
+ end
124
+ payload
125
+ end
126
+
127
+ # One authorization-registry read before Start; never a Computer status poll.
101
128
  def current_binding(computer_id)
102
129
  resp = get("#{REGISTER_PATH}/#{computer_id}", op: "computer binding lookup")
103
130
  if resp.status == 404
@@ -114,7 +141,7 @@ module PineSandbox
114
141
 
115
142
  # Permanently delete `computer_id`. The portal tombstones it at once (no
116
143
  # further attach, renewal, or lease refresh), destroys its bound sandboxes
117
- # asynchronously, keeps saved browser state for 24 hours before purging it,
144
+ # asynchronously, keeps saved browser state for 25 hours before purging it,
118
145
  # and never accepts the id again. Idempotent and non-disclosing: an unknown,
119
146
  # already-deleted, or cross-project id is the same 204. Returns true.
120
147
  def delete_computer(computer_id)
@@ -134,9 +161,9 @@ module PineSandbox
134
161
  end
135
162
  end
136
163
 
137
- # Mint the per-attach credentials for one runtime + boot. `sandbox_id` is the
138
- # provisioned sandbox this attach targets — the portal records the
139
- # sandbox→computer attribution at mint (its renewal gate keys off it).
164
+ # Mint the per-attach credentials for one Computer sandbox and boot. `sandbox_id`
165
+ # is the sandbox id this attach targets — the portal records which Computer it
166
+ # belongs to at mint (its renewal gate keys off it).
140
167
  # Returns a Hash with string keys: "bind_token", "broker_grant",
141
168
  # "key_assertion", "binding_revision", and the effective "location"
142
169
  # (plus expiry metadata).
@@ -208,13 +235,15 @@ module PineSandbox
208
235
  )
209
236
  when 412
210
237
  problem = parse_json(resp)
238
+ # A Portal that predates current_revision sends only current_binding_revision.
239
+ revision = problem.fetch("current_revision") { problem["current_binding_revision"] }
211
240
  raise BindingRevisionConflictError.new(
212
241
  binding_conflict_message(
213
242
  error_message(resp, "binding revision changed; reload and adopt the winning binding"),
214
- problem["current_binding_revision"], problem["current_sandbox_id"]
243
+ revision, problem["current_sandbox_id"]
215
244
  ),
216
- current_binding_revision: problem["current_binding_revision"],
217
- current_runtime_id: problem["current_sandbox_id"],
245
+ current_binding_revision: revision,
246
+ current_sandbox_id: problem["current_sandbox_id"],
218
247
  **error_attributes(resp)
219
248
  )
220
249
  else
@@ -243,6 +272,19 @@ module PineSandbox
243
272
  { country: normalized.fetch("country") }
244
273
  end
245
274
 
275
+ def artifact_token?(payload, copy_from)
276
+ return false unless payload.is_a?(Hash)
277
+ return false unless %w[access_token expires_at endpoint bucket].all? { |key| payload[key].is_a?(String) && !payload[key].empty? }
278
+ return false unless payload["prefix"].is_a?(String) && [true, false].include?(payload["writable"])
279
+ return false unless payload["used_bytes"].is_a?(Integer) && payload["limit_bytes"].is_a?(Integer)
280
+
281
+ Time.iso8601(payload["expires_at"])
282
+ prefixes = payload["copy_from"] || {}
283
+ prefixes.is_a?(Hash) && copy_from.all? { |id| prefixes[id].is_a?(String) }
284
+ rescue ArgumentError
285
+ false
286
+ end
287
+
246
288
  def response_location?(value)
247
289
  value.is_a?(Hash) && value["country"].is_a?(String) &&
248
290
  value["country"].match?(/\A[A-Z]{2}\z/)
@@ -347,12 +389,12 @@ module PineSandbox
347
389
 
348
390
  # The attach revision is a fence against two live attaches of one
349
391
  # Computer, so the SDK never retries past it; the message says what to do.
350
- def binding_conflict_message(message, current_revision, current_runtime_id)
392
+ def binding_conflict_message(message, current_revision, current_sandbox_id)
351
393
  return message unless current_revision.is_a?(Integer)
352
394
 
353
- holder = current_runtime_id.to_s.empty? ? "" : " (runtime #{current_runtime_id})"
395
+ holder = current_sandbox_id.to_s.empty? ? "" : " (Computer sandbox #{current_sandbox_id})"
354
396
  "#{message}. Another start changed the Computer binding#{holder}. " \
355
- "Do not retry automatically. Check the current runtime before starting again."
397
+ "Do not retry automatically. Check whether the Computer is still running before starting again."
356
398
  end
357
399
 
358
400
  def error_message(resp, default)
@@ -15,7 +15,7 @@ module PineSandbox
15
15
  module_function
16
16
 
17
17
  # info binds the sealed payload to the receiving process identity so a
18
- # ciphertext sealed for one runtime/boot cannot be opened by another.
18
+ # ciphertext sealed for one Computer sandbox/boot cannot be opened by another.
19
19
  # MUST byte-match the receiver's info string.
20
20
  #
21
21
  # @param pod_uid [String]
@@ -26,8 +26,8 @@ module PineSandbox
26
26
  end
27
27
 
28
28
  # restore_info is the two-round v3 restore's round-2 domain: the
29
- # integrator-unwrapped component secrets sealed back to the same runtime
30
- # key. Same suite, DISTINCT domain — a bind ciphertext can never be
29
+ # integrator-unwrapped component secrets sealed back to the same Computer
30
+ # sandbox key. Same suite, DISTINCT domain — a bind ciphertext can never be
31
31
  # replayed as a secrets payload or vice versa. MUST byte-match the
32
32
  # receiver's restore info string.
33
33
  def restore_info(pod_uid, coord_boot_id)
@@ -36,7 +36,7 @@ module PineSandbox
36
36
 
37
37
  # seal encrypts plaintext to the recipient's raw 32-byte X25519 public
38
38
  # key. The AAD is fixed empty (the portal attach path carries no nonce —
39
- # payload-to-runtime binding lives in the info string).
39
+ # payload-to-sandbox binding lives in the info string).
40
40
  #
41
41
  # @param recipient_pub_raw [String] 32 raw bytes (NOT base64).
42
42
  # @param plaintext [String] bytes to seal.
@@ -7,6 +7,7 @@ require_relative "control_plane"
7
7
  require_relative "connection"
8
8
  require_relative "computer"
9
9
  require_relative "diagnostics"
10
+ require_relative "artifacts"
10
11
 
11
12
  module PineSandbox
12
13
  # The SDK entry point. Holds the project credential (`pk_`) + the endpoint
@@ -107,6 +108,7 @@ module PineSandbox
107
108
  PineSandbox::Zone.new(endpoint, control_host: control_host, gateway_url: gateway_url)
108
109
  end
109
110
  @spec_version = spec_version
111
+ @clock = clock
110
112
  @diagnostics = Diagnostics.new(api_key: api_key, zone: @zone) unless api_key.to_s.empty?
111
113
 
112
114
  token_source =
@@ -176,7 +178,7 @@ module PineSandbox
176
178
  provider.available_locations
177
179
  end
178
180
 
179
- # Optional configuration discovery, not a runtime operation prerequisite.
181
+ # Optional configuration discovery, not a lifecycle operation prerequisite.
180
182
  def project_policy
181
183
  provider = @connection.attach_provider
182
184
  raise ArgumentError, "project policy requires a project API key provider" unless provider.respond_to?(:project_policy)
@@ -184,12 +186,25 @@ module PineSandbox
184
186
  provider.project_policy
185
187
  end
186
188
 
189
+ # A Computer's artifacts, running or stopped: the files it keeps beyond
190
+ # its sessions, to list, upload, download, open, copy and delete (see
191
+ # Artifacts). Needs the project API key; no request is sent until the
192
+ # first operation.
193
+ def artifacts(computer_id)
194
+ raise ArgumentError, "id must be a lowercase UUIDv7" unless computer_id.to_s.match?(Computer::UUID_V7)
195
+
196
+ provider = @connection.attach_provider
197
+ raise ArgumentError, "artifacts require a project API key provider" unless provider.respond_to?(:artifact_token)
198
+
199
+ Artifacts.new(computer_id: computer_id, token_source: provider, clock: @clock)
200
+ end
201
+
187
202
  # PERMANENTLY delete a Computer: its identity and saved state end (stop
188
203
  # and kill keep both). Accepts a PineSandbox::Computer or its id String. A
189
204
  # Computer that is still attached is force-terminated first (#kill — no
190
205
  # final capture, the state is being deleted). The portal tombstones the id
191
206
  # immediately, destroys any remaining sandboxes asynchronously, purges saved
192
- # state after 24 hours, and never accepts the id again. Idempotent: unknown,
207
+ # state after 25 hours, and never accepts the id again. Idempotent: unknown,
193
208
  # already-deleted, and other projects' ids all return true. No undelete.
194
209
  def delete_computer(computer)
195
210
  computer_id =
@@ -253,8 +268,8 @@ module PineSandbox
253
268
  location ? provider.register_computer(computer_id, location: location) : provider.register_computer(computer_id)
254
269
  end
255
270
 
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.
271
+ # Re-attach an EXISTING persistent Computer by its stored id + state key. The
272
+ # Computer starts and is bound with its prior state restored.
258
273
  # The id may be given positionally or as `id:` (matching #create_computer).
259
274
  def start_computer(*args, **options)
260
275
  if args.first.is_a?(Computer::Credentials)
@@ -264,11 +279,11 @@ module PineSandbox
264
279
  credentials, *args = args
265
280
  options = credentials_options(credentials, options)
266
281
  end
267
- start_existing_computer(*args, **options, recover_runtime: false)
282
+ start_existing_computer(*args, **options, recover: false)
268
283
  end
269
284
 
270
285
  # 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
286
+ # its Start response before saving the Computer token. Never follow a newer
272
287
  # binding during this invocation; project authorization is sufficient.
273
288
  def stop_computer(computer_id, **wait_options)
274
289
  raise ArgumentError, "id must be a lowercase UUIDv7" unless computer_id.to_s.match?(Computer::UUID_V7)
@@ -277,27 +292,27 @@ module PineSandbox
277
292
  raise ArgumentError, "Stop by ID requires a provider with current_binding"
278
293
  end
279
294
  reference = provider.current_binding(computer_id)
280
- runtime_id = reference["sandbox_id"]
281
- return true if runtime_id.nil?
295
+ sandbox_id = reference["sandbox_id"]
296
+ return true if sandbox_id.nil?
282
297
 
283
- RuntimeHandle.new(control_plane: @connection.control_plane, runtime_id: runtime_id).stop(**wait_options)
298
+ SandboxHandle.new(control_plane: @connection.control_plane, sandbox_id: sandbox_id).stop(**wait_options)
284
299
  rescue UnknownComputerError
285
300
  true
286
301
  end
287
302
 
288
- # Explicitly discard a failed runtime, wait for release, then Start from
289
- # committed state. This loses unsaved memory; it never replaces a healthy
303
+ # Explicitly shut down a Computer that failed, wait for the shutdown, then
304
+ # Start from committed state. This loses unsaved memory; it never replaces a healthy
290
305
  # paused or running Computer and never silently follows failed Resume.
291
306
  def recover_computer(*args, **options)
292
307
  if args.first.is_a?(Computer::Credentials)
293
308
  credentials, *args = args
294
309
  options = credentials_options(credentials, options)
295
310
  end
296
- start_existing_computer(*args, **options, recover_runtime: true)
311
+ start_existing_computer(*args, **options, recover: true)
297
312
  end
298
313
 
299
314
  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)
315
+ binding_revision: 0, prior_keys: {}, ephemeral: false, location: nil, recover: false, **attach_opts)
301
316
  if positional_id && id && positional_id != id
302
317
  raise ArgumentError, "attach_computer got two different ids (positional and id:)"
303
318
  end
@@ -305,22 +320,22 @@ module PineSandbox
305
320
  raise ArgumentError, "attach_computer requires the Computer id" if id.nil?
306
321
 
307
322
  provider = @connection.attach_provider
308
- if recover_runtime && !provider.respond_to?(:current_binding)
323
+ if recover && !provider.respond_to?(:current_binding)
309
324
  raise ArgumentError, "saved-state recovery requires a provider with current_binding"
310
325
  end
311
326
  if provider.respond_to?(:current_binding)
312
327
  reference = provider.current_binding(id)
313
328
  binding_revision = reference.fetch("binding_revision")
314
- if (runtime_id = reference["sandbox_id"])
329
+ if (sandbox_id = reference["sandbox_id"])
315
330
  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)
331
+ sandbox = @connection.control_plane.get(sandbox_id)
332
+ unless sandbox.status == :terminated && sandbox.reason == "COMPUTE_RELEASED"
333
+ unless recover || sandbox.status == :stopping
334
+ raise SandboxRetainedError.new(sandbox_id: sandbox_id, state: sandbox.status)
320
335
  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"
336
+ handle = SandboxHandle.new(control_plane: @connection.control_plane, sandbox_id: sandbox_id)
337
+ unless handle.release_failed_sandbox(sandbox)
338
+ raise TimeoutError, "The failed Computer's shutdown is unconfirmed; nothing was started"
324
339
  end
325
340
  end
326
341
  rescue NotFoundError
@@ -343,14 +358,14 @@ module PineSandbox
343
358
  alias_method :attach_computer, :start_computer
344
359
 
345
360
  # 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
361
+ # sandbox_id, ct_}` — NO new start, NO re-bind. For driving a running Computer
347
362
  # 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,
363
+ # backend that cached the `ct_`. Use #start_computer to start it again with state
364
+ # restored.
365
+ def adopt_existing(id:, key:, sandbox_id:, computer_token:, status: :running,
351
366
  binding_revision: 0, ephemeral: false)
352
367
  Computer.new(id, key: key, binding_revision: binding_revision, ephemeral: ephemeral)
353
- .adopt(@connection, runtime_id: runtime_id || sandbox_id, computer_token: computer_token,
368
+ .adopt(@connection, sandbox_id: sandbox_id, computer_token: computer_token,
354
369
  status: status, binding_revision: binding_revision)
355
370
  end
356
371
 
@@ -366,7 +381,7 @@ module PineSandbox
366
381
  raise ArgumentError, "credentials and binding belong to different Computers"
367
382
  end
368
383
  computer = adopt_existing(id: credentials.id.dup, key: credentials.key.dup,
369
- runtime_id: binding.runtime_id.dup, computer_token: binding.computer_token.dup,
384
+ sandbox_id: binding.sandbox_id.dup, computer_token: binding.computer_token.dup,
370
385
  binding_revision: binding.binding_revision, ephemeral: credentials.ephemeral)
371
386
  configure_capture_keypairs(computer, credentials.capture_keypair, credentials.prior_capture_keypairs)
372
387
  credentials.prior_keys.each { |version, key| computer.add_prior_key(version, key.dup) }
@@ -374,18 +389,41 @@ module PineSandbox
374
389
  end
375
390
 
376
391
  # Block form: yields a ready Computer and stops it on exit.
377
- # Failed saves and unconfirmed deletion are raised to the caller.
392
+ # Failed saves and unconfirmed deletion are raised to the caller. When the
393
+ # block raises, its error propagates and a failed Stop is only warned about.
378
394
  def with_computer(**opts)
379
395
  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"
396
+ failure = nil
397
+ begin
398
+ yield computer
399
+ rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised
400
+ failure = e
401
+ raise
402
+ ensure
403
+ # Also runs when the block leaves early with return, break or throw.
404
+ if failure
405
+ stop_after_failure(computer, failure)
406
+ elsif !computer.stop
407
+ raise TimeoutError, "Computer #{computer.id} stop is unconfirmed; retry Stop with its saved binding"
408
+ end
384
409
  end
385
410
  end
386
411
 
387
412
  private
388
413
 
414
+ # Stop after with_computer's block failed: the block's error is the one the
415
+ # caller gets, so a Stop that fails or stays unconfirmed is only reported.
416
+ # The failure is recorded where the block raised, never read from $!, which
417
+ # inside a caller's own rescue clause is the exception that clause handles.
418
+ def stop_after_failure(computer, failure)
419
+ return if computer.stop
420
+
421
+ warn "PineSandbox: Computer #{computer.id} stop is unconfirmed; retry Stop with its saved binding " \
422
+ "(while #{failure.class} propagates)"
423
+ rescue StandardError => e
424
+ warn "PineSandbox: Computer #{computer.id} stop failed (#{e.class}: #{e.message}) while #{failure.class} propagates"
425
+ end
426
+
389
427
  def credentials_options(credentials, options)
390
428
  unless credentials.is_a?(Computer::Credentials)
391
429
  raise ArgumentError, "credentials must be a PineSandbox::Computer::Credentials"
@@ -401,13 +439,13 @@ module PineSandbox
401
439
  computer.attach(@connection, **attach_opts)
402
440
  rescue StandardError => e
403
441
  revision = computer.binding_revision
404
- runtime_id = computer.last_authorized_runtime_id
405
- raise if revision <= initial_revision || runtime_id.to_s.empty?
442
+ sandbox_id = computer.last_authorized_sandbox_id
443
+ raise if revision <= initial_revision || sandbox_id.to_s.empty?
406
444
 
407
445
  raise AttachAuthorizationCommittedError.new(
408
- "attach authorization committed at revision #{revision} for runtime #{runtime_id}, " \
446
+ "attach authorization committed at revision #{revision} for Computer sandbox #{sandbox_id}, " \
409
447
  "but the bind failed: #{e.message}",
410
- binding_revision: revision, runtime_id: runtime_id,
448
+ binding_revision: revision, sandbox_id: sandbox_id,
411
449
  credentials: recovery_credentials,
412
450
  status: (e.respond_to?(:status) ? e.status : nil), cause: e
413
451
  )