pinesandbox 0.3.19 → 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
@@ -18,6 +19,8 @@ module PineSandbox
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
25
  # This source caches nothing. Computer#run_bind calls it exactly once for each
23
26
  # start, then convergence retries re-send the held envelope verbatim
@@ -97,6 +100,30 @@ module PineSandbox
97
100
  payload
98
101
  end
99
102
 
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
+
100
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")
@@ -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)
@@ -208,12 +235,14 @@ 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"],
245
+ current_binding_revision: revision,
217
246
  current_sandbox_id: problem["current_sandbox_id"],
218
247
  **error_attributes(resp)
219
248
  )
@@ -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/)
@@ -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 =
@@ -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 =
@@ -8,6 +8,7 @@ require_relative "bind_hpke"
8
8
  require_relative "capture_keypair"
9
9
  require_relative "adapters/coordinator_adapter"
10
10
  require_relative "adapters/snapshot_adapter"
11
+ require_relative "artifacts"
11
12
  require_relative "session"
12
13
 
13
14
  module PineSandbox
@@ -21,7 +22,7 @@ module PineSandbox
21
22
  #
22
23
  # computer = PineSandbox::Computer.new("0190…", key: secret_32_bytes)
23
24
  # computer.attach(connection)
24
- # session = computer.create_session(browser: true)
25
+ # session = computer.create_session
25
26
  # session.create_tab(url: "https://example.com")
26
27
  # computer.stop
27
28
  #
@@ -386,25 +387,36 @@ module PineSandbox
386
387
  # friendly adjective-noun-hex name (e.g. "brave-otter-9f3a1c") — read it back
387
388
  # off the returned {Session}. `label` is an optional human-meaningful tag,
388
389
  # distinct from the addressing name. A duplicate name raises
389
- # PineSandbox::ConflictError. The SDK default is `browser: false` (shell
390
- # only), unlike the raw API's; pass `browser: true` for a browser.
391
- def create_session(name: nil, label: nil, browser: false, blind: false)
390
+ # PineSandbox::ConflictError. Every session has its own browser window.
391
+ #
392
+ # `artifact_access` is what the session's agent may do with the Computer's
393
+ # artifacts (see Client#artifacts): "none" (the default: nothing, and the
394
+ # agent isn't told about them), "read_only" (list and read) or
395
+ # "read_write" (also save and delete files and make public links). A
396
+ # Symbol works too. Your own access with the project key is the same
397
+ # either way.
398
+ def create_session(name: nil, label: nil, artifact_access: nil)
399
+ unless artifact_access.nil? || ((artifact_access.is_a?(String) || artifact_access.is_a?(Symbol)) &&
400
+ Session::ARTIFACT_ACCESS.include?(artifact_access.to_s))
401
+ raise ArgumentError, "artifact_access must be one of #{Session::ARTIFACT_ACCESS.join(', ')}"
402
+ end
403
+
404
+ options = { name: name, label: label }
405
+ options[:artifact_access] = artifact_access.to_s unless artifact_access.nil?
392
406
  rebind_on_unauthorized do
393
- payload = coordinator!.create_session(
394
- token: computer_token, name: name, label: label, browser: browser, blind: blind
395
- )
407
+ payload = coordinator!.create_session(token: computer_token, **options)
396
408
  Session.new(parent: self, payload: payload)
397
409
  end
398
410
  end
399
411
 
400
412
  # Desktop delegation in one call:
401
413
  # computer.delegate_desktop(name: session.name)
402
- # Ensures the named (browser) desktop session exists, then returns its
414
+ # Ensures the named desktop session exists, then returns its
403
415
  # browser-safe {DelegatedConnection} (host + session name + spec major +
404
416
  # a fresh short-TTL `dt_`; no `ct_`/`ps_`/JWS). `create: true` (default) creates
405
417
  # the session if absent; `create: false` requires it. To refresh the `dt_`
406
418
  # before a reconnect, call `session(name).delegate` again (server-side).
407
- def delegate_desktop(name:, create: true, browser: true)
419
+ def delegate_desktop(name:, create: true)
408
420
  require_session_name!(name)
409
421
  s =
410
422
  begin
@@ -412,7 +424,7 @@ module PineSandbox
412
424
  rescue PineSandbox::NotFoundError
413
425
  raise unless create
414
426
 
415
- create_session(name: name, browser: browser)
427
+ create_session(name: name)
416
428
  end
417
429
  s.delegate
418
430
  end
@@ -426,14 +438,15 @@ module PineSandbox
426
438
  # Rebuilds a session from the name and token saved when it was created,
