pinesandbox 0.0.1 → 0.3.18
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/Gemfile +3 -0
- data/README.md +668 -4
- data/Rakefile +5 -0
- data/lib/pinesandbox/adapters/coordinator_adapter.rb +1374 -0
- data/lib/pinesandbox/adapters/snapshot_adapter.rb +37 -0
- data/lib/pinesandbox/agent_events.rb +213 -0
- data/lib/pinesandbox/agent_mode.rb +252 -0
- data/lib/pinesandbox/agent_turn.rb +75 -0
- data/lib/pinesandbox/attach_credentials_source.rb +392 -0
- data/lib/pinesandbox/author_terminal_translator.rb +96 -0
- data/lib/pinesandbox/bind_hpke.rb +54 -0
- data/lib/pinesandbox/capture_keypair.rb +43 -0
- data/lib/pinesandbox/client.rb +446 -0
- data/lib/pinesandbox/clock.rb +51 -0
- data/lib/pinesandbox/computer.rb +1075 -0
- data/lib/pinesandbox/computer_binding.rb +47 -0
- data/lib/pinesandbox/computer_credentials.rb +175 -0
- data/lib/pinesandbox/connection.rb +26 -0
- data/lib/pinesandbox/control_events.rb +156 -0
- data/lib/pinesandbox/control_plane.rb +341 -0
- data/lib/pinesandbox/control_state.rb +31 -0
- data/lib/pinesandbox/control_token_source.rb +232 -0
- data/lib/pinesandbox/delegated_connection.rb +46 -0
- data/lib/pinesandbox/diagnostics.rb +83 -0
- data/lib/pinesandbox/drive_mode.rb +67 -0
- data/lib/pinesandbox/error_taxonomy.rb +104 -0
- data/lib/pinesandbox/errors.rb +525 -0
- data/lib/pinesandbox/exec_stream_parser.rb +90 -0
- data/lib/pinesandbox/models/artifact.rb +51 -0
- data/lib/pinesandbox/models/computer_session.rb +57 -0
- data/lib/pinesandbox/models/computer_tab.rb +30 -0
- data/lib/pinesandbox/models/file_entry.rb +38 -0
- data/lib/pinesandbox/operations.rb +134 -0
- data/lib/pinesandbox/response_handler.rb +88 -0
- data/lib/pinesandbox/runtime_details.rb +25 -0
- data/lib/pinesandbox/runtime_handle.rb +252 -0
- data/lib/pinesandbox/session.rb +627 -0
- data/lib/pinesandbox/skills.rb +142 -0
- data/lib/pinesandbox/spec_version_middleware.rb +34 -0
- data/lib/pinesandbox/sse_frame_parser.rb +95 -0
- data/lib/pinesandbox/state_hpke.rb +57 -0
- data/lib/pinesandbox/version.rb +3 -0
- data/lib/pinesandbox/zone.rb +176 -0
- data/lib/pinesandbox.rb +40 -2
- data/pinesandbox.gemspec +47 -0
- metadata +155 -10
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
require "json"
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module PineSandbox
|
|
7
|
+
# Hash-compatible public payload with per-request scheduling metadata.
|
|
8
|
+
class DiagnosticResponse < Hash
|
|
9
|
+
attr_reader :retry_after
|
|
10
|
+
|
|
11
|
+
def initialize(payload, retry_after: nil)
|
|
12
|
+
super()
|
|
13
|
+
update(payload)
|
|
14
|
+
@retry_after = retry_after
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @api private — the Client exposes the project-scoped diagnostics operations.
|
|
19
|
+
class Diagnostics
|
|
20
|
+
def initialize(api_key:, zone:)
|
|
21
|
+
@api_key = api_key
|
|
22
|
+
@connection = zone.faraday(zone.control_host) do |f|
|
|
23
|
+
f.options.open_timeout = 5
|
|
24
|
+
f.options.timeout = 30
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create(computer_id:, idempotency_key:, session: nil, request_id: nil, description: nil)
|
|
29
|
+
body = { computer_id: computer_id, session: session, request_id: request_id, description: description }.compact
|
|
30
|
+
request(:post, "/v1/diagnostics", body: body, idempotency_key: idempotency_key)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def get(id)
|
|
34
|
+
raise ArgumentError, "valid diagnostic reference required" unless id.is_a?(String) && id.match?(/\Adiag_[0-9a-f]{16}\z/)
|
|
35
|
+
|
|
36
|
+
request(:get, "/v1/diagnostics/#{id}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def inspect = "#<PineSandbox::Diagnostics [credentials redacted]>"
|
|
40
|
+
alias_method :to_s, :inspect
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def request(method, path, body: nil, idempotency_key: nil)
|
|
45
|
+
op = "#{method.to_s.upcase} #{path}"
|
|
46
|
+
resp = @connection.public_send(method, path) do |req|
|
|
47
|
+
req.headers["Authorization"] = "Bearer #{@api_key}"
|
|
48
|
+
req.headers["Accept"] = "application/json"
|
|
49
|
+
req.headers["Idempotency-Key"] = idempotency_key if idempotency_key
|
|
50
|
+
if body
|
|
51
|
+
req.headers["Content-Type"] = "application/json"
|
|
52
|
+
req.body = JSON.generate(body)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
result = JSON.parse(resp.body.to_s) rescue nil
|
|
56
|
+
result = {} unless result.is_a?(Hash)
|
|
57
|
+
request_id = resp.headers["x-request-id"] || result["request_id"]
|
|
58
|
+
retry_after = parse_retry_after(resp.headers["retry-after"])
|
|
59
|
+
unless (200..299).cover?(resp.status)
|
|
60
|
+
raise PortalError.new(result["detail"] || "Diagnostic request failed", status: resp.status,
|
|
61
|
+
request_id: request_id, op: op, problem_type: result["type"], reason: result["reason"], retry_after: retry_after)
|
|
62
|
+
end
|
|
63
|
+
unless result["id"].is_a?(String) && result["id"].match?(/\Adiag_[0-9a-f]{16}\z/) && result["state"].is_a?(String) && !result["state"].empty?
|
|
64
|
+
raise PortalError.new("Invalid diagnostic response", status: resp.status, request_id: request_id, op: op, retry_after: retry_after)
|
|
65
|
+
end
|
|
66
|
+
DiagnosticResponse.new(result, retry_after: retry_after)
|
|
67
|
+
rescue Faraday::TimeoutError
|
|
68
|
+
raise PineSandbox::TimeoutError, PineSandbox.with_context("Diagnostic request timed out", op: op)
|
|
69
|
+
rescue Faraday::ConnectionFailed
|
|
70
|
+
raise PineSandbox::ConnectionError, PineSandbox.with_context("Diagnostic connection failed", op: op)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse_retry_after(value)
|
|
74
|
+
text = value.to_s.strip
|
|
75
|
+
return nil if text.empty?
|
|
76
|
+
return text.to_i if text.match?(/\A\d+\z/)
|
|
77
|
+
|
|
78
|
+
[(Time.httpdate(text) - Time.now).ceil, 0].max
|
|
79
|
+
rescue ArgumentError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module PineSandbox
|
|
2
|
+
# session.drive.* — drive the session with your own agent. #observe is
|
|
3
|
+
# read-only (allowed even while Pine's agent runs — you can watch);
|
|
4
|
+
# #computer_use and #upload_file are mutating drive actions, refused while
|
|
5
|
+
# Pine's agent or a person is driving (a session has one driver at a time).
|
|
6
|
+
# The actions need the session token (ps_); #observe also accepts the
|
|
7
|
+
# Computer token, which a handle read without its ps_ uses.
|
|
8
|
+
#
|
|
9
|
+
# Reached via {Session#drive}: `session.drive.observe`.
|
|
10
|
+
class DriveMode
|
|
11
|
+
def initialize(session)
|
|
12
|
+
@session = session
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# observe — the ObservationSnapshot: a screenshot plus the coordinate
|
|
16
|
+
# metadata (viewport / screenshot_size / device_scale_factor / scroll) and a
|
|
17
|
+
# monotonic seq + navigation page_epoch. Read-only.
|
|
18
|
+
def observe
|
|
19
|
+
coordinator.observe(token: @session.send(:read_token), name: name)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# computer_use(action, **fields) — one native computer-use action. `action`
|
|
23
|
+
# is e.g. :screenshot, :left_click, :type, :scroll; `fields` carry that
|
|
24
|
+
# action's inputs (coordinate:, text:, …). Returns the action result.
|
|
25
|
+
def computer_use(action, **fields)
|
|
26
|
+
coordinator.computer_use(token: ps, name: name, action: action.to_s, params: fields)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The common actions, as #computer_use calls. Coordinates are screenshot
|
|
30
|
+
# pixels (see #observe for the mapping).
|
|
31
|
+
def screenshot = computer_use(:screenshot)
|
|
32
|
+
def click(x, y) = computer_use(:left_click, coordinate: [x, y])
|
|
33
|
+
def right_click(x, y) = computer_use(:right_click, coordinate: [x, y])
|
|
34
|
+
def double_click(x, y) = computer_use(:double_click, coordinate: [x, y])
|
|
35
|
+
def mouse_move(x, y) = computer_use(:mouse_move, coordinate: [x, y])
|
|
36
|
+
def type_text(text) = computer_use(:type, text: text)
|
|
37
|
+
|
|
38
|
+
# Presses a key or chord, such as "Enter" or "ctrl+a".
|
|
39
|
+
def key(chord) = computer_use(:key, text: chord)
|
|
40
|
+
|
|
41
|
+
# Scrolls at (x, y); direction is "up", "down", "left" or "right".
|
|
42
|
+
def scroll(x, y, direction, amount)
|
|
43
|
+
computer_use(:scroll, coordinate: [x, y], scroll_direction: direction.to_s, scroll_amount: amount)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# upload_file(selector, file) — set a previously-deposited attachment as the
|
|
47
|
+
# value of the file <input> matched by selector. `file` is a single
|
|
48
|
+
# name in the session's attachments/ (deposit it first via the files API).
|
|
49
|
+
def upload_file(selector, file)
|
|
50
|
+
coordinator.upload_file(token: ps, name: name, selector: selector, file: file)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def name
|
|
56
|
+
@session.name
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def coordinator
|
|
60
|
+
@session.send(:coordinator)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def ps
|
|
64
|
+
@session.token
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module PineSandbox
|
|
2
|
+
# @api private — the retry judgment per problem type, used only when an
|
|
3
|
+
# older server omits `retryable` from a problem. A copy of
|
|
4
|
+
# sdks/pine-computer/contract/error-taxonomy.json, which the test suite
|
|
5
|
+
# checks this table against.
|
|
6
|
+
module ErrorTaxonomy
|
|
7
|
+
RETRYABLE_BY_PROBLEM_TYPE = {
|
|
8
|
+
"/errors/sandbox-not-found" => false,
|
|
9
|
+
"/errors/sandbox-not-ready" => true,
|
|
10
|
+
"/errors/unauthorized" => false,
|
|
11
|
+
"/errors/forbidden" => false,
|
|
12
|
+
"/errors/precondition-required" => false,
|
|
13
|
+
"/errors/precondition-failed" => true,
|
|
14
|
+
"/errors/malformed-json" => false,
|
|
15
|
+
"/errors/malformed-request" => false,
|
|
16
|
+
"/errors/invalid-field" => false,
|
|
17
|
+
"/errors/invalid-filename" => false,
|
|
18
|
+
"/errors/empty-patch" => false,
|
|
19
|
+
"/errors/actor-type-required" => false,
|
|
20
|
+
"/errors/invalid-transition" => false,
|
|
21
|
+
"/errors/control-not-held" => false,
|
|
22
|
+
"/errors/idempotency-in-progress" => true,
|
|
23
|
+
"/errors/lease-expired" => false,
|
|
24
|
+
"/errors/lease-refresh-unavailable" => true,
|
|
25
|
+
"/errors/session-not-found" => false,
|
|
26
|
+
"/errors/file-not-found" => false,
|
|
27
|
+
"/errors/session-busy" => true,
|
|
28
|
+
"/errors/agent-admission-unavailable" => true,
|
|
29
|
+
"/errors/session-limit" => false,
|
|
30
|
+
"/errors/session-teardown-failed" => true,
|
|
31
|
+
"/errors/desktop-bind-failed" => true,
|
|
32
|
+
"/errors/handoff-not-found" => false,
|
|
33
|
+
"/errors/handoff-unavailable" => true,
|
|
34
|
+
"/errors/handoff-expired" => false,
|
|
35
|
+
"/errors/handoff-not-human" => false,
|
|
36
|
+
"/errors/handoff-persistence-failed" => true,
|
|
37
|
+
"/errors/no-desktop-stream" => false,
|
|
38
|
+
"/errors/desktop-unavailable" => false,
|
|
39
|
+
"/errors/skills-unavailable" => false,
|
|
40
|
+
"/errors/secret-in-skill" => false,
|
|
41
|
+
"/errors/skill-author-failed" => true,
|
|
42
|
+
"/errors/author-in-flight" => true,
|
|
43
|
+
"/errors/author-not-found" => false,
|
|
44
|
+
"/errors/action-not-implemented" => false,
|
|
45
|
+
"/errors/computer-use-failed" => true,
|
|
46
|
+
"/errors/artifact-error" => true,
|
|
47
|
+
"/errors/artifact-not-found" => false,
|
|
48
|
+
"/errors/artifact-too-large" => false,
|
|
49
|
+
"/errors/capture-failed" => true,
|
|
50
|
+
"/errors/capture-not-enabled" => false,
|
|
51
|
+
"/errors/internal" => true,
|
|
52
|
+
"/errors/no-active-task" => false,
|
|
53
|
+
"/errors/no-active-turn" => false,
|
|
54
|
+
"/errors/no-pending-request" => false,
|
|
55
|
+
"/errors/stale-turn" => false,
|
|
56
|
+
"/errors/task-control-failed" => true,
|
|
57
|
+
"/errors/task-not-found" => false,
|
|
58
|
+
"/errors/task-not-ready" => true,
|
|
59
|
+
"/errors/bind-not-configured" => false,
|
|
60
|
+
"/errors/bind-lease-unenforceable" => false,
|
|
61
|
+
"/errors/bind-state-lost" => true,
|
|
62
|
+
"/errors/pod-tainted" => false,
|
|
63
|
+
"/errors/wrong-pod-uid" => false,
|
|
64
|
+
"/errors/stale-coord-boot-id" => false,
|
|
65
|
+
"/errors/already-bound-different-cid" => false,
|
|
66
|
+
"/errors/bind-in-progress" => true,
|
|
67
|
+
"/errors/egress-activation-failed" => true,
|
|
68
|
+
"/errors/bind-rejected" => false,
|
|
69
|
+
"/errors/bind-restore-failed" => true,
|
|
70
|
+
"/errors/hpke-decrypt-failed" => false,
|
|
71
|
+
"/errors/not-bound" => false,
|
|
72
|
+
"/errors/grant-cid-mismatch" => false,
|
|
73
|
+
"/errors/grant-downgrade" => false,
|
|
74
|
+
"/errors/grant-expired" => true,
|
|
75
|
+
"/errors/grant-hash-mismatch" => false,
|
|
76
|
+
"/errors/grant-malformed" => false,
|
|
77
|
+
"/errors/grant-refresh-rejected" => false,
|
|
78
|
+
"/errors/grant-wrong-cid" => false,
|
|
79
|
+
"/errors/bind-restore-no-challenge" => false,
|
|
80
|
+
"/errors/bind-restore-contended" => false,
|
|
81
|
+
"/errors/skill-publication-unavailable" => true,
|
|
82
|
+
"/errors/runtime-initializing" => true,
|
|
83
|
+
"/errors/computer-stopped" => false,
|
|
84
|
+
"/errors/stop-failed" => false,
|
|
85
|
+
"/errors/history-expired" => false,
|
|
86
|
+
"/errors/storage-exhausted" => false
|
|
87
|
+
}.freeze
|
|
88
|
+
|
|
89
|
+
module_function
|
|
90
|
+
|
|
91
|
+
# The taxonomy's verdict for a known type; otherwise 412 is retryable, 501
|
|
92
|
+
# is not, any other 5xx is, and everything else is not.
|
|
93
|
+
def retryable?(problem_type, status)
|
|
94
|
+
known = RETRYABLE_BY_PROBLEM_TYPE[problem_type.to_s]
|
|
95
|
+
return known unless known.nil?
|
|
96
|
+
|
|
97
|
+
status = status.to_i
|
|
98
|
+
return true if status == 412
|
|
99
|
+
return false if status == 501
|
|
100
|
+
|
|
101
|
+
status >= 500
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|