pinesandbox 0.0.1 → 0.3.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -0
  3. data/LICENSE +201 -0
  4. data/README.md +670 -4
  5. data/Rakefile +5 -0
  6. data/lib/pinesandbox/adapters/coordinator_adapter.rb +1399 -0
  7. data/lib/pinesandbox/adapters/snapshot_adapter.rb +37 -0
  8. data/lib/pinesandbox/agent_events.rb +213 -0
  9. data/lib/pinesandbox/agent_mode.rb +252 -0
  10. data/lib/pinesandbox/agent_turn.rb +97 -0
  11. data/lib/pinesandbox/attach_credentials_source.rb +392 -0
  12. data/lib/pinesandbox/author_terminal_translator.rb +96 -0
  13. data/lib/pinesandbox/bind_hpke.rb +54 -0
  14. data/lib/pinesandbox/capture_keypair.rb +43 -0
  15. data/lib/pinesandbox/client.rb +469 -0
  16. data/lib/pinesandbox/clock.rb +51 -0
  17. data/lib/pinesandbox/computer.rb +1075 -0
  18. data/lib/pinesandbox/computer_binding.rb +51 -0
  19. data/lib/pinesandbox/computer_credentials.rb +175 -0
  20. data/lib/pinesandbox/connection.rb +26 -0
  21. data/lib/pinesandbox/control_events.rb +156 -0
  22. data/lib/pinesandbox/control_plane.rb +341 -0
  23. data/lib/pinesandbox/control_state.rb +31 -0
  24. data/lib/pinesandbox/control_token_source.rb +232 -0
  25. data/lib/pinesandbox/delegated_connection.rb +46 -0
  26. data/lib/pinesandbox/diagnostics.rb +83 -0
  27. data/lib/pinesandbox/drive_mode.rb +67 -0
  28. data/lib/pinesandbox/error_taxonomy.rb +105 -0
  29. data/lib/pinesandbox/errors.rb +505 -0
  30. data/lib/pinesandbox/exec_stream_parser.rb +90 -0
  31. data/lib/pinesandbox/models/artifact.rb +51 -0
  32. data/lib/pinesandbox/models/computer_session.rb +57 -0
  33. data/lib/pinesandbox/models/computer_tab.rb +30 -0
  34. data/lib/pinesandbox/models/file_entry.rb +38 -0
  35. data/lib/pinesandbox/operations.rb +131 -0
  36. data/lib/pinesandbox/response_handler.rb +88 -0
  37. data/lib/pinesandbox/sandbox_details.rb +25 -0
  38. data/lib/pinesandbox/sandbox_handle.rb +247 -0
  39. data/lib/pinesandbox/session.rb +647 -0
  40. data/lib/pinesandbox/skills.rb +142 -0
  41. data/lib/pinesandbox/spec_version_middleware.rb +34 -0
  42. data/lib/pinesandbox/sse_frame_parser.rb +95 -0
  43. data/lib/pinesandbox/state_hpke.rb +57 -0
  44. data/lib/pinesandbox/version.rb +3 -0
  45. data/lib/pinesandbox/zone.rb +173 -0
  46. data/lib/pinesandbox.rb +40 -2
  47. data/pinesandbox.gemspec +49 -0
  48. metadata +157 -10