427
439
  # under this Computer's current binding.
428
440
  # No request, session creation or token refresh; authorization is checked
429
- # on first use. Metadata remains available through #session(name).
441
+ # on first use. Metadata remains available through #session(name); the
442
+ # rebuilt session's #artifact_access is nil, since only the Computer knows it.
430
443
  def adopt_session(name, session_token)
431
444
  coordinator!
432
445
  unless name.is_a?(String) && !name.empty? && session_token.is_a?(String) && !session_token.empty?
433
446
  raise ArgumentError, "session name and token are required"
434
447
  end
435
448
 
436
- Session.new(parent: self, payload: { "name" => name, "token" => session_token })
449
+ Session.new(parent: self, payload: { "name" => name, "token" => session_token }, artifact_access_known: false)
437
450
  end
438
451
 
439
452
  # computer.skills.* — the Computer's learned-skill lifecycle (Computer Skills): list/get the
@@ -443,6 +456,23 @@ module PineSandbox
443
456
  @skills ||= Skills.new(self)
444
457
  end
445
458
 
459
+ # A public link to one of the Computer's artifacts (see Client#artifacts),
460
+ # served by this running Computer: anyone with the URL can download the
461
+ # file as it is when they open it. The link stops working after
462
+ # `expires_in_seconds` (60 to 2,592,000) if given, when the file is
463
+ # deleted, and always when the Computer stops. Returns the response Hash,
464
+ # { "url" => …, "expires_at" => … }; "expires_at" only with an expiry.
465
+ def artifact_link(path, expires_in_seconds: nil)
466
+ path = ArtifactPath.check!(path)
467
+ unless expires_in_seconds.nil? || (expires_in_seconds.is_a?(Integer) && expires_in_seconds.between?(60, 2_592_000))
468
+ raise ArgumentError, "expires_in_seconds must be an integer from 60 to 2592000"
469
+ end
470
+
471
+ rebind_on_unauthorized do
472
+ coordinator!.create_artifact_link(token: computer_token, path: path, expires_in_seconds: expires_in_seconds)
473
+ end
474
+ end
475
+
446
476
  # capabilities — the product features this Computer can use for its binding, and the limits
447
477
  # it enforces on your calls.
448
478
  def capabilities
@@ -38,6 +38,8 @@ module PineSandbox
38
38
  "/errors/no-desktop-stream" => false,
39
39
  "/errors/desktop-unavailable" => false,
40
40
  "/errors/skills-unavailable" => false,
41
+ "/errors/artifacts-unavailable" => true,
42
+ "/errors/artifacts-not-configured" => false,
41
43
  "/errors/secret-in-skill" => false,
42
44
  "/errors/skill-author-failed" => true,
43
45
  "/errors/author-in-flight" => true,
@@ -191,6 +191,18 @@ module PineSandbox
191
191
  class HandoffExpired < GoneError; end
192
192
  class HandoffPersistenceFailed < ServerError; end
193
193
 
194
+ # A Computer's artifacts have no file at #path (the path you passed,
195
+ # relative to the Computer's artifacts). A NotFoundError, so tier rescues
196
+ # keep catching it.
197
+ class ArtifactNotFoundError < NotFoundError
198
+ attr_reader :path
199
+
200
+ def initialize(path:, **fields)
201
+ @path = path
202
+ super(**fields)
203
+ end
204
+ end
205
+
194
206
  # Problem `type` → its typed error; anything else falls back to the status tier.
