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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42cf362beb935ed10df113f8dae0e006d141ab3849f3203630fa0aad880a4616
4
- data.tar.gz: '09865bf5bd34bfb3dcd6b3832ff8dcd69e992e2fa9b485215525b154567b9528'
3
+ metadata.gz: 1a5f2719e33f58ee6f18cfb781c42c983341981c5dc3c0469a71bfcdebe89b27
4
+ data.tar.gz: 3ef413fba3327ecfca7069f39f96241f622ccdccb795fec799b036b2f4931fa4
5
5
  SHA512:
6
- metadata.gz: 5b62511ef3c2399f26d15d0372d9707534e06df49f615410b08f68493ccd290bf819d497b294aa49004b70741a4f2e607506acab686cb2cf4a429eb61c75dffe
7
- data.tar.gz: b12ea12e14b46c4ebb078d690e2df1045da8e846548a06518806c0ea8ee5ce0b7d3e9d509662095781e27adb341b9591927f2cd9cb5846684c0db0cda56aeac0
6
+ metadata.gz: 715dfedf0fa03c0ed77ecf2fd5661f7ad060e1dc2d4a648499041290c0e060f98a9fad2d5fe63e3aec387f5134aeadca99558bde55be1482d06a4eb8c97caf64
7
+ data.tar.gz: 4356c0f100f12b2686517d54a656e4df7ee66a075476a9b2a80e37cd648ec5ddc2e2e1ba512eaee586a0653cc2291d0c77579d7b64f02cc93ea234f98ec21531
data/README.md CHANGED
@@ -47,7 +47,7 @@ store.save_credentials(credentials.id, credentials.to_json)
47
47
  computer = client.create_computer(credentials)
48
48
  store.save_binding(computer.id, computer.binding.to_json)
49
49
 
50
- session = computer.create_session(browser: true) # Pine picks the name: save session.name
50
+ session = computer.create_session # Pine picks the name: save session.name
51
51
  session.create_tab(url: "https://example.com")
52
52
  ```
53
53
 
@@ -227,17 +227,36 @@ For integrations using older storage or field-based methods:
227
227
 
228
228
  ## Sessions
229
229
 
230
- A session is the unit of work — a browser context + bash terminal. Create one, then
231
- drive it (next two sections). A Computer runs up to 3 sessions at once by default;
230
+ A session is the unit of work: its own browser window and bash terminal. Create one,
231
+ then drive it (next two sections). A Computer runs up to 3 sessions at once by default;
232
232
  `computer.capabilities` reports the limit (`sessions.concurrent`).
233
233
 
234
234
  ```ruby
235
- session = computer.create_session(name: "researcher-1", browser: true)
235
+ session = computer.create_session(name: "researcher-1")
236
236
  computer.session("researcher-1") # metadata by name; token is redacted
237
237
  computer.sessions # list all on this Computer
238
238
  computer.destroy_session("researcher-1") # tear down (keeps on-disk dir; clean: true to GC)
