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 +4 -4
- data/README.md +64 -5
- data/lib/pinesandbox/adapters/coordinator_adapter.rb +30 -79
- data/lib/pinesandbox/agent_turn.rb +1 -0
- data/lib/pinesandbox/artifacts.rb +776 -0
- data/lib/pinesandbox/attach_credentials_source.rb +45 -3
- data/lib/pinesandbox/client.rb +16 -1
- data/lib/pinesandbox/computer.rb +42 -12
- data/lib/pinesandbox/error_taxonomy.rb +2 -0
- data/lib/pinesandbox/errors.rb +27 -0
- data/lib/pinesandbox/models/artifact_file.rb +40 -0
- data/lib/pinesandbox/models/computer_session.rb +7 -3
- data/lib/pinesandbox/operations.rb +2 -0
- data/lib/pinesandbox/sandbox_handle.rb +2 -1
- data/lib/pinesandbox/session.rb +18 -3
- data/lib/pinesandbox/streamed_read.rb +80 -0
- data/lib/pinesandbox/version.rb +3 -1
- data/lib/pinesandbox.rb +2 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1a5f2719e33f58ee6f18cfb781c42c983341981c5dc3c0469a71bfcdebe89b27
|
|
4
|
+
data.tar.gz: 3ef413fba3327ecfca7069f39f96241f622ccdccb795fec799b036b2f4931fa4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
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
|
-
|
|
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
|
|
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[:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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"] =
|
|
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"] =
|
|
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
|