195
207
  PROBLEM_TYPE_CLASSES = {
196
208
  "/errors/control-not-held" => ControlNotHeld,
@@ -240,6 +252,21 @@ module PineSandbox
240
252
  end
241
253
  end
242
254
 
255
+ # The Computer's artifacts take up more than its storage limit, so an
256
+ # upload or a copy into them is refused before anything is sent. Delete
257
+ # files to make room. #used_bytes and #limit_bytes are as Pine counted
258
+ # them when it issued the storage token.
259
+ class ArtifactStorageFullError < Error
260
+ attr_reader :used_bytes, :limit_bytes
261
+
262
+ def initialize(used_bytes:, limit_bytes:)
263
+ @used_bytes = used_bytes
264
+ @limit_bytes = limit_bytes
265
+ super("the Computer's artifacts use #{used_bytes} bytes, more than their limit of #{limit_bytes}; " \
266
+ "delete files before writing more")
267
+ end
268
+ end
269
+
243
270
  class StreamError < Error; end
244
271
  class StreamingUnsupportedError < Error; end
245
272
 
@@ -0,0 +1,40 @@
1
+ require "time"
2
+
3
+ module PineSandbox
4
+ module Models
5
+ # One file in a Computer's artifacts. Returned by
6
+ # {PineSandbox::Artifacts#list}, #upload and #copy. `path` is relative to
7
+ # the Computer's artifacts ("research/q3.csv"); `note` is the note the file
8
+ # was uploaded with, if any.
9
+ ArtifactFile = Struct.new(
10
+ :path,
11
+ :size,
12
+ :updated_at,
13
+ :content_type,
14
+ :note,
15
+ keyword_init: true
16
+ ) do
17
+ # From a storage object, whose name is `prefix` followed by the path.
18
+ def self.from_object(object, prefix:)
19
+ new(
20
+ path: object["name"].to_s.delete_prefix(prefix),
21
+ # Storage sends the size as a decimal string.
22
+ size: object["size"].nil? ? nil : Integer(object["size"].to_s, 10),
23
+ updated_at: object["updated"] && Time.parse(object["updated"]),
24
+ content_type: object["contentType"],
25
+ note: object.dig("metadata", "pine-note")
26
+ )
27
+ end
28
+ end
29
+
30
+ # One page of {PineSandbox::Artifacts#list}: the files, the folders
31
+ # directly below the listed prefix (each ending in "/"; none when listing
32
+ # recursively), and the token for the next page (nil on the last one).
33
+ ArtifactPage = Struct.new(
34
+ :files,
35
+ :folders,
36
+ :next_page_token,
37
+ keyword_init: true
38
+ )
39
+ end
40
+ end
@@ -12,12 +12,15 @@ module PineSandbox
12
12
  :token,
13
13
  :session_dir,
14
14
  :browser,
15
- :blind,
15
+ :artifact_access,
16
16
  :created_at,
17
17
  :last_active_at,
18
18
  keyword_init: true
19
19
  ) do
20
- def self.from_payload(payload)
20
+ # `artifact_access_known` is false for a session whose artifact access
21
+ # only the Computer knows (Computer#adopt_session): #artifact_access is
22
+ # then nil.
23
+ def self.from_payload(payload, artifact_access_known: true)
21
24
  return nil unless payload
22
25
  browser_payload = payload["browser"]
23
26
  new(
@@ -26,7 +29,8 @@ module PineSandbox
26
29
  token: payload["token"],
27
30
  session_dir: payload["session_dir"],
28
31
  browser: browser_payload && Browser.from_payload(browser_payload),
29
- blind: payload.fetch("blind", false),
32
+ # An older Computer doesn't report it; its sessions have no access.
33
+ artifact_access: artifact_access_known ? payload.fetch("artifact_access", "none") : nil,
30
34
  created_at: payload["created_at"] && Time.parse(payload["created_at"]),
31
35
  last_active_at: payload["last_active_at"] && Time.parse(payload["last_active_at"])
32
36
  )
@@ -35,7 +35,9 @@ module PineSandbox
35
35
  ["cancelAuthorRun", "POST", "/v2/sessions/{name}/skills/author/{author_id}/cancel", :computer, false, false],
36
36
  ["captureCheckpoint", "POST", "/v1/coord/capture", :computer, false, false],
37
37
  ["computerUse", "POST", "/v1/sessions/{name}/computer-use", :computer, false, false],
38
+ ["createArtifactLink", "POST", "/v1/artifacts/links", :computer, false, false],
38
39
  ["createComputer", "POST", "/computer-sandboxes", :control, true, false],
40
+ ["createComputerArtifactToken", "POST", "/v1/computers/{computer_id}/artifacts/token", :control, false, false],
39
41
  ["createDiagnostic", "POST", "/v1/diagnostics", :control, true, true],
40
42
  ["createSession", "POST", "/sessions", :computer, false, false],
41
43
  ["deactivateSkill", "POST", "/v1/skills/{name}/deactivate", :computer, false, false],
@@ -103,7 +103,8 @@ module PineSandbox
103
103
  def release_failed_sandbox(info, wait_timeout: 90, interval: DEFAULT_POLL_INTERVAL)
104
104
  deadline = @clock.call + wait_timeout
105
105
  unless info.status == :stopping
106
- unless %i[save_failed unavailable failed terminated].include?(info.status) && info.reason != "CaptureTimeout"
106
+ # Accepts CAPTURE_TIMEOUT as well as CaptureTimeout to unify on SNAKE_CASE while staying backward compatible.
107
+ unless %i[save_failed unavailable failed terminated].include?(info.status) && !%w[CAPTURE_TIMEOUT CaptureTimeout].include?(info.reason)
107
108
  raise PineSandbox::ConflictError.new(status: 409, error_message: "The Computer has no recorded failure; use Stop or Resume")