239
239
  ```
240
240
 
241
+ A session's agent can use the Computer's [artifacts](#artifacts) only when you create
242
+ the session with `artifact_access: "read_only"` (list and read files) or
243
+ `artifact_access: "read_write"` (also save and delete files and make public links).
244
+ The default is `"none"`. `session.artifact_access` reports the access it was created
245
+ with, and is `nil` for a session from `computer.adopt_session`, whose access only the
246
+ Computer knows (`computer.session(name).artifact_access` reads it). Your own access
247
+ with the project key, through `client.artifacts`, is the same either way.
248
+
249
+ ```ruby
250
+ session = computer.create_session(name: "analyst", artifact_access: "read_only")
251
+ session.artifact_access # => "read_only"
252
+ ```
253
+
254
+ A person who must type a password or an MFA code takes control of the session
255
+ (`session.take_control`, then `release_control`) and types it over the desktop while
256
+ the agent waits. A secret your application already holds goes in the task instead
257
+ (`session.agent.run(goal, secrets: {...})`): the agent reads it from
258
+ `$SESSION_SECRETS`.
259
+
241
260
  ## Drive — Pine's agent (hand off the loop)
242
261
 
243
262
  For an attached Computer, `computer.require_agent_protocol!` checks public agent
@@ -427,6 +446,46 @@ Without a block, `open_file` returns an Enumerator of the chunks. Session files
427
446
  until the Computer stops; save what you need first. Routes: `/files` in the
428
447
  [Computer API reference](https://console.pinecomputer.io/spec/computer).
429
448
 
449
+ ## Artifacts
450
+
451
+ A Computer's **artifacts** are the files it keeps beyond its sessions. Reach them
452
+ with the project key, whether the Computer is running or stopped. A session's agent
453
+ can use them only when the session was created with `artifact_access: "read_only"`
454
+ or `"read_write"` (see [Sessions](#sessions)); that choice doesn't change your own
455
+ access here.
456
+
457
+ ```ruby
458
+ artifacts = client.artifacts(computer_id)
459
+
460
+ page = artifacts.list(prefix: "research/") # one page: files, folders, next_page_token
461
+ page.files.each { |file| puts "#{file.path} #{file.size} #{file.note}" }
462
+
463
+ artifacts.upload("inputs/brief.pdf", Pathname("brief.pdf"), note: "the brief")
464
+ artifacts.upload("inputs/notes.txt", "plain bytes")
465
+ artifacts.download("reports/q3.pdf", "q3.pdf") # => bytes written
466
+ artifacts.open("reports/q3.csv") { |chunk| io.write(chunk) }
467
+ artifacts.copy(other_computer_id, "templates/form.pdf", "inputs/form.pdf")
468
+ artifacts.delete("inputs/notes.txt")
469
+
470
+ # A public link, served by the running Computer until it stops:
471
+ computer.artifact_link("reports/q3.pdf", expires_in_seconds: 86_400) # => { "url" => ..., "expires_at" => ... }
472
+ ```
473
+
474
+ The SDK gets one short-lived storage token from Pine and sends the files straight to
475
+ and from storage, never through Pine; it renews the token before it lapses. An upload
476
+ takes a String of bytes, a `Pathname` or an IO with a known size, and resumes after a
477
+ dropped connection; without `content_type:` it is typed by its path's extension, the
478
+ same way in every Pine SDK. A download writes beside the destination and renames into
479
+ place. `download` and `open` resume after a dropped connection, a request timeout, a
480
+ 429 or a 5xx other than 501, from where they stopped and from the same version of the
481
+ file (one replaced meanwhile raises `PineSandbox::PreconditionFailedError`). `copy` also copies within the
482
+ Computer, given its own id. A missing file raises `PineSandbox::ArtifactNotFoundError`
483
+ (`#path`); while the Computer's artifacts are over their limit, `upload` and `copy`
484
+ raise `PineSandbox::ArtifactStorageFullError` without sending anything, and check
485
+ again on the next call, so deleting files makes room. `open` passes each chunk on as it
486
+ arrives with faraday-net_http 3.0 or later; an older faraday-net_http gives the body
487
+ only once it is complete, so `open` writes it to a temporary file first.
488
+
430
489
  ## Skills — `learn` / `teach` (and when each blocks)
431
490
 
432
491
  `session.learn` and `session.teach` author a learned-skill **draft** from
@@ -482,7 +541,7 @@ client.delete_computer(computer) # PERMANENT: kill if attached, then delete id
482
541
 
483
542
  `stop` and `kill` keep the Computer's identity and saved state; `delete_computer`
484
543
  (a `Computer` or its id) ends both. The id is refused for every later attach, saved
485
- state is purged after 24 hours, and the id is never reusable. Deleting an unknown,
544
+ state is purged after 25 hours, and the id is never reusable. Deleting an unknown,
486
545
  already-deleted, or other project's id also returns `true`. There is no undelete.