@@ -0,0 +1,505 @@
1
+ require_relative "error_taxonomy"
2
+
3
+ module PineSandbox
4
+ # Marks a renamed public method: the old name keeps working and, when Ruby's
5
+ # deprecation warnings are on (Warning[:deprecated] = true), warns once per
6
+ # call site with the name to use instead.
7
+ # @api private
8
+ def self.deprecated_name(old, new)
9
+ warn("PineSandbox: #{old} is deprecated; use #{new}", category: :deprecated, uplevel: 2)
10
+ end
11
+
12
+ # Fold a request_id INTO an error message string. The portal/Computer error classes
13
+ # below carry #request_id as an accessor, but integrators overwhelmingly log only
14
+ # `e.message` — so the one identifier that correlates a failure against the server's
15
+ # logs must land in the message too. No-op when there's no id (bind/connection faults
16
+ # that never reached a gateway-fronted response). ApiError folds its context the same way.
17
+ def self.with_request_id(message, request_id)
18
+ return message if request_id.to_s.empty?
19
+
20
+ "#{message} (request_id=#{request_id})"
21
+ end
22
+
23
+ # Fold the RESOURCE-FIRST troubleshooting context INTO an error message: op (WHICH
24
+ # operation — "<METHOD> <path>", carrying the computer/sandbox id where the call is
25
+ # resource-scoped, so it names WHICH resource) then request_id (the secondary precision
26
+ # handle). Each part only when present; no-op when both are absent. Integrators log only
27
+ # `e.message`, so a generic handler must see the operation without extra plumbing.
28
+ def self.with_context(message, op: nil, request_id: nil)
29
+ parts = []
30
+ parts << "op=#{op}" if op && !op.to_s.empty?
31
+ parts << "request_id=#{request_id}" if request_id && !request_id.to_s.empty?
32
+ parts.empty? ? message : "#{message} (#{parts.join(', ')})"
33
+ end
34
+
35
+ # Base for every error this SDK raises.
36
+ class Error < StandardError
37
+ # Set on a run's error when no response arrived (it is then also a
38
+ # RunUnconfirmedError): the run may have started. Send it again with this
39
+ # idempotency_key, which returns the turn it started, or starts it if it
40
+ # never did.
41
+ attr_accessor :idempotency_key
42
+ end
43
+
44
+ # Marks a run whose response never arrived, after the SDK resent it for as
45
+ # long as its budget allows: the run may have started. The error is still
46
+ # the failure that ended the resends (a ConnectionError, TimeoutError or
47
+ # ApiError, so existing rescues keep catching it), extended with this
48
+ # module, so `rescue PineSandbox::RunUnconfirmedError` catches it too. Send
49
+ # the run again with #idempotency_key, which returns the turn it started,
50
+ # or starts it if it never did.
51
+ module RunUnconfirmedError
52
+ def idempotency_key = @idempotency_key
53
+ end
54
+
55
+ # An HTTP error response from the Computer API. #status is the HTTP status;
56
+ # #problem_type and #problem_extensions carry an RFC 9457 problem detail
57
+ # (#field, #reason, #handoff_id, #request_id and #retryable? read its common
58
+ # members). The message is resource-first: the Computer sandbox (#sandbox_id) and the
59
+ # operation (#op, "<METHOD> <path>") come before #request_id, so a handler
60
+ # that logs only the message can still route a report.
61
+ class ApiError < Error
62
+ attr_reader :status, :error_code, :error_message, :response_body, :problem_type, :problem_extensions
63
+
64
+ def initialize(status:, error_code: nil, error_message: nil, response_body: nil,
65
+ problem_type: nil, problem_extensions: nil)
66
+ @status = status
67
+ @error_code = error_code
68
+ @error_message = redact_credentials(error_message)
69
+ @response_body = redact_credentials(response_body)
70
+ @problem_type = problem_type
71
+ @problem_extensions = problem_extensions || {}
72
+ @built_message = build_message
73
+ super(@built_message)
74
+ end
75
+
76
+ # The id of the Computer sandbox that answered, when the request reached a Computer.
77
+ def sandbox_id = problem_extensions["sandbox_id"]
78
+
79
+ # The response's machine `code` ({code, message} bodies), when present.
80
+ def code = error_code
81
+
82
+ def op = problem_extensions["op"]
83
+ def handoff_id = problem_extensions["handoff_id"]
84
+ def field = problem_extensions["field"]
85
+ def reason = problem_extensions["reason"]
86
+ def request_id = problem_extensions["request_id"]
87
+
88
+ # The server's verdict on whether retrying the same operation is sane.
89
+ # When the response doesn't say, the problem type's entry in the shared
90
+ # error taxonomy decides (412 and other 5xx true, 501 and the rest false).
91
+ # A true verdict never by itself makes a mutation safe to send again.
92
+ def retryable?
93
+ wire = problem_extensions["retryable"]
94
+ return wire if wire == true || wire == false
95
+
96
+ ErrorTaxonomy.retryable?(problem_type, status)
97
+ end
98
+
99
+ # The response's Retry-After in seconds, when it set one.
100
+ attr_accessor :retry_after
101
+
102
+ # Folds the sandbox id and operation onto an error raised before they were
103
+ # known, filling only what the server didn't send, and rebuilds the message.
104
+ # Returns self, for re-raising.
105
+ def attach_context!(sandbox_id: nil, op: nil)
106
+ problem_extensions["sandbox_id"] ||= sandbox_id if sandbox_id && !sandbox_id.to_s.empty?
107
+ problem_extensions["op"] ||= op if op && !op.to_s.empty?
108
+ @built_message = build_message
109
+ self
110
+ end
111
+
112
+ def message = @built_message
113
+ alias_method :to_s, :message
114
+
115
+ private
116
+
117
+ def build_message
118
+ base =
119
+ if error_message && !error_message.empty?
120
+ error_message
121
+ elsif error_code
122
+ "API request failed with status #{status} (#{error_code})"
123
+ else
124
+ "API request failed with status #{status}"
125
+ end
126
+ parts = []
127
+ parts << "sandbox=#{sandbox_id}" if sandbox_id && !sandbox_id.to_s.empty?
128
+ parts << "op=#{op}" if op && !op.to_s.empty?
129
+ parts << "request_id=#{request_id}" if request_id && !request_id.to_s.empty?
130
+ parts.empty? ? base : "#{base} (#{parts.join(', ')})"
131
+ end
132
+
133
+ # A server can echo request headers into an error body, which becomes
134
+ # #response_body and, when it isn't JSON, the message. Callers log both, so a
135
+ # service credential never stays in either.
136
+ def redact_credentials(body)
137
+ return body unless body.is_a?(String)
138
+
139
+ body.gsub(/(X-EXECD-ACCESS-TOKEN["']?\s*[:=]\s*["']?)[^"'\s,}]+/i, '\1[REDACTED]')
140
+ end
141
+ end
142
+
143
+ # The Computer does not serve the public agent protocol. The SDK concludes it
144
+ # from capability discovery, so it has no HTTP status, and the request that
145
+ # led to discovery is not retried against an older protocol. This is not an
146
+ # API conflict and must never trigger conflict recovery or automatic steering.
147
+ class AgentProtocolUnsupported < Error
148
+ def initialize
149
+ super("This Computer does not support the required public agent protocol. Stop this Computer, then Start to update it.")
150
+ end
151
+ end
152
+
153
+ # Status tiers: rescue these whether or not the response named a problem type.
154
+ class BadRequestError < ApiError; end
155
+ class UnauthorizedError < ApiError; end
156
+ class ForbiddenError < ApiError; end
157
+ class NotFoundError < ApiError; end
158
+ class ConflictError < ApiError; end
159
+ class GoneError < ApiError; end
160
+ class PreconditionFailedError < ApiError; end
161
+ class UnprocessableEntityError < ApiError; end
162
+ class PreconditionRequiredError < ApiError; end
163
+ class ServerError < ApiError; end
164
+ class ServiceUnavailableError < ServerError; end
165
+
166
+ # Typed problem details, each under its status tier so tier rescues still
167
+ # catch them.
168
+ class ControlNotHeld < ConflictError; end
169
+ # The Computer's access lapsed and Pine refused to renew it (the project is
170
+ # suspended or out of credits): control is cut, reads stay open. Re-attach,
171
+ # or report the suspension.
172
+ class LeaseExpired < ForbiddenError; end
173
+ # The project, or this Computer's binding, doesn't have a feature the call
174
+ # needs, such as the shell grant for Session#exec or the artifacts feature for
175
+ # the artifact methods. Retrying won't help.
176
+ class FeatureDisabled < ForbiddenError; end
177
+ # The access lapsed and renewing it failed for now. Retry; don't re-attach.
178
+ class LeaseRefreshUnavailable < ServiceUnavailableError; end
179
+ class AnotherSessionHuman < ConflictError; end
180
+ class ConcurrentTransition < ConflictError; end
181
+ class InvalidTransition < UnprocessableEntityError; end
182
+ class ActorTypeRequired < BadRequestError; end
183
+ class InvalidField < BadRequestError; end
184
+ class EmptyPatch < BadRequestError; end
185
+ class MalformedJSON < BadRequestError; end
186
+ class SessionNotFound < NotFoundError; end
187
+ class DesktopBindFailed < ServiceUnavailableError; end
188
+ class HandoffNotFound < NotFoundError; end
189
+ class HandoffUnavailable < ConflictError; end
190
+ class HandoffNotHuman < NotFoundError; end
191
+ class HandoffExpired < GoneError; end
192
+ class HandoffPersistenceFailed < ServerError; end
193
+
194
+ # Problem `type` → its typed error; anything else falls back to the status tier.
195
+ PROBLEM_TYPE_CLASSES = {
196
+ "/errors/control-not-held" => ControlNotHeld,
197
+ "/errors/lease-expired" => LeaseExpired,
198
+ "/errors/feature-disabled" => FeatureDisabled,
199
+ "/errors/lease-refresh-unavailable" => LeaseRefreshUnavailable,
200
+ "/errors/another-session-human" => AnotherSessionHuman,
201
+ "/errors/concurrent-transition" => ConcurrentTransition,
202
+ "/errors/invalid-transition" => InvalidTransition,
203
+ "/errors/actor-type-required" => ActorTypeRequired,
204
+ "/errors/invalid-field" => InvalidField,
205
+ "/errors/empty-patch" => EmptyPatch,
206
+ "/errors/malformed-json" => MalformedJSON,
207
+ "/errors/session-not-found" => SessionNotFound,
208
+ "/errors/desktop-bind-failed" => DesktopBindFailed,
209
+ "/errors/handoff-not-found" => HandoffNotFound,
210
+ "/errors/handoff-unavailable" => HandoffUnavailable,
211
+ "/errors/handoff-not-human" => HandoffNotHuman,
212
+ "/errors/handoff-expired" => HandoffExpired,
213
+ "/errors/handoff-persistence-failed" => HandoffPersistenceFailed,
214
+ "/errors/precondition-failed" => PreconditionFailedError,
215
+ "/errors/precondition-required" => PreconditionRequiredError,
216
+ "/errors/unauthorized" => UnauthorizedError,
217
+ "/errors/forbidden" => ForbiddenError
218
+ }.freeze
219
+
220
+ def self.problem_class_for(problem_type) = PROBLEM_TYPE_CLASSES[problem_type]
221
+
222
+ # Failures with no HTTP response, or a timeout status (408/504) without a
223
+ # problem body. #retry_after is the response's Retry-After in seconds, when
224
+ # a timeout status set one.
225
+ class TimeoutError < Error
226
+ attr_accessor :retry_after
227
+ end
228
+ class ConnectionError < Error; end
229
+ class ReadyTimeoutError < Error; end
230
+
231
+ # The Computer reported failed, terminated or unavailable before the
232
+ # lifecycle operation finished.
233
+ class SandboxFailedError < Error
234
+ attr_reader :sandbox_id, :state
235
+
236
+ def initialize(message = nil, sandbox_id: nil, state: nil)
237
+ @sandbox_id = sandbox_id
238
+ @state = state
239
+ super(message || "Computer sandbox #{sandbox_id} reported #{state.inspect} before completing the lifecycle operation")
240
+ end
241
+ end
242
+
243
+ class StreamError < Error; end
244
+ class StreamingUnsupportedError < Error; end
245
+
246
+ # An event stream could not be re-established: it reconnected
247
+ # RetryPolicy::STREAM_RECONNECT_BUDGET times in a row without delivering an
248
+ # event. #cause is the last failure, when there was one. Reopen it with the
249
+ # last event id you processed. A ConnectionError, so connection rescues
250
+ # keep catching it.
251
+ class StreamLostError < ConnectionError
252
+ attr_reader :op, :request_id
253
+
254
+ def initialize(op: nil, request_id: nil, cause: nil)
255
+ @op = op
256
+ @request_id = request_id
257
+ @stream_cause = cause
258
+ detail = cause ? ": #{cause.message}" : ""
259
+ super(PineSandbox.with_context("event stream lost (reconnect budget exhausted)#{detail}", op: op, request_id: request_id))
260
+ end
261
+
262
+ def cause = @stream_cause || super
263
+ end
264
+
265
+
266
+ # The source binding is retained; no sandbox deletion follows this error.
267
+ class StopSaveError < Error
268
+ attr_reader :save_error
269
+
270
+ def initialize(error, retryable: nil)
271
+ @save_error = error
272
+ @retryable = retryable
273
+ super("Computer stop could not confirm saved state: #{error.message}")
274
+ end
275
+
276
+ def problem_type = save_error.respond_to?(:problem_type) ? save_error.problem_type : "/errors/stop-failed"
277
+ def retryable? = @retryable.nil? ? (save_error.respond_to?(:retryable?) && save_error.retryable?) : @retryable
278
+ end
279
+
280
+ # BindError and its subclasses are the typed outcomes of Computer#attach.
281
+ # `#status` is the HTTP status when one is available; `#cause` is the
282
+ # underlying ApiError, so callers can inspect the raw response if needed.
283
+ class BindError < Error
284
+ attr_reader :status, :cause
285
+
286
+ def initialize(message, status: nil, cause: nil)
287
+ @status = status
288
+ @cause = cause
289
+ super(message)
290
+ end
291
+ end
292
+
293
+ # Start found the Computer still running or paused, and started nothing.
294
+ # Preserve the BindError superclass for integrations already rescuing Start conflicts.
295
+ class SandboxRetainedError < BindError
296
+ attr_reader :sandbox_id, :state
297
+
298
+ def initialize(sandbox_id:, state:)
299
+ @sandbox_id, @state = sandbox_id, state
300
+ super("Computer hasn't stopped (Computer sandbox #{sandbox_id}, state #{state}); Stop it or Resume retained memory before Start")
301
+ end
302
+ end
303
+
304
+ # ControlTokenError is the typed outcome of exchanging a `pk_` client key at
305
+ # the portal's POST /v1/control-token (ControlTokenSource).
306
+ # `#status`/`#body`/`#request_id` carry the portal response for diagnosis;
307
+ # `#op` is "<METHOD> <path>" (WHICH portal call — the primary spine); `#retry_after`
308
+ # is the parsed Retry-After (seconds) on a RateLimited.
309
+ class ControlTokenError < Error
310
+ attr_reader :status, :body, :request_id, :op, :retry_after
311
+
312
+ def initialize(message, status: nil, body: nil, request_id: nil, op: nil, retry_after: nil)
313
+ @status = status
314
+ @body = body
315
+ @request_id = request_id
316
+ @op = op
317
+ @retry_after = retry_after
318
+ super(PineSandbox.with_context(message, op: op, request_id: request_id))
319
+ end
320
+ end
321
+
322
+ # 401 — the pk_ key is missing, malformed, or unknown, raised by the
323
+ # control-token mint. (The attach-credentials flow reports its own 401 as an
324
+ # AttachCredentialsError with #status == 401, so a `rescue AttachCredentialsError`
325
+ # around attach stays complete.)
326
+ class InvalidClientKey < ControlTokenError; end
327
+
328
+ # 403 — the key is valid but its scope may not mint control-plane tokens
329
+ # (needs pk_session or pk_admin).
330
+ class InsufficientScope < ControlTokenError; end
331
+
332
+ # 429 — the portal rate-limited the mint. `#retry_after` is honored on retry.
333
+ class RateLimited < ControlTokenError; end
334
+
335
+ # Base error for the pk_-authenticated Portal machine API. #op is
336
+ # "<METHOD> <path>" and #request_id is the secondary precision handle.
337
+ class PortalError < Error
338
+ attr_reader :status, :body, :request_id, :op, :problem_type, :reason, :retry_after
339
+
340
+ def initialize(message, status: nil, body: nil, request_id: nil, op: nil,
341
+ problem_type: nil, reason: nil, retry_after: nil)
342
+ @status = status
343
+ @body = body
344
+ @request_id = request_id
345
+ @op = op
346
+ @problem_type = problem_type
347
+ @reason = reason
348
+ @retry_after = retry_after
349
+ super(PineSandbox.with_context(message, op: op, request_id: request_id))
350
+ end
351
+ end
352
+
353
+ # AttachCredentialsError + subclasses are the typed outcomes of optional
354
+ # pre-registration and per-attach credential minting.
355
+ class AttachCredentialsError < PortalError; end
356
+
357
+ # A failed project-authenticated read of the available location catalog.
358
+ class LocationDiscoveryError < PortalError; end
359
+
360
+ # 404 — the computer_id is deleted or owned by another project. A genuinely
361
+ # new id is lazily recorded during first attach.
362
+ class UnknownComputerError < AttachCredentialsError; end
363
+
364
+ # 409/422 — registration refused: the computer_id is taken by another
365
+ # project, tombstoned, or malformed.
366
+ class ComputerRegistrationError < AttachCredentialsError; end
367
+
368
+ # 422 — deletion refused: the computer_id is malformed (not a lowercase
369
+ # UUIDv7). Also raised when a custom attach credentials provider cannot
370
+ # delete. Unknown, deleted, and cross-project ids are not errors (204).
371
+ class ComputerDeletionError < AttachCredentialsError; end
372
+
373
+ # 403 — the key/project/computer is validly identified but may not mint attach
374
+ # credentials now (scope, project disabled, key revoked, or computer disabled).
375
+ class ProjectAccessDenied < AttachCredentialsError; end
376
+
377
+ # 412 — another attach advanced the authorization revision. Terminal:
378
+ # reload/adopt the winning integrator database binding; do not start the Computer again.
379
+ class BindingRevisionConflictError < AttachCredentialsError
380
+ attr_reader :current_binding_revision, :current_sandbox_id
381
+
382
+ def initialize(message, current_binding_revision: nil, current_sandbox_id: nil, **kwargs)
383
+ @current_binding_revision = current_binding_revision
384
+ @current_sandbox_id = current_sandbox_id
385
+ super(message, **kwargs)
386
+ end
387
+
388
+ # The winning binding's revision (also #current_binding_revision).
389
+ def current_revision = current_binding_revision
390
+ end
391
+
392
+ # Portal committed a new authorization revision, but the later bind
393
+ # failed. The SDK has shut the Computer down best-effort. Persist the
394
+ # revision before retrying so the next attach can supersede this attempt.
395
+ # #credentials is present for create_computer, whose generated identity and
396
+ # capture keypair would otherwise be lost because the method has no result.
397
+ class AttachAuthorizationCommittedError < BindError
398
+ attr_reader :binding_revision, :sandbox_id, :credentials
399
+
400
+ def initialize(message, binding_revision:, sandbox_id: nil, credentials: nil, **kwargs)
401
+ @binding_revision = binding_revision
402
+ @sandbox_id = sandbox_id
403
+ @credentials = credentials
404
+ super(message, **kwargs)
405
+ end
406
+ end
407
+
408
+ # The Computer answered with an `X-Computer-Spec-Version` major this SDK
409
+ # doesn't support. Fail fast — the SDK pins a major.
410
+ class SpecVersionMismatch < Error
411
+ attr_reader :supported, :served
412
+
413
+ def initialize(message, supported: nil, served: nil)
414
+ @supported = supported
415
+ @served = served
416
+ super(message)
417
+ end
418
+ end
419
+
420
+ # The Computer rejected the portal-issued bind_token: bad signature, unknown
421
+ # kid, iss/aud mismatch, expired, replayed jti, or issued for another start.
422
+ # Terminal — retrying with the same token cannot succeed.
423
+ class BindAuthError < BindError; end
424
+
425
+ # This Computer is already attached elsewhere: another attach holds its epoch
426
+ # lease, or you are attaching from two places at once.
427
+ class ComputerAlreadyAttachedError < BindError; end
428
+
429
+ # This Computer sandbox can't be attached (it was already attached, or a prior attach
430
+ # failed on it). The SDK shuts it down before raising; retry attach to start
431
+ # the Computer again.
432
+ class SandboxUnusableError < BindError; end
433
+ # Deprecated: use SandboxUnusableError.
434
+ PodPoisonedError = SandboxUnusableError
435
+ deprecate_constant :PodPoisonedError
436
+
437
+ # Saved state was unreachable during the bind; the SDK has shut the Computer
438
+ # down. Retry attach later (transient).
439
+ class SavedStateUnavailableError < BindError; end
440
+ # Deprecated: use SavedStateUnavailableError.
441
+ BrokerUnreachableError = SavedStateUnavailableError
442
+ deprecate_constant :BrokerUnreachableError
443
+
444
+ # The bind handshake exhausted its retries without a terminal
445
+ # classification. Usually a slow start or a network issue.
446
+ class BindTimeoutError < BindError; end
447
+
448
+ # The Computer returned 401 for its Computer token (ct_) — a REPORT that
449
+ # it did not recognize the binding's token, NOT an instruction to
450
+ # re-attach. ct_ is binding-lifetime (no idle expiry), so on a running
451
+ # Computer this state is binding_auth_lost: surface it and reconcile; only
452
+ # a Computer confirmed stopped justifies an attach (a new start invalidates
453
+ # every ps_ and fences the previous start's state).
454
+ # A 401 for a session token (ps_) is an ordinary UnauthorizedError.
455
+ class TokenRejectedError < BindError
456
+ MESSAGE = "the Computer rejected the computer token (401). ct_ is binding-lifetime, so on a " \
457
+ "running Computer this is binding_auth_lost — reconcile; re-attach ONLY once the " \
458
+ "Computer is confirmed stopped (a new start invalidates every ps_)".freeze
459
+
460
+ # The rejection of `api_error` (a 401 answer to a Computer token), with
461
+ # its operation and request id folded into the message.
462
+ def self.for_api_error(api_error)
463
+ new(PineSandbox.with_context(MESSAGE, op: api_error.op, request_id: api_error.request_id),
464
+ status: 401, cause: api_error)
465
+ end
466
+ end
467
+
468
+ # The gateway can no longer route to this Computer's host, which is also
469
+ # true while it's paused or recovering. This is distinct from an /errors/session-not-found response on a live
470
+ # Computer. Inherit the legacy 404 tier so existing NotFoundError rescues
471
+ # remain valid; the original typed ApiError is retained as Exception#cause.
472
+ class SandboxGoneError < NotFoundError
473
+ def initialize(api_error)
474
+ detail = api_error.error_message.to_s
475
+ message = "the Computer can't be reached at its host"
476
+ message = "#{message}: #{detail}" unless detail.empty?
477
+ super(
478
+ status: api_error.status,
479
+ error_code: api_error.error_code,
480
+ error_message: message,
481
+ response_body: api_error.response_body,
482
+ problem_type: api_error.problem_type,
483
+ problem_extensions: api_error.problem_extensions.dup
484
+ )
485
+ self.retry_after = api_error.retry_after
486
+ end
487
+ end
488
+
489
+ # 429 COMPUTER_CAPACITY_EXCEEDED on create/attach: the project is at its
490
+ # concurrent Computer limit, so nothing was provisioned. Not retried by the
491
+ # SDK — capacity frees only when another of the project's Computers stops.
492
+ # An ApiError, so existing generic rescues keep catching it.
493
+ class CapacityExceededError < ApiError
494
+ CODE = "COMPUTER_CAPACITY_EXCEEDED".freeze
495
+ end
496
+
497
+ # 429 COMPUTER_CREATE_RATE_LIMITED on create: the project has started as many
498
+ # new Computers in the last minute as it may, so nothing was provisioned.
499
+ # #retry_after is the server's Retry-After in seconds (nil when absent). Not
500
+ # retried by the SDK; the caller decides when to create again. An ApiError,
501
+ # so existing generic rescues keep catching it.
502
+ class CreateRateLimitedError < ApiError
503
+ CODE = "COMPUTER_CREATE_RATE_LIMITED".freeze
504
+ end
505
+ end
@@ -0,0 +1,90 @@
1
+ require "json"
2
+ require_relative "sse_frame_parser"
3
+
4
+ module PineSandbox
5
+ # Decodes a command's output stream (Session#exec) into Event structs. The
6
+ # stream is framed by the shared SseFrameParser: each frame's `data:` is
7
+ # one JSON event whose `type` names it. A frame without data, or whose data
8
+ # is not a JSON object, is skipped; a block cut off at the end of the stream
9
+ # is discarded.
10
+ class ExecStreamParser
11
+ Event = Struct.new(
12
+ :type,
13
+ :id,
14
+ :data,
15
+ :text,
16
+ :execution_count,
17
+ :execution_time,
18
+ :timestamp,
19
+ :results,
20
+ :error,
21
+ :raw,
22
+ keyword_init: true
23
+ )
24
+
25
+ SUPPORTED_EVENT_TYPES = %i[
26
+ init
27
+ stdout
28
+ stderr
29
+ execution_complete
30
+ error
31
+ result
32
+ ping
33
+ ].freeze
34
+
35
+ def initialize(&block)
36
+ @frames = SseFrameParser.new
37
+ @callback = block
38
+ end
39
+
40
+ def parse(chunk, &block)
41
+ callback = block || @callback
42
+ raise ArgumentError, "a block is required to receive exec events" unless callback
43
+
44
+ @frames.feed(chunk) { |frame| dispatch(frame, callback) }
45
+ end
46
+
47
+ # The end of the stream: a trailing block without its blank line is dropped.
48
+ def finish(&_block)
49
+ @frames.finish
50
+ end
51
+
52
+ private
53
+
54
+ def dispatch(frame, callback)
55
+ return if frame.data.empty?
56
+
57
+ parsed = begin
58
+ JSON.parse(frame.data)
59
+ rescue JSON::ParserError
60
+ nil
61
+ end
62
+ return unless parsed.is_a?(Hash)
63
+
64
+ callback.call(
65
+ Event.new(
66
+ type: extract_type(parsed, frame.event == "message" ? nil : frame.event),
67
+ id: frame.id,
68
+ data: parsed,
69
+ text: parsed["text"],
70
+ execution_count: extract_value(parsed, "execution_count", "executionCount"),
71
+ execution_time: extract_value(parsed, "execution_time", "executionTime"),
72
+ timestamp: parsed["timestamp"],
73
+ results: parsed["results"],
74
+ error: parsed["error"],
75
+ raw: frame.data
76
+ )
77
+ )
78
+ end
79
+
80
+ def extract_type(parsed, fallback)
81
+ normalized = (parsed["type"] || fallback).to_s.tr("-", "_")
82
+ normalized.empty? ? nil : normalized.downcase.to_sym
83
+ end
84
+
85
+ def extract_value(parsed, *keys)
86
+ keys.each { |key| return parsed[key] if parsed.key?(key) }
87
+ nil
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,51 @@
1
+ require "time"
2
+
3
+ module PineSandbox
4
+ module Models
5
+ # A session artifact's metadata — a managed session output (a client upload,
6
+ # a browser download, or an agent task output) with a download
7
+ # endpoint. Returned by {PineSandbox::Session#list_artifacts} and
8
+ # {PineSandbox::Session#upload_artifact}. Mirrors the spec `Artifact` schema.
9
+ Artifact = Struct.new(
10
+ :id,
11
+ :session_id,
12
+ :turn_id,
13
+ :root,
14
+ :relative_path,
15
+ :filename,
16
+ :content_type,
17
+ :size,
18
+ :sha256,
19
+ :created_by,
20
+ :created_at,
21
+ :retention,
22
+ keyword_init: true
23
+ ) do
24
+ def self.from_payload(payload)
25
+ # filename is the human name WITH extension (basename of the id-prefixed
26
+ # relative_path) — display/download by this, not `id`. Derive it if an
27
+ # older Computer omits it.
28
+ relative_path = payload["relative_path"]
29
+ new(
30
+ id: payload["id"],
31
+ session_id: payload["session_id"],
32
+ turn_id: payload["turn_id"],
33
+ root: payload["root"],
34
+ relative_path: relative_path,
35
+ filename: payload["filename"] || (relative_path && !relative_path.empty? ? File.basename(relative_path) : nil),
36
+ content_type: payload["content_type"],
37
+ size: payload["size"],
38
+ sha256: payload["sha256"],
39
+ created_by: payload["created_by"],
40
+ created_at: payload["created_at"] && Time.parse(payload["created_at"]),
41
+ retention: payload["retention"]
42
+ )
43
+ end
44
+
45
+ # True for an agent-produced (task output) artifact.
46
+ def agent?
47
+ created_by == "agent"
48
+ end
49
+ end
50
+ end
51
+ end