108
109
  end
109
110
  raise PineSandbox::Error, "Server did not supply a conditional release version" if info.etag.to_s.empty?
@@ -19,11 +19,17 @@ module PineSandbox
19
19
  # tabs, focus, epoch, drive actions, passkey intent and decisions) need the
20
20
  # session token.
21
21
  class Session
22
+ # What a session's agent may do with the Computer's artifacts
23
+ # (Computer#create_session `artifact_access:`).
24
+ ARTIFACT_ACCESS = %w[none read_only read_write].freeze
25
+
22
26
  attr_reader :info, :parent
23
27
 
24
- def initialize(parent:, payload:)
28
+ # `artifact_access_known` is false when only the Computer knows the
29
+ # session's artifact access (Computer#adopt_session).
30
+ def initialize(parent:, payload:, artifact_access_known: true)
25
31
  @parent = parent
26
- @info = Models::Session.from_payload(payload)
32
+ @info = Models::Session.from_payload(payload, artifact_access_known: artifact_access_known)
27
33
  raise PineSandbox::ApiError.new(status: 0, error_message: "empty session payload") if @info.nil?
28
34
  end
29
35
 
@@ -52,6 +58,15 @@ module PineSandbox
52
58
  info.session_dir
53
59
  end
54
60
 
61
+ # What this session's agent may do with the Computer's artifacts, as set
62
+ # at create: "none", "read_only" or "read_write". "none" when the Computer
63
+ # doesn't say (an older one). nil for a session from
64
+ # Computer#adopt_session, whose access isn't known: read it with
65
+ # Computer#session(name).
66
+ def artifact_access
67
+ info.artifact_access
68
+ end
69
+
55
70
  # session.agent.* — hand-off-the-loop driving (run/steer/answer/cancel/reset/
56
71
  # status/result/events). The agent drives; you delegate goals. See {AgentMode}.
57
72
  def agent
@@ -499,7 +514,7 @@ module PineSandbox
499
514
  # reach the browser. Driving needs control of the session, not this
500
515
  # token, so the same token serves a viewer and a driver. Re-mint before
501
516
  # each (re)connect. Returns { token:, expires_at: }. 409
502
- # (NoDesktopStream) when the session is blind; 503
517
+ # (NoDesktopStream) for a session an older SDK created as blind; 503
503
518
  # (DesktopUnavailable) when streaming is off.
504
519
  def desktop_token
505
520
  ct_call { coordinator.mint_desktop_token(token: parent.computer_token, name: name) }
@@ -0,0 +1,80 @@
1
+ require "tempfile"
2
+
3
+ module PineSandbox
4
+ # @api private — what the SDK's streamed reads of a body share: a file read
5
+ # from a Computer (Adapters::CoordinatorAdapter) and an artifact read from
6
+ # storage (Artifacts). Only a 2xx body reaches the caller, chunk by chunk as
7
+ # it arrives; an error answer's body is kept, bounded, for its error.
8
+ module StreamedRead
9
+ # A failed answer keeps this much of its body for the error.
10
+ ERROR_BODY_CAP = 64 * 1024
11
+ # Chunk size for passing on a body that was spooled to a temp file.
12
+ SPOOL_READ_SIZE = 64 * 1024
13
+
14
+ # Raised through Faraday's read loop in place of an exception from the
15
+ # caller's block, so the block's own error is never mistaken for a
16
+ # dropped connection. The streaming method raises #error again as it was.
17
+ class BlockError < StandardError
18
+ attr_reader :error
19
+
20
+ def initialize(error)
21
+ @error = error
22
+ super(error.message)
23
+ end
24
+ end
25
+
26
+ module_function
27
+
28
+ # Calls the caller's block with `args` from inside Faraday's read loop,
29
+ # which would turn an IOError or EPIPE from the block into a dropped
30
+ # connection; the block's own error comes out as a BlockError.
31
+ def pass(block, *args)
32
+ block.call(*args)
33
+ rescue StandardError => e
34
+ raise BlockError.new(e)
35
+ end
36
+
37
+ # Adds as much of `chunk` to `buffer` as keeps it within ERROR_BODY_CAP.
38
+ def keep_error_body(buffer, chunk)
39
+ remaining = ERROR_BODY_CAP - buffer.bytesize
40
+ buffer << chunk.byteslice(0, remaining) if remaining.positive?
41
+ end
42
+
43
+ # Reads one streamed GET. Yields the proc to set as the request's
44
+ # on_data; the block sends the request and returns its response. Each
45
+ # chunk of a 2xx body goes to `deliver` with its answer (the Faraday env,
46
+ # or the response) as it arrives. faraday-net_http before 3.0 passes no
47
+ # env and learns the status only after the body, so its chunks go to a
48
+ # temp file, not memory, and on to `deliver` once the status is known. An
49
+ # error answer's body, kept up to ERROR_BODY_CAP, becomes the response's
50
+ # body. Returns the response.
51
+ def read(deliver)
52
+ error_body = +""
53
+ spool = nil
54
+ on_data = proc do |chunk, _received, env|
55
+ if env.nil?
56
+ (spool ||= Tempfile.new("pinesandbox-read", binmode: true)).write(chunk)
57
+ elsif env.status.to_i.between?(200, 299)
58
+ deliver.call(chunk.b, env) unless chunk.empty?
59
+ else
60
+ keep_error_body(error_body, chunk)
61
+ end
62
+ end
63
+ response = yield on_data
64
+ if spool
65
+ spool.rewind
66
+ if response.success?
67
+ while (chunk = spool.read(SPOOL_READ_SIZE))
68
+ deliver.call(chunk, response)
69
+ end
70
+ else
71
+ keep_error_body(error_body, spool.read(ERROR_BODY_CAP).to_s)
72
+ end
73
+ end
74
+ response.env.body = error_body if !response.success? && response.body.to_s.empty?
75
+ response
76
+ ensure
77
+ spool&.close!
78
+ end
79
+ end
80
+ end
@@ -1,3 +1,5 @@
1
1
  module PineSandbox