487
546
 
488
547
  `stop` submits graceful Stop to Pine's controller, then waits up to 390s
@@ -1,7 +1,6 @@
1
1
  require "faraday"
2
2
  require "json"
3
3
  require "securerandom"
4
- require "tempfile"
5
4
  require "uri"
6
5
  require_relative "../clock"
7
6
  require_relative "../errors"
@@ -11,6 +10,8 @@ require_relative "../control_events"
11
10
  require_relative "../sse_frame_parser"
12
11
  require_relative "../exec_stream_parser"
13
12
  require_relative "../response_handler"
13
+ require_relative "../streamed_read"
14
+ require_relative "../version"
14
15
 
15
16
  module PineSandbox
16
17
  module Adapters
@@ -36,10 +37,6 @@ module PineSandbox
36
37
 
37
38
  DEFAULT_HTTP_TIMEOUT = 30
38
39
  DEFAULT_OPEN_TIMEOUT = 5
39
- # A non-2xx stream answer keeps this much of its body for the error.
40
- STREAM_ERROR_BODY_CAP = 64 * 1024
41
- # Chunk size for passing on a body that was spooled to a temp file.
42
- SPOOL_READ_SIZE = 64 * 1024
43
40
  # An event stream silent this long (three missed keepalives) is reopened.
44
41
  STREAM_IDLE_TIMEOUT = 60
45
42
 
@@ -192,15 +189,23 @@ module PineSandbox
192
189
  true
193
190
  end
194
191
 
195
- def create_session(token:, name: nil, label: nil, browser: false, blind: false)
192
+ # POST /v1/artifacts/links — a public link this running Computer serves
193
+ # to one of its artifacts (ct_ only). Returns { "url", "expires_at"? }.
194
+ def create_artifact_link(token:, path:, expires_in_seconds: nil)
195
+ body = { path: path }
196
+ body[:expires_in_seconds] = expires_in_seconds if expires_in_seconds
197
+ post("v1/artifacts/links", token: token, json: body).body
198
+ end
199
+
200
+ def create_session(token:, name: nil, label: nil, artifact_access: nil)
196
201
  # Omit :name when blank so the coordinator mints a friendly name; only
197
- # send :label when the caller set one. Name stays first so the on-wire
198
- # body order is stable.
202
+ # send :label and :artifact_access when the caller set them. Name stays
203
+ # first so the on-wire body order is stable. Every session has a
204
+ # browser window, so the request names none.
199
205
  body = {}
200
206
  body[:name] = name unless name.nil? || name.to_s.empty?
201
- body[:browser] = browser
202
207
  body[:label] = label unless label.nil? || label.to_s.empty?
203
- body[:blind] = true if blind
208
+ body[:artifact_access] = artifact_access if artifact_access
204
209
  post("sessions", token: token, json: body)
205
210
  .body
206
211
  .fetch("session")
@@ -509,7 +514,7 @@ module PineSandbox
509
514
  event.data["exitCode"] || event.data[:exitCode]
510
515
  exit_code = raw unless raw.nil?
511
516
  end
512
- pass_to_block(block, event) if block
517
+ StreamedRead.pass(block, event) if block
513
518
  end
514
519
  # Clamp the HTTP timeout above timeout_ms so the client waits for
515
520
  # the server's own timeout to fire an execution_complete event
@@ -530,11 +535,11 @@ module PineSandbox
530
535
  # known only at the end: parse the chunk and keep it in case
531
536
  # the answer is an error.
532
537
  parser.parse(chunk)
533
- keep_error_body(error_body, chunk)
538
+ StreamedRead.keep_error_body(error_body, chunk)
534
539
  elsif env.status.to_i.between?(200, 299)
535
540
  parser.parse(chunk)
536
541
  else
537
- keep_error_body(error_body, chunk)
542
+ StreamedRead.keep_error_body(error_body, chunk)
538
543
  end
