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
|
@@ -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
|
|
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
|
-
|
|
243
|
+
revision, problem["current_sandbox_id"]
|
|
215
244
|
),
|
|
216
|
-
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/)
|
data/lib/pinesandbox/client.rb
CHANGED
|
@@ -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
|
|
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 =
|
data/lib/pinesandbox/computer.rb
CHANGED
|
@@ -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
|
|
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.
|
|
390
|
-
#
|
|
391
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
data/lib/pinesandbox/errors.rb
CHANGED
|
@@ -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
|
-
:
|
|
15
|
+
:artifact_access,
|
|
16
16
|
:created_at,
|
|
17
17
|
:last_active_at,
|
|
18
18
|
keyword_init: true
|
|
19
19
|
) do
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?
|
data/lib/pinesandbox/session.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
data/lib/pinesandbox/version.rb
CHANGED
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.
|
|
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
|