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,392 @@
1
+ require "faraday"
2
+ require "json"
3
+ require_relative "errors"
4
+
5
+ module PineSandbox
6
+ # The pk_-backed attach-credential provider — the DEFAULT attach path
7
+ # (AUTH_REDESIGN: portal-as-issuer). Exchanges the project client key at the
8
+ # portal for per-attach credentials:
9
+ #
10
+ # * GET /v1/locations — project-visible
11
+ # country discovery.
12
+ # * POST /v1/computers — optional legacy
13
+ # pre-registration helper. Normal attach does not require it.
14
+ # * POST /v1/computers/:computer_id/attach-credentials — body carries the
15
+ # runtime coordinates {pod_uid, coord_boot_id}; returns {bind_token,
16
+ # broker_grant, key_assertion, binding_revision}. The portal authenticates
17
+ # the pk_ and atomically creates/rechecks its minimal authorization row,
18
+ # advances the key floor, supersedes the prior binding, and mints.
19
+ # * DELETE /v1/computers/:computer_id — permanent delete.
20
+ # 204 for deleted, already deleted, unknown, and cross-project ids alike.
21
+ #
22
+ # This source caches nothing. Computer#run_bind calls it exactly once for a
23
+ # sandbox, then convergence retries re-send the held envelope verbatim
24
+ # (the bind is idempotent on jti + ciphertext). A runtime-identity shift
25
+ # requires a fresh sandbox; it never authorizes the same sandbox twice.
26
+ # The pk_ never appears in #inspect.
27
+ class AttachCredentialsSource
28
+ REGISTER_PATH = "/v1/computers"
29
+ LOCATIONS_PATH = "/v1/locations"
30
+
31
+ def initialize(api_key:, host:, protocol: "https",
32
+ open_timeout: 5, request_timeout: 30, connection: nil, gateway_url: nil)
33
+ raise ArgumentError, "api_key required (a pk_ client key)" if api_key.to_s.empty?
34
+
35
+ @api_key = api_key.to_s
36
+ @host = host.to_s
37
+ @protocol = protocol
38
+ @gateway_url = gateway_url
39
+ @open_timeout = open_timeout
40
+ @request_timeout = request_timeout
41
+ @connection = connection
42
+ end
43
+
44
+ # Register `computer_id` into the portal ownership registry (idempotent —
45
+ # re-registering the same id for the same project is a no-op success).
46
+ # Returns the parsed registration body.
47
+ def register_computer(computer_id, location: nil)
48
+ raise ArgumentError, "computer_id required" if computer_id.to_s.empty?
49
+
50
+ body = { computer_id: computer_id.to_s }
51
+ body[:location] = normalize_location(location) if location
52
+ resp = post(REGISTER_PATH, json: body, op: "computer registration")
53
+ case resp.status
54
+ when 200, 201
55
+ parse_json(resp)
56
+ when 409, 422
57
+ raise ComputerRegistrationError.new(
58
+ error_message(resp, "computer registration refused (#{resp.status})"),
59
+ status: resp.status, body: resp.body, request_id: request_id(resp), op: http_op(resp)
60
+ )
61
+ else
62
+ raise_generic(resp, "computer registration")
63
+ end
64
+ end
65
+
66
+ # Return the project-visible country catalog and server-owned default. This
67
+ # is inventory discovery only; a real route is still qualified and
68
+ # activated when the Computer binds.
69
+ def available_locations
70
+ resp = get(LOCATIONS_PATH, op: "location discovery")
71
+ raise_location_error(resp) unless resp.status == 200
72
+
73
+ payload = parse_json(resp)
74
+ locations = payload["locations"]
75
+ default_location = payload["default_location"]
76
+ valid = locations.is_a?(Array) &&
77
+ locations.all? { |location| response_location?(location) } &&
78
+ locations.map { |location| location["country"] }.uniq.length == locations.length &&
79
+ response_location?(default_location)
80
+ unless valid
81
+ raise LocationDiscoveryError.new(
82
+ "location discovery response was malformed",
83
+ status: 200, body: resp.body, request_id: request_id(resp), op: http_op(resp)
84
+ )
85
+ end
86
+ payload
87
+ end
88
+
89
+ def project_policy
90
+ resp = get("/v1/project-policy", op: "project policy")
91
+ raise PortalError.new(error_message(resp, "Project policy unavailable"), **error_attributes(resp)) unless resp.status == 200
92
+
93
+ payload = parse_json(resp)
94
+ unless payload.is_a?(Hash) && payload.key?("lifecycle") && payload.key?("max_concurrent_computers")
95
+ raise PortalError.new("Invalid project policy response", **error_attributes(resp))
96
+ end
97
+ payload
98
+ end
99
+
100
+ # One authorization-registry read before Start; never a runtime status poll.
101
+ def current_binding(computer_id)
102
+ resp = get("#{REGISTER_PATH}/#{computer_id}", op: "computer binding lookup")
103
+ if resp.status == 404
104
+ raise UnknownComputerError.new("Computer not found", **error_attributes(resp))
105
+ end
106
+ raise_generic(resp, "computer binding lookup") unless resp.status == 200
107
+ payload = parse_json(resp)
108
+ unless payload["computer_id"] == computer_id && payload["binding_revision"].is_a?(Integer) && payload["binding_revision"] >= 0 &&
109
+ (payload["sandbox_id"].nil? || payload["sandbox_id"].is_a?(String))
110
+ raise AttachCredentialsError.new("computer binding response was malformed", **error_attributes(resp))
111
+ end
112
+ payload
113
+ end
114
+
115
+ # Permanently delete `computer_id`. The portal tombstones it at once (no
116
+ # further attach, renewal, or lease refresh), destroys its bound sandboxes
117
+ # asynchronously, keeps saved browser state for 24 hours before purging it,
118
+ # and never accepts the id again. Idempotent and non-disclosing: an unknown,
119
+ # already-deleted, or cross-project id is the same 204. Returns true.
120
+ def delete_computer(computer_id)
121
+ raise ArgumentError, "computer_id required" if computer_id.to_s.empty?
122
+
123
+ resp = delete("#{REGISTER_PATH}/#{computer_id}", op: "computer deletion")
124
+ case resp.status
125
+ when 204
126
+ true
127
+ when 422
128
+ raise ComputerDeletionError.new(
129
+ error_message(resp, "computer deletion refused: malformed computer_id #{computer_id}"),
130
+ **error_attributes(resp)
131
+ )
132
+ else
133
+ raise_generic(resp, "computer deletion", forbidden: "project or key may not delete computers")
134
+ end
135
+ end
136
+
137
+ # Mint the per-attach credentials for one runtime + boot. `sandbox_id` is the
138
+ # provisioned sandbox this attach targets — the portal records the
139
+ # sandbox→computer attribution at mint (its renewal gate keys off it).
140
+ # Returns a Hash with string keys: "bind_token", "broker_grant",
141
+ # "key_assertion", "binding_revision", and the effective "location"
142
+ # (plus expiry metadata).
143
+ # `pk_computer` (base64url raw X25519 public key) + `key_generation` drive
144
+ # the v3 persistent attach: the portal advances its per-Computer key floor
145
+ # and returns the signed "key_assertion". They are OPTIONAL — an ephemeral
146
+ # Computer (access-lease only, no persistence) omits both, and the portal
147
+ # then mints an access-lease-only binding with no key_assertion.
148
+ def credentials(computer_id:, pod_uid:, coord_boot_id:, sandbox_id:,
149
+ expected_binding_revision:, idempotency_key:,
150
+ pk_computer: nil, key_generation: nil, location: nil)
151
+ raise ArgumentError, "computer_id required" if computer_id.to_s.empty?
152
+ raise ArgumentError, "pod_uid required" if pod_uid.to_s.empty?
153
+ raise ArgumentError, "coord_boot_id required" if coord_boot_id.to_s.empty?
154
+ raise ArgumentError, "sandbox_id required" if sandbox_id.to_s.empty?
155
+ # pk_computer/key_generation are the persistent-attach pair: BOTH present
156
+ # (v3) or BOTH absent (ephemeral). EXACTLY ONE is a malformed persistent
157
+ # request — reject it locally instead of silently dropping the field and
158
+ # registering the Computer as ephemeral. When present, the generation must
159
+ # be positive.
160
+ if pk_computer.to_s.empty? != key_generation.to_s.empty?
161
+ raise ArgumentError, "pk_computer and key_generation must be provided together"
162
+ end
163
+ raise ArgumentError, "key_generation must be positive" if !pk_computer.to_s.empty? && !key_generation.to_i.positive?
164
+ raise ArgumentError, "expected_binding_revision must be non-negative" if expected_binding_revision.to_i.negative?
165
+ raise ArgumentError, "idempotency_key required" if idempotency_key.to_s.empty?
166
+
167
+ body = {
168
+ pod_uid: pod_uid,
169
+ coord_boot_id: coord_boot_id,
170
+ sandbox_id: sandbox_id
171
+ }
172
+ body[:location] = normalize_location(location) if location
173
+ if pk_computer.to_s.empty?
174
+ # Ephemeral: no capture identity, and signal the mode so the portal
175
+ # lazily creates a new Computer row as ephemeral.
176
+ body[:persistence_mode] = "ephemeral"
177
+ else
178
+ body[:pk_computer] = pk_computer
179
+ body[:key_generation] = key_generation
180
+ end
181
+ body[:expected_binding_revision] = expected_binding_revision
182
+ resp = post(
183
+ "#{REGISTER_PATH}/#{computer_id}/attach-credentials", json: body,
184
+ op: "attach-credentials", headers: { "Idempotency-Key" => idempotency_key }
185
+ )
186
+ case resp.status
187
+ when 200
188
+ parsed = parse_json(resp)
189
+ begin
190
+ committed_revision = Integer(parsed["binding_revision"])
191
+ rescue ArgumentError, TypeError
192
+ committed_revision = -1
193
+ end
194
+ if committed_revision <= expected_binding_revision.to_i
195
+ raise AttachCredentialsError.new(
196
+ "attach-credentials response missing an advancing binding revision",
197
+ status: 200, body: resp.body, request_id: request_id(resp), op: http_op(resp)
198
+ )
199
+ end
200
+ # An advancing revision means Portal committed. Return the complete
201
+ # receipt to Computer, which records that revision before validating
202
+ # the remaining v3 fields.
203
+ parsed
204
+ when 404
205
+ raise UnknownComputerError.new(
206
+ error_message(resp, "unknown, deleted, or cross-project computer_id #{computer_id}"),
207
+ **error_attributes(resp)
208
+ )
209
+ when 412
210
+ problem = parse_json(resp)
211
+ raise BindingRevisionConflictError.new(
212
+ binding_conflict_message(
213
+ error_message(resp, "binding revision changed; reload and adopt the winning binding"),
214
+ problem["current_binding_revision"], problem["current_sandbox_id"]
215
+ ),
216
+ current_binding_revision: problem["current_binding_revision"],
217
+ current_runtime_id: problem["current_sandbox_id"],
218
+ **error_attributes(resp)
219
+ )
220
+ else
221
+ raise_generic(resp, "attach-credentials mint")
222
+ end
223
+ end
224
+
225
+ def inspect
226
+ "#<PineSandbox::AttachCredentialsSource host=#{@host.inspect} [pk_ redacted]>"
227
+ end
228
+ alias_method :to_s, :inspect
229
+
230
+ private
231
+
232
+ def normalize_location(value)
233
+ unless value.is_a?(Hash)
234
+ raise ArgumentError, "location must be a Hash with country: or mode: \"direct\""
235
+ end
236
+ normalized = value.transform_keys(&:to_s)
237
+ return { mode: "direct" } if normalized == { "mode" => "direct" }
238
+ unless normalized.keys == [ "country" ] &&
239
+ normalized["country"].is_a?(String) &&
240
+ normalized["country"].match?(/\A[A-Z]{2}\z/)
241
+ raise ArgumentError, "location must contain an ISO alpha-2 country or mode: \"direct\", exclusively"
242
+ end
243
+ { country: normalized.fetch("country") }
244
+ end
245
+
246
+ def response_location?(value)
247
+ value.is_a?(Hash) && value["country"].is_a?(String) &&
248
+ value["country"].match?(/\A[A-Z]{2}\z/)
249
+ end
250
+
251
+ def raise_location_error(resp)
252
+ if resp.status == 403
253
+ raise ProjectAccessDenied.new(
254
+ error_message(resp, "project or key may not discover locations"),
255
+ **error_attributes(resp)
256
+ )
257
+ end
258
+ message = error_message(resp, "portal location discovery failed (#{resp.status})")
259
+ if resp.status == 401
260
+ message = "invalid project client key (unknown or revoked pk_): #{message}"
261
+ end
262
+ raise LocationDiscoveryError.new(
263
+ message,
264
+ **error_attributes(resp)
265
+ )
266
+ end
267
+
268
+ # 403 is the one status broken out of the AttachCredentialsError family — the
269
+ # project/key/computer may not mint now (ProjectAccessDenied). A bad key (401), a rate
270
+ # limit (429), and server errors all stay AttachCredentialsError (with #status) so a
271
+ # `rescue AttachCredentialsError` around the attach call keeps catching them; callers
272
+ # distinguish a bad key by #status == 401.
273
+ def raise_generic(resp, label,
274
+ forbidden: "project or key may not mint attach credentials (scope, project status, or computer status)")
275
+ if resp.status == 403
276
+ raise ProjectAccessDenied.new(
277
+ error_message(resp, forbidden),
278
+ **error_attributes(resp)
279
+ )
280
+ end
281
+ default =
282
+ case resp.status
283
+ when 401 then "invalid or unknown project client key"
284
+ when 429 then "portal rate-limited the #{label}"
285
+ else "portal #{label} failed (#{resp.status})"
286
+ end
287
+ raise AttachCredentialsError.new(error_message(resp, default), **error_attributes(resp))
288
+ end
289
+
290
+ # "<METHOD> <path>" off the Faraday response env — WHICH portal operation failed (the
291
+ # path carries the computer_id for the computer-scoped mints, so it names WHICH Computer).
292
+ # nil when the env/url is unavailable (a diagnostic detail must never mask the real error).
293
+ def http_op(resp)
294
+ env = resp.respond_to?(:env) ? resp.env : nil
295
+ return nil unless env && env.method
296
+
297
+ url = env.url
298
+ path = url.respond_to?(:path) ? url.path : url.to_s
299
+ "#{env.method.to_s.upcase} #{path}"
300
+ rescue StandardError
301
+ nil
302
+ end
303
+
304
+ def post(path, json:, op:, headers: {})
305
+ request(:post, path, op: op) do |req|
306
+ req.headers["Authorization"] = "Bearer #{@api_key}"
307
+ req.headers["Accept"] = "application/json"
308
+ req.headers["Content-Type"] = "application/json"
309
+ headers.each { |key, value| req.headers[key] = value }
310
+ req.body = json.to_json
311
+ end
312
+ end
313
+
314
+ def get(path, op:)
315
+ request(:get, path, op: op) do |req|
316
+ req.headers["Authorization"] = "Bearer #{@api_key}"
317
+ req.headers["Accept"] = "application/json"
318
+ end
319
+ end
320
+
321
+ def delete(path, op:)
322
+ request(:delete, path, op: op) do |req|
323
+ req.headers["Authorization"] = "Bearer #{@api_key}"
324
+ req.headers["Accept"] = "application/json"
325
+ end
326
+ end
327
+
328
+ def request(method, path, op:)
329
+ connection.public_send(method, path) { |req| yield req }
330
+ rescue Faraday::TimeoutError => e
331
+ raise PineSandbox::TimeoutError,
332
+ PineSandbox.with_context(
333
+ "portal #{op} call timed out: #{e.message}",
334
+ op: "#{method.to_s.upcase} #{path.split('?').first}"
335
+ )
336
+ rescue Faraday::ConnectionFailed => e
337
+ raise PineSandbox::ConnectionError,
338
+ PineSandbox.with_context(
339
+ "portal #{op} connection failed: #{e.message}",
340
+ op: "#{method.to_s.upcase} #{path.split('?').first}"
341
+ )
342
+ end
343
+
344
+ def request_id(resp)
345
+ resp.headers["x-request-id"] || resp.headers["X-Request-Id"]
346
+ end
347
+
348
+ # The attach revision is a fence against two live attaches of one
349
+ # Computer, so the SDK never retries past it; the message says what to do.
350
+ def binding_conflict_message(message, current_revision, current_runtime_id)
351
+ return message unless current_revision.is_a?(Integer)
352
+
353
+ holder = current_runtime_id.to_s.empty? ? "" : " (runtime #{current_runtime_id})"
354
+ "#{message}. Another start changed the Computer binding#{holder}. " \
355
+ "Do not retry automatically. Check the current runtime before starting again."
356
+ end
357
+
358
+ def error_message(resp, default)
359
+ body = parse_json(resp)
360
+ msg = body["detail"] || body["message"] || body["error"]
361
+ msg.to_s.empty? ? default : msg.to_s
362
+ rescue StandardError
363
+ default
364
+ end
365
+
366
+ def error_attributes(resp)
367
+ problem = parse_json(resp)
368
+ body_request_id = problem["request_id"].to_s
369
+ {
370
+ status: resp.status, body: resp.body,
371
+ request_id: body_request_id.empty? ? request_id(resp) : body_request_id,
372
+ op: http_op(resp), problem_type: problem["type"], reason: problem["reason"]
373
+ }
374
+ end
375
+
376
+ def parse_json(resp)
377
+ return resp.body if resp.body.is_a?(Hash)
378
+ return {} if resp.body.to_s.empty?
379
+
380
+ JSON.parse(resp.body)
381
+ rescue JSON::ParserError
382
+ {}
383
+ end
384
+
385
+ def connection
386
+ @connection ||= Zone.faraday(scheme: @protocol, host: @host, gateway_url: @gateway_url) do |f|
387
+ f.options.open_timeout = @open_timeout
388
+ f.options.timeout = @request_timeout
389
+ end
390
+ end
391
+ end
392
+ end
@@ -0,0 +1,96 @@
1
+ require_relative "errors"
2
+
3
+ module PineSandbox
4
+ # Maps a streaming authoring run's terminal Result (the last event of
5
+ # /v2/sessions/{name}/skills/author/{id}/events) into the {LearnResult} /
6
+ # {TeachResult} return shapes of the blocking Session#learn / Session#teach.
7
+ #
8
+ # The terminal Result's `terminal_reason` is one of the AUTHOR_TERMINAL_*
9
+ # values below; the optional `author` object carries the typed
10
+ # authoring outcome.
11
+ module AuthorTerminalTranslator
12
+ AUTHOR_TERMINAL_DRAFT_REGISTERED = "draft_registered".freeze
13
+ AUTHOR_TERMINAL_CLARIFICATION_REQUEST = "clarification_requested".freeze
14
+ AUTHOR_TERMINAL_NOT_REUSABLE = "not_reusable".freeze
15
+ AUTHOR_TERMINAL_REGISTRATION_FAILED = "registration_failed".freeze
16
+ AUTHOR_TERMINAL_CANCELED = "canceled".freeze
17
+ AUTHOR_TERMINAL_FAILED = "failed".freeze
18
+ # Terminal reason when the Computer's services restarted while the run
19
+ # was in flight. Surfaced to the caller as an Error so they can retry —
20
+ # the authoring itself didn't fail; the run got interrupted.
21
+ AUTHOR_TERMINAL_COORDINATOR_RESTARTED = "coordinator_restarted".freeze
22
+
23
+ module_function
24
+
25
+ # to_learn_result translates the terminal event to the legacy /learn body.
26
+ # Self-Learn has no clarification round (an ambiguous run reports false +
27
+ # the caller runs Teach instead), so clarification_requested folds into
28
+ # learned:false here.
29
+ def to_learn_result(terminal)
30
+ raise PineSandbox::Error, "author run produced no terminal event" if terminal.nil?
31
+
32
+ result = terminal["payload"] || {}
33
+ reason = result["terminal_reason"]
34
+ summary = result["summary"]
35
+ author = result["author"].is_a?(Hash) ? result["author"] : {}
36
+
37
+ case reason
38
+ when AUTHOR_TERMINAL_DRAFT_REGISTERED
39
+ draft = author["draft"]
40
+ raise PineSandbox::Error, "author run draft_registered without draft metadata" if draft.nil?
41
+ { "learned" => true, "draft" => draft }
42
+ when AUTHOR_TERMINAL_CLARIFICATION_REQUEST
43
+ { "learned" => false, "reason" => "the run was too ambiguous to author a reliable skill — teach it instead" }
44
+ when AUTHOR_TERMINAL_NOT_REUSABLE
45
+ { "learned" => false, "reason" => summary || "the navigation is not a reusable skill" }
46
+ when AUTHOR_TERMINAL_REGISTRATION_FAILED, AUTHOR_TERMINAL_CANCELED, AUTHOR_TERMINAL_FAILED, "storage_exhausted"
47
+ raise PineSandbox::Error, "author run #{reason}: #{summary}"
48
+ when AUTHOR_TERMINAL_COORDINATOR_RESTARTED
49
+ raise PineSandbox::Error,
50
+ "author run interrupted because the Computer's services restarted — retry the learn call"
51
+ else
52
+ raise PineSandbox::Error, "unknown author terminal_reason: #{reason}"
53
+ end
54
+ end
55
+
56
+ # to_teach_result translates the terminal event to the legacy /teach body.
57
+ # Teach DOES support clarification — the legacy contract carries the
58
+ # questions and the handoff_id on `needs_clarification: true`. The handoff_id
59
+ # comes from the 202 echo (passed in) since the legacy /teach response also
60
+ # echoed it on both success and clarification.
61
+ def to_teach_result(terminal, handoff_id:)
62
+ raise PineSandbox::Error, "author run produced no terminal event" if terminal.nil?
63
+
64
+ result = terminal["payload"] || {}
65
+ reason = result["terminal_reason"]
66
+ summary = result["summary"]
67
+ author = result["author"].is_a?(Hash) ? result["author"] : {}
68
+
69
+ case reason
70
+ when AUTHOR_TERMINAL_DRAFT_REGISTERED
71
+ draft = author["draft"]
72
+ raise PineSandbox::Error, "author run draft_registered without draft metadata" if draft.nil?
73
+ { "taught" => true, "handoff_id" => handoff_id, "draft" => draft }
74
+ when AUTHOR_TERMINAL_CLARIFICATION_REQUEST
75
+ questions = author["questions"] || []
76
+ echoed_handoff = author["handoff_id"] || handoff_id
77
+ {
78
+ "taught" => false,
79
+ "needs_clarification" => true,
80
+ "questions" => questions,
81
+ "handoff_id" => echoed_handoff
82
+ }
83
+ when AUTHOR_TERMINAL_NOT_REUSABLE
84
+ { "taught" => false, "reason" => summary || "the demonstration is not a reusable skill" }
85
+ when AUTHOR_TERMINAL_REGISTRATION_FAILED, AUTHOR_TERMINAL_CANCELED, AUTHOR_TERMINAL_FAILED, "storage_exhausted"
86
+ raise PineSandbox::Error, "author run #{reason}: #{summary}"
87
+ when AUTHOR_TERMINAL_COORDINATOR_RESTARTED
88
+ raise PineSandbox::Error,
89
+ "author run interrupted because the Computer's services restarted — retry the teach call"
90
+ else
91
+ raise PineSandbox::Error, "unknown author terminal_reason: #{reason}"
92
+ end
93
+ end
94
+
95
+ end
96
+ end
@@ -0,0 +1,54 @@
1
+ require "hpke"
2
+ require "openssl"
3
+
4
+ module PineSandbox
5
+ # BindHPKE seals the bind payload (computer_key + broker_grant) to the
6
+ # Computer's per-process ephemeral X25519 public key using RFC 9180
7
+ # HPKE in this fixed suite:
8
+ #
9
+ # KEM = DHKEM(X25519, HKDF-SHA256) (0x0020)
10
+ # KDF = HKDF-SHA256 (0x0001)
11
+ # AEAD = ChaCha20-Poly1305 (0x0003)
12
+ #
13
+ # The wire form is `enc(32 bytes) || aead_ciphertext`.
14
+ module BindHPKE
15
+ module_function
16
+
17
+ # info binds the sealed payload to the receiving process identity so a
18
+ # ciphertext sealed for one runtime/boot cannot be opened by another.
19
+ # MUST byte-match the receiver's info string.
20
+ #
21
+ # @param pod_uid [String]
22
+ # @param coord_boot_id [String]
23
+ # @return [String] ASCII info string.
24
+ def info(pod_uid, coord_boot_id)
25
+ "pine.bind.v1|#{pod_uid}|#{coord_boot_id}"
26
+ end
27
+
28
+ # restore_info is the two-round v3 restore's round-2 domain: the
29
+ # integrator-unwrapped component secrets sealed back to the same runtime
30
+ # key. Same suite, DISTINCT domain — a bind ciphertext can never be
31
+ # replayed as a secrets payload or vice versa. MUST byte-match the
32
+ # receiver's restore info string.
33
+ def restore_info(pod_uid, coord_boot_id)
34
+ "pine.bind.restore.v1|#{pod_uid}|#{coord_boot_id}"
35
+ end
36
+
37
+ # seal encrypts plaintext to the recipient's raw 32-byte X25519 public
38
+ # key. The AAD is fixed empty (the portal attach path carries no nonce —
39
+ # payload-to-runtime binding lives in the info string).
40
+ #
41
+ # @param recipient_pub_raw [String] 32 raw bytes (NOT base64).
42
+ # @param plaintext [String] bytes to seal.
43
+ # @param info [String] context string from #info.
44
+ # @return [String] HPKE wire bytes: enc(32) || ciphertext.
45
+ def seal(recipient_pub_raw, plaintext, info)
46
+ pk_r = OpenSSL::PKey.new_raw_public_key("X25519", recipient_pub_raw)
47
+ hpke = HPKE.new(HPKE::DHKEM_X25519_HKDF_SHA256, HPKE::HKDF_SHA256, HPKE::CHACHA20_POLY1305)
48
+ res = hpke.setup_base_s(pk_r, info.b)
49
+ # hpke gem arg order is (aad, plaintext) — AAD first; ours is empty.
50
+ ct = res[:context_s].seal("", plaintext)
51
+ res[:enc] + ct
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,43 @@
1
+ require_relative "state_hpke"
2
+
3
+ module PineSandbox
4
+ # CaptureKeypair is the integrator's asymmetric state-encryption identity
5
+ # (asymmetric component envelope v3): state is sealed to +pk+ on the platform —
6
+ # which never holds +sk+ — and restoring it requires this SDK to unwrap
7
+ # the per-component secrets during the bind (the two-round restore).
8
+ # PERSIST both halves; retain SUPERSEDED generations until their state has
9
+ # been re-sealed (a restore of pre-rotation state needs the old sk).
10
+ class CaptureKeypair
11
+ attr_reader :generation, :pk, :sk
12
+
13
+ # @param generation [Integer] monotonic keypair generation (the portal
14
+ # records a per-Computer floor; a rotation submits generation+1).
15
+ # @param pk [String] 32 raw X25519 public-key bytes.
16
+ # @param sk [String] 32 raw X25519 private-key bytes.
17
+ def initialize(generation:, pk:, sk:)
18
+ raise ArgumentError, "capture keypair generation must be positive" unless generation.is_a?(Integer) && generation.positive?
19
+ raise ArgumentError, "capture keypair halves must be 32 bytes" unless pk.to_s.bytesize == 32 && sk.to_s.bytesize == 32
20
+
21
+ @generation = generation
22
+ @pk = pk.dup.freeze
23
+ @sk = sk.dup.freeze
24
+ end
25
+
26
+ # Mint a fresh capture keypair offline.
27
+ def self.generate(generation:)
28
+ pk, sk = StateHPKE.generate_keypair
29
+ new(generation: generation, pk: pk, sk: sk)
30
+ end
31
+
32
+ # The canonical public-key fingerprint (hex sha256) the platform binds
33
+ # into every envelope sealed to this keypair.
34
+ def fingerprint
35
+ StateHPKE.fingerprint(@pk)
36
+ end
37
+
38
+ def inspect
39
+ "#<PineSandbox::CaptureKeypair generation=#{@generation} pk=#{fingerprint[0, 16]} sk=[redacted]>"
40
+ end
41
+ alias_method :to_s, :inspect
42
+ end
43
+ end