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,47 @@
1
+ require "json"
2
+
3
+ module PineSandbox
4
+ class Computer
5
+ # A saved connection to one runtime. Store with Credentials in encrypted
6
+ # application storage. Importing it neither contacts nor replaces the runtime.
7
+ class Binding < Struct.new(:computer_id, :runtime_id, :computer_token,
8
+ :binding_revision, keyword_init: true)
9
+ SCHEMA = 1
10
+
11
+ def self.from_json(json)
12
+ data = JSON.parse(json)
13
+ unless data.is_a?(Hash) && data["schema"].is_a?(Integer) && data["schema"] == SCHEMA
14
+ raise ArgumentError, "unsupported Computer binding schema"
15
+ end
16
+ new(computer_id: data["computer_id"], runtime_id: data["runtime_id"],
17
+ computer_token: data["computer_token"], binding_revision: data["binding_revision"]).validate!
18
+ rescue JSON::ParserError
19
+ raise ArgumentError, "Computer binding must be valid JSON"
20
+ end
21
+
22
+ def validate!
23
+ unless computer_id.is_a?(String) && computer_id.match?(Computer::UUID_V7)
24
+ raise ArgumentError, "binding computer_id must be a lowercase UUIDv7"
25
+ end
26
+ unless runtime_id.is_a?(String) && runtime_id.length <= 63 && runtime_id.match?(Zone::RUNTIME_ID_RE)
27
+ raise ArgumentError, "binding runtime_id must be a runtime identifier"
28
+ end
29
+ unless computer_token.is_a?(String) && computer_token.match?(/\Act_[!-~]+\z/)
30
+ raise ArgumentError, "binding computer_token must be a Computer token"
31
+ end
32
+ unless binding_revision.is_a?(Integer) && binding_revision.between?(0, (2**63) - 1)
33
+ raise ArgumentError, "binding revision must be a non-negative integer"
34
+ end
35
+ self
36
+ end
37
+
38
+ def to_json(*args)
39
+ validate!
40
+ { schema: SCHEMA, computer_id:, runtime_id:, computer_token:, binding_revision: }.to_json(*args)
41
+ end
42
+
43
+ def inspect = "#<PineSandbox::Computer::Binding computer_id=#{computer_id} runtime_id=#{runtime_id} token=[redacted]>"
44
+ alias_method :to_s, :inspect
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,175 @@
1
+ require "base64"
2
+ require "json"
3
+
4
+ module PineSandbox
5
+ class Computer
6
+ # Caller-owned secrets for starting a Computer. JSON contains secret bytes
7
+ # encoded as Base64; store it in your application's encrypted storage.
8
+ # Keep Struct readers, writers and raw to_h values; the member set expands.
9
+ class Credentials < Struct.new(:id, :key, :capture_keypair, :prior_capture_keypairs,
10
+ :prior_keys, :ephemeral, keyword_init: true)
11
+ SCHEMA = 1
12
+
13
+ def self.from_json(json)
14
+ from_serialized_hash(JSON.parse(json))
15
+ rescue JSON::ParserError
16
+ raise ArgumentError, "credentials must be valid JSON"
17
+ end
18
+
19
+ def self.from_serialized_hash(hash)
20
+ raise ArgumentError, "computer credentials must be a Hash" unless hash.is_a?(Hash)
21
+
22
+ data = hash.transform_keys(&:to_s)
23
+ # Released Go credentials used encoding/json's exported field names.
24
+ # Accept those saved records, but always write the shared schema.
25
+ if !data.key?("schema") && data.key?("ID")
26
+ pair = data["CaptureKeypair"]
27
+ raise ArgumentError, "capture keypair must be an object" unless pair.is_a?(Hash)
28
+ data = {"schema" => SCHEMA, "id" => data["ID"], "key" => data["Key"],
29
+ "capture_keypair" => {"generation" => pair["Generation"], "pk" => pair["PK"], "sk" => pair["SK"]}}
30
+ end
31
+ schema = data["schema"]
32
+ unless schema.is_a?(Integer) && schema == SCHEMA
33
+ raise ArgumentError, "unsupported computer credentials schema (expected #{SCHEMA})"
34
+ end
35
+
36
+ prior_capture_keypairs = data.fetch("prior_capture_keypairs", [])
37
+ unless prior_capture_keypairs.is_a?(Array)
38
+ raise ArgumentError, "prior_capture_keypairs must be an Array"
39
+ end
40
+
41
+ new(
42
+ id: fetch_string(data, "id"),
43
+ key: decode(fetch_string(data, "key"), "key"),
44
+ ephemeral: data.fetch("ephemeral", false),
45
+ capture_keypair: data["capture_keypair"] && keypair_from_h(data["capture_keypair"]),
46
+ prior_capture_keypairs: prior_capture_keypairs.map { |entry| keypair_from_h(entry) },
47
+ prior_keys: prior_keys_from(data.fetch("prior_keys", []))
48
+ ).validate!
49
+ end
50
+
51
+ def self.prior_keys_from(entries)
52
+ raise ArgumentError, "prior_keys must be an Array" unless entries.is_a?(Array)
53
+
54
+ pairs = entries.map do |entry|
55
+ raise ArgumentError, "prior_keys entries must be Hashes" unless entry.is_a?(Hash)
56
+
57
+ entry = entry.transform_keys(&:to_s)
58
+ [ entry.fetch("version") { raise ArgumentError, "prior key is missing version" },
59
+ decode(fetch_string(entry, "key"), "prior key") ]
60
+ end
61
+ raise ArgumentError, "prior_keys versions must be distinct" if pairs.map(&:first).uniq.length != pairs.length
62
+
63
+ pairs.to_h
64
+ end
65
+ private_class_method :prior_keys_from
66
+
67
+ def initialize(id: nil, key: nil, ephemeral: false, capture_keypair: nil,
68
+ prior_capture_keypairs: [], prior_keys: {})
69
+ super(id: id&.dup, key: key&.dup, capture_keypair: capture_keypair,
70
+ prior_capture_keypairs: prior_capture_keypairs, prior_keys: prior_keys,
71
+ ephemeral: ephemeral)
72
+ end
73
+
74
+ def validate!
75
+ raise ArgumentError, "id must be a lowercase UUIDv7" unless id.is_a?(String) && id.match?(Computer::UUID_V7)
76
+ raise ArgumentError, "key must be 32 raw bytes" unless key.is_a?(String) && key.bytesize == 32
77
+ raise ArgumentError, "ephemeral must be true or false" unless [ true, false ].include?(ephemeral)
78
+ unless capture_keypair.nil? || capture_keypair.is_a?(CaptureKeypair)
79
+ raise ArgumentError, "capture_keypair must be a PineSandbox::CaptureKeypair"
80
+ end
81
+ if !ephemeral && capture_keypair.nil?
82
+ raise ArgumentError, "persistent Computer credentials require capture_keypair"
83
+ end
84
+ unless prior_capture_keypairs.is_a?(Array) && prior_capture_keypairs.all? { |keypair| keypair.is_a?(CaptureKeypair) }
85
+ raise ArgumentError, "prior_capture_keypairs must contain only PineSandbox::CaptureKeypair values"
86
+ end
87
+ prior_generations = prior_capture_keypairs.map(&:generation)
88
+ if prior_generations.any? && capture_keypair.nil?
89
+ raise ArgumentError, "prior_capture_keypairs require capture_keypair"
90
+ end
91
+ if prior_generations.uniq.length != prior_generations.length ||
92
+ prior_generations.any? { |generation| generation >= capture_keypair.generation }
93
+ raise ArgumentError, "prior capture keypair generations must be distinct and below the current generation"
94
+ end
95
+ unless prior_keys.is_a?(Hash) && prior_keys.all? { |version, bytes| version.is_a?(Integer) && bytes.is_a?(String) && bytes.bytesize == 32 }
96
+ raise ArgumentError, "prior_keys must map Integer versions to 32 raw bytes"
97
+ end
98
+ if prior_keys.key?(Computer::CURRENT_KEY_VERSION)
99
+ raise ArgumentError, "prior_keys must not include the current key version #{Computer::CURRENT_KEY_VERSION}"
100
+ end
101
+
102
+ self
103
+ end
104
+
105
+ def ephemeral?
106
+ ephemeral
107
+ end
108
+
109
+ # JSON contains secrets; ordinary Struct#to_h retains raw Ruby values.
110
+ def serialized_hash
111
+ validate!
112
+ {
113
+ "schema" => SCHEMA,
114
+ "id" => id.dup,
115
+ "key" => Base64.strict_encode64(key),
116
+ "ephemeral" => ephemeral?,
117
+ "capture_keypair" => capture_keypair && self.class.keypair_to_h(capture_keypair),
118
+ "prior_capture_keypairs" => prior_capture_keypairs.sort_by(&:generation).map { |keypair| self.class.keypair_to_h(keypair) },
119
+ "prior_keys" => prior_keys.sort.map { |version, bytes| { "version" => version, "key" => Base64.strict_encode64(bytes) } }
120
+ }
121
+ end
122
+
123
+ def to_json(*args)
124
+ serialized_hash.to_json(*args)
125
+ end
126
+
127
+ def inspect
128
+ "#<PineSandbox::Computer::Credentials id=#{id.inspect} " \
129
+ "ephemeral=#{ephemeral?} key=[redacted] capture_keypair=[redacted]>"
130
+ end
131
+ alias_method :to_s, :inspect
132
+
133
+ private :serialized_hash
134
+ private_class_method :from_serialized_hash
135
+
136
+ class << self
137
+ def keypair_to_h(keypair)
138
+ {
139
+ "generation" => keypair.generation,
140
+ "pk" => Base64.strict_encode64(keypair.pk),
141
+ "sk" => Base64.strict_encode64(keypair.sk)
142
+ }
143
+ end
144
+
145
+ private
146
+
147
+ def keypair_from_h(entry)
148
+ raise ArgumentError, "capture keypair entries must be Hashes" unless entry.is_a?(Hash)
149
+
150
+ entry = entry.transform_keys(&:to_s)
151
+ CaptureKeypair.new(
152
+ generation: entry.fetch("generation") { raise ArgumentError, "capture keypair is missing generation" },
153
+ pk: decode(fetch_string(entry, "pk"), "capture keypair pk"),
154
+ sk: decode(fetch_string(entry, "sk"), "capture keypair sk")
155
+ )
156
+ end
157
+
158
+ def fetch_string(data, name)
159
+ value = data[name]
160
+ raise ArgumentError, "computer credentials field #{name} must be a String" unless value.is_a?(String)
161
+
162
+ value
163
+ end
164
+
165
+ def decode(value, label)
166
+ decoded = Base64.strict_decode64(value)
167
+ raise ArgumentError unless Base64.strict_encode64(decoded) == value
168
+ decoded
169
+ rescue ArgumentError
170
+ raise ArgumentError, "computer credentials field #{label} is not strict Base64"
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,26 @@
1
+ require_relative "zone"
2
+ require_relative "clock"
3
+
4
+ module PineSandbox
5
+ # Internal value object bundling everything Computer#attach needs against the
6
+ # Computer gateway: the `attach_provider` (mints per-attach {bind_token,
7
+ # broker_grant} — the pk_-backed portal source or an in-process issuer), the
8
+ # `zone` (derives the control + data hosts), and the `control_plane`
9
+ # (project-JWS create/inspect/destroy at api.<zone>). Built by
10
+ # PineSandbox::Client — not a public constructor.
11
+ class Connection
12
+ attr_reader :zone, :control_plane, :attach_provider, :clock
13
+
14
+ # `clock` paces every wait the Computer and its data-plane client make.
15
+ def initialize(zone:, control_plane:, attach_provider:, clock: PineSandbox::SYSTEM_CLOCK)
16
+ raise ArgumentError, "attach_provider required (mints the per-attach bind_token + broker_grant)" unless attach_provider
17
+ raise ArgumentError, "zone required (a PineSandbox::Zone)" unless zone.is_a?(PineSandbox::Zone)
18
+ raise ArgumentError, "control_plane required" unless control_plane
19
+
20
+ @attach_provider = attach_provider
21
+ @zone = zone
22
+ @control_plane = control_plane
23
+ @clock = clock
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,156 @@
1
+ require "json"
2
+ require "time"
3
+
4
+ module PineSandbox
5
+ # Typed events delivered by `Session#control_events(&block)`. Each
6
+ # struct corresponds to one of the four canonical v1 SSE event types:
7
+ #
8
+ # - controller_changed → ControllerChanged
9
+ # - idle_changed → IdleChanged
10
+ # - handoff_completed → HandoffCompleted
11
+ # - handoff_failed → HandoffFailed
12
+ #
13
+ # `idle_warning` (IdleWarning) is decoded too, should the Computer emit it.
14
+ #
15
+ # The block sees ONE typed struct per event. Unknown event types are
16
+ # dropped silently — clients should add new types via this catalog
17
+ # before stable consumers exist (SSE protocol contract).
18
+ #
19
+ # All structs carry `id` (the SSE `id:` field, monotonic uint64 per
20
+ # session) so callers can stash the last-seen ID for reconnect, and `type`
21
+ # (the SSE event name).
22
+ module ControlEvents
23
+ ControllerChanged = Struct.new(:id, :session_name, :controller, :epoch, :trigger,
24
+ :current_human_session, keyword_init: true) do
25
+ def type = "controller_changed"
26
+ end
27
+ # The person's idle deadline or pause changed. `idle_deadline` is a Time,
28
+ # or nil while the idle timer is paused.
29
+ IdleChanged = Struct.new(:id, :session_name, :epoch, :idle_deadline, :idle_paused,
30
+ :sub_operations, keyword_init: true) do
31
+ def type = "idle_changed"
32
+ end
33
+ IdleWarning = Struct.new(:id, :session_name, :expires_in_seconds, keyword_init: true) do
34
+ def type = "idle_warning"
35
+ end
36
+ HandoffCompleted = Struct.new(:id, :session_name, :handoff_id, keyword_init: true) do
37
+ def type = "handoff_completed"
38
+ end
39
+ HandoffFailed = Struct.new(:id, :session_name, :handoff_id, :reason, :problem,
40
+ keyword_init: true) do
41
+ def type = "handoff_failed"
42
+ end
43
+
44
+ # decode_event parses one SSE frame's (event_type, id, data_json)
45
+ # tuple into the matching typed struct. Returns nil on unknown event
46
+ # type or invalid JSON — the caller advances to the next frame.
47
+ def self.decode_event(event_type, id, data_json)
48
+ data =
49
+ begin
50
+ data_json.is_a?(Hash) ? data_json : JSON.parse(data_json.to_s)
51
+ rescue JSON::ParserError
52
+ return nil
53
+ end
54
+
55
+ case event_type
56
+ when "controller_changed"
57
+ ControllerChanged.new(
58
+ id: id,
59
+ session_name: data["session_name"],
60
+ controller: data["controller"],
61
+ epoch: data["epoch"],
62
+ trigger: data["trigger"],
63
+ current_human_session: data["current_human_session"]
64
+ )
65
+ when "idle_changed"
66
+ IdleChanged.new(
67
+ id: id,
68
+ session_name: data["session_name"],
69
+ epoch: data["epoch"],
70
+ idle_deadline: parse_time(data["idle_deadline"]),
71
+ idle_paused: data["idle_paused"] == true,
72
+ sub_operations: Array(data["sub_operations"])
73
+ )
74
+ when "idle_warning"
75
+ IdleWarning.new(
76
+ id: id,
77
+ session_name: data["session_name"],
78
+ expires_in_seconds: data["expires_in_seconds"]
79
+ )
80
+ when "handoff_completed"
81
+ HandoffCompleted.new(
82
+ id: id,
83
+ session_name: data["session_name"],
84
+ handoff_id: data["handoff_id"]
85
+ )
86
+ when "handoff_failed"
87
+ HandoffFailed.new(
88
+ id: id,
89
+ session_name: data["session_name"],
90
+ handoff_id: data["handoff_id"],
91
+ reason: data["reason"],
92
+ problem: data["problem"]
93
+ )
94
+ end
95
+ end
96
+
97
+ def self.parse_time(value)
98
+ return nil unless value.is_a?(String) && !value.empty?
99
+
100
+ Time.iso8601(value)
101
+ rescue ArgumentError
102
+ nil
103
+ end
104
+ private_class_method :parse_time
105
+ end
106
+
107
+ # ControlEventStreamParser parses the v1 SSE byte stream
108
+ # (`id:\nevent:\ndata:\n\n`) into typed ControlEvents structs. Unlike
109
+ # Services::EventStreamParser, this parser is focused on the v1
110
+ # control surface: every emitted event has an `event:` discriminator,
111
+ # the JSON `data` payload has NO `type` field (the SSE `event:` IS
112
+ # the type), and ID is decimal-encoded uint64.
113
+ #
114
+ # Use:
115
+ # parser = ControlEventStreamParser.new
116
+ # parser.feed(chunk) { |evt| ... } # multiple calls as chunks arrive
117
+ # parser.finish # end of stream: drop a cut-off block
118
+ #
119
+ # The parser is stateful per-stream; instantiate a fresh one on
120
+ # reconnect.
121
+ class ControlEventStreamParser
122
+ def initialize
123
+ @frames = SseFrameParser.new
124
+ end
125
+
126
+ # Feed a chunk; yields a typed ControlEvents::* struct per decoded frame.
127
+ # Framing is delegated to the single SseFrameParser; this class owns only
128
+ # the control-event DECODING (event-name + id + payload → typed struct).
129
+ def feed(chunk, &block)
130
+ @frames.feed(chunk) { |frame| emit(frame, &block) }
131
+ end
132
+
133
+ # The end of the stream: a trailing block missing its blank-line
134
+ # terminator was cut off and is dropped.
135
+ def finish(&_block)
136
+ @frames.finish
137
+ end
138
+
139
+ private
140
+
141
+ # Decode one framed event into a typed struct and hand it to the caller.
142
+ # Unknown event types decode to nil (forward-compat) and are dropped; a
143
+ # non-integer id decodes to a nil id.
144
+ def emit(frame, &block)
145
+ typed = ControlEvents.decode_event(frame.event, parse_id(frame.id), frame.data)
146
+ block.call(typed) if typed && block
147
+ end
148
+
149
+ def parse_id(s)
150
+ return nil if s.nil?
151
+ Integer(s, 10)
152
+ rescue ArgumentError
153
+ nil
154
+ end
155
+ end
156
+ end