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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -0
  3. data/README.md +668 -4
  4. data/Rakefile +5 -0
  5. data/lib/pinesandbox/adapters/coordinator_adapter.rb +1374 -0
  6. data/lib/pinesandbox/adapters/snapshot_adapter.rb +37 -0
  7. data/lib/pinesandbox/agent_events.rb +213 -0
  8. data/lib/pinesandbox/agent_mode.rb +252 -0
  9. data/lib/pinesandbox/agent_turn.rb +75 -0
  10. data/lib/pinesandbox/attach_credentials_source.rb +392 -0
  11. data/lib/pinesandbox/author_terminal_translator.rb +96 -0
  12. data/lib/pinesandbox/bind_hpke.rb +54 -0
  13. data/lib/pinesandbox/capture_keypair.rb +43 -0
  14. data/lib/pinesandbox/client.rb +446 -0
  15. data/lib/pinesandbox/clock.rb +51 -0
  16. data/lib/pinesandbox/computer.rb +1075 -0
  17. data/lib/pinesandbox/computer_binding.rb +47 -0
  18. data/lib/pinesandbox/computer_credentials.rb +175 -0
  19. data/lib/pinesandbox/connection.rb +26 -0
  20. data/lib/pinesandbox/control_events.rb +156 -0
  21. data/lib/pinesandbox/control_plane.rb +341 -0
  22. data/lib/pinesandbox/control_state.rb +31 -0
  23. data/lib/pinesandbox/control_token_source.rb +232 -0
  24. data/lib/pinesandbox/delegated_connection.rb +46 -0
  25. data/lib/pinesandbox/diagnostics.rb +83 -0
  26. data/lib/pinesandbox/drive_mode.rb +67 -0
  27. data/lib/pinesandbox/error_taxonomy.rb +104 -0
  28. data/lib/pinesandbox/errors.rb +525 -0
  29. data/lib/pinesandbox/exec_stream_parser.rb +90 -0
  30. data/lib/pinesandbox/models/artifact.rb +51 -0
  31. data/lib/pinesandbox/models/computer_session.rb +57 -0
  32. data/lib/pinesandbox/models/computer_tab.rb +30 -0
  33. data/lib/pinesandbox/models/file_entry.rb +38 -0
  34. data/lib/pinesandbox/operations.rb +134 -0
  35. data/lib/pinesandbox/response_handler.rb +88 -0
  36. data/lib/pinesandbox/runtime_details.rb +25 -0
  37. data/lib/pinesandbox/runtime_handle.rb +252 -0
  38. data/lib/pinesandbox/session.rb +627 -0
  39. data/lib/pinesandbox/skills.rb +142 -0
  40. data/lib/pinesandbox/spec_version_middleware.rb +34 -0
  41. data/lib/pinesandbox/sse_frame_parser.rb +95 -0
  42. data/lib/pinesandbox/state_hpke.rb +57 -0
  43. data/lib/pinesandbox/version.rb +3 -0
  44. data/lib/pinesandbox/zone.rb +176 -0
  45. data/lib/pinesandbox.rb +40 -2
  46. data/pinesandbox.gemspec +47 -0
  47. 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