539
544
  end
540
545
  req.options.timeout = http_timeout
@@ -896,11 +901,11 @@ module PineSandbox
896
901
  # known only at the end: parse the chunk and keep it in case the
897
902
  # answer is an error.
898
903
  parser.feed(chunk) { |frame| yield frame }
899
- keep_error_body(error_body, chunk)
904
+ StreamedRead.keep_error_body(error_body, chunk)
900
905
  elsif env.status.to_i.between?(200, 299)
901
906
  parser.feed(chunk) { |frame| yield frame }
902
907
  else
903
- keep_error_body(error_body, chunk)
908
+ StreamedRead.keep_error_body(error_body, chunk)
904
909
  end
905
910
  end
906
911
  end
@@ -912,29 +917,9 @@ module PineSandbox
912
917
  response
913
918
  end
914
919
 
915
- # Raised through Faraday's read loop in place of an exception from the
916
- # caller's block, so the block's own error is never mistaken for a
917
- # dropped connection.
918
- class BlockError < StandardError
919
- attr_reader :error
920
-
921
- def initialize(error)
922
- @error = error
923
- super(error.message)
924
- end
925
- end
920
+ BlockError = StreamedRead::BlockError
926
921
  private_constant :BlockError
927
922
 
928
- # Runs the caller's block on one piece of a streamed answer from inside
929
- # Faraday's read loop, which would turn an IOError or EPIPE from the
930
- # block into a dropped connection. The streaming method raises the
931
- # block's error again as it was.
932
- def pass_to_block(block, value)
933
- block.call(value)
934
- rescue StandardError => e
935
- raise BlockError.new(e)
936
- end
937
-
938
923
  # The resuming event-stream engine. Each connection opens from the last
939
924
  # event id seen; frames advance the cursor before any decode, so a
940
925
  # skipped frame is never replayed. A dropped connection (a transport
@@ -944,7 +929,7 @@ module PineSandbox
944
929
  # dead one ends with StreamLostError. An error answer raises at once.
945
930
  # Returns the cursor when `terminal` or `stop_when` matches an event, or
946
931
  # when `max_reconnects` (a caller cap on total reconnects) is used up.
947
- def follow_events(op:, cursor:, open:, decode:, terminal: nil, stop_when: nil, max_reconnects: nil)
932
+ def follow_events(op:, cursor:, open:, decode:, terminal: nil, stop_when: nil, max_reconnects: nil, &block)
948
933
  cursor = cursor.to_s
949
934
  failures = 0
950
935
  reconnects = 0
@@ -962,11 +947,7 @@ module PineSandbox
962
947
  next if event.nil?
963
948
 
964
949
  delivered = true
965
- begin
966
- yield event
967
- rescue StandardError => e
968
- raise BlockError.new(e)
969
- end
950
+ StreamedRead.pass(block, event)
970
951
  throw :pine_stream_stop if terminal&.call(event) || stop_when&.call(event)
971
952
  end
972
953
  request_id = request_id_header(response) || request_id
@@ -1005,11 +986,6 @@ module PineSandbox
1005
986
  ::PineSandbox::ControlEvents.decode_event(frame.event, id, frame.data)
1006
987
  end
1007
988
 
1008
- def keep_error_body(buffer, chunk)
1009
- remaining = STREAM_ERROR_BODY_CAP - buffer.bytesize
1010
- buffer << chunk.byteslice(0, remaining) if remaining.positive?
1011
- end
1012
-
1013
989
  # Passes a binary GET's body to the block in chunks as it arrives instead
1014
990
  # of buffering it. Only a 2xx body reaches the block: an error answer is
1015
991
  # kept (bounded) and raised as the same typed error the buffered read
@@ -1017,44 +993,19 @@ module PineSandbox
1017
993
  # raises too, so the caller knows the body is incomplete. The default
1018
994
  # connection's read timeout bounds each wait for the next chunk.
