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,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
|