2
- VERSION = "0.3.19"
2
+ VERSION = "0.3.20"
3
+ # What the SDK's requests send as User-Agent.
4
+ USER_AGENT = "pinesandbox-ruby/#{VERSION}".freeze
3
5
  end
data/lib/pinesandbox.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "pinesandbox/models/computer_session"
8
8
  require_relative "pinesandbox/models/computer_tab"
9
9
  require_relative "pinesandbox/models/file_entry"
10
10
  require_relative "pinesandbox/models/artifact"
11
+ require_relative "pinesandbox/models/artifact_file"
11
12
  require_relative "pinesandbox/sse_frame_parser"
12
13
  require_relative "pinesandbox/control_events"
13
14
  require_relative "pinesandbox/agent_events"
@@ -20,6 +21,7 @@ require_relative "pinesandbox/capture_keypair"
20
21
  require_relative "pinesandbox/adapters/coordinator_adapter"
21
22
  require_relative "pinesandbox/adapters/snapshot_adapter"
22
23
  require_relative "pinesandbox/skills"
24
+ require_relative "pinesandbox/artifacts"
23
25
  require_relative "pinesandbox/author_terminal_translator"
24
26
  require_relative "pinesandbox/session"
25
27
  require_relative "pinesandbox/control_token_source"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pinesandbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.19
4
+ version: 0.3.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pine AI
@@ -126,6 +126,7 @@ files:
126
126
  - lib/pinesandbox/agent_events.rb
127
127
  - lib/pinesandbox/agent_mode.rb
128
128
  - lib/pinesandbox/agent_turn.rb
129
+ - lib/pinesandbox/artifacts.rb
129
130
  - lib/pinesandbox/attach_credentials_source.rb
130
131
  - lib/pinesandbox/author_terminal_translator.rb
131
132
  - lib/pinesandbox/bind_hpke.rb
@@ -147,6 +148,7 @@ files:
147
148
  - lib/pinesandbox/errors.rb
148
149
  - lib/pinesandbox/exec_stream_parser.rb
149
150
  - lib/pinesandbox/models/artifact.rb
151
+ - lib/pinesandbox/models/artifact_file.rb
150
152
  - lib/pinesandbox/models/computer_session.rb
151
153
  - lib/pinesandbox/models/computer_tab.rb
152
154
  - lib/pinesandbox/models/file_entry.rb
@@ -159,6 +161,7 @@ files:
159
161
  - lib/pinesandbox/spec_version_middleware.rb
160
162
  - lib/pinesandbox/sse_frame_parser.rb
161
163
  - lib/pinesandbox/state_hpke.rb
164
+ - lib/pinesandbox/streamed_read.rb
162
165
  - lib/pinesandbox/version.rb
163
166
  - lib/pinesandbox/zone.rb
164
167
  - pinesandbox.gemspec