1019
995
  def stream_bytes(path, token:, accept:, &block)
1020
- error_body = +""
1021
- # faraday-net_http before 3.0 passes no env and learns the status only
1022
- # after the body, so a chunk can't be told from an error body until the
1023
- # response ends. Those chunks go to a temp file, not memory, and are
1024
- # passed on once the status is known.
1025
- spool = nil
1026
996
  with_transport_norm("GET", path) do
1027
- response = connection.get(route_path(path)) do |req|
1028
- apply_token(req, token)
1029
- req.headers["Accept"] = accept
1030
- req.options.on_data = proc do |chunk, _received, env|
1031
- if env.nil?
1032
- (spool ||= Tempfile.new("pinesandbox-read", binmode: true)).write(chunk)
1033
- elsif env.status.to_i.between?(200, 299)
1034
- pass_to_block(block, chunk.b) unless chunk.empty?
1035
- else
1036
- keep_error_body(error_body, chunk)
1037
- end
1038
- end
1039
- end
1040
- if spool
1041
- spool.rewind
1042
- if response.success?
1043
- while (chunk = spool.read(SPOOL_READ_SIZE))
1044
- yield chunk
1045
- end
1046
- else
1047
- keep_error_body(error_body, spool.read(STREAM_ERROR_BODY_CAP).to_s)
997
+ response = StreamedRead.read(->(chunk, _answer) { StreamedRead.pass(block, chunk) }) do |on_data|
998
+ connection.get(route_path(path)) do |req|
999
+ apply_token(req, token)
1000
+ req.headers["Accept"] = accept
1001
+ req.options.on_data = on_data
1048
1002
  end
1049
1003
  end
1050
- response.env.body = error_body if !response.success? && response.body.to_s.empty?
1051
1004
  ensure_success!(response, token: token) unless response.success?
1052
1005
  end
1053
1006
  nil
1054
1007
  rescue BlockError => e
1055
1008
  raise e.error
1056
- ensure
1057
- spool&.close!
1058
1009
  end
1059
1010
 
1060
1011
  def get(path, token:, accept: "application/json", headers: nil)
@@ -1332,7 +1283,7 @@ module PineSandbox
1332
1283
  # API key (coord gates every route on the bearer).
1333
1284
  def connection
1334
1285
  @connection ||= @zone.faraday(@host) do |conn|
1335
- conn.headers["User-Agent"] = "pinesandbox-ruby/#{PineSandbox::VERSION}"
1286
+ conn.headers["User-Agent"] = PineSandbox::USER_AGENT
1336
1287
  conn.options.open_timeout = DEFAULT_OPEN_TIMEOUT
1337
1288
  conn.options.timeout = DEFAULT_HTTP_TIMEOUT
1338
1289
  conn.adapter Faraday.default_adapter
@@ -1350,7 +1301,7 @@ module PineSandbox
1350
1301
  # block, which the adapter calls AFTER applying request_timeout.
1351
1302
  def streaming_connection
1352
1303
  @streaming_connection ||= @zone.faraday(@host) do |conn|
1353
- conn.headers["User-Agent"] = "pinesandbox-ruby/#{PineSandbox::VERSION}"
1304
+ conn.headers["User-Agent"] = PineSandbox::USER_AGENT
1354
1305
  conn.options.open_timeout = DEFAULT_OPEN_TIMEOUT
1355
1306
  # DELIBERATELY no conn.options.timeout — adapter block is authoritative.
1356
1307
  conn.adapter Faraday.default_adapter do |http|
@@ -19,6 +19,7 @@ module PineSandbox
19
19
  # The agent's closing message; the question for "needs_input".
20
20
  def summary = self["summary"]
21
21
  def artifacts = self["artifacts"]
22
+ # Saved paths: client.artifacts(ref["computer_id"]).download(ref["path"], destination).
22
23
  def author = self["author"]
23
24
  def usage = self["usage"]
24
25
  end