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,341 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
require "json"
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "clock"
|
|
6
|
+
require_relative "operations"
|
|
7
|
+
require_relative "runtime_details"
|
|
8
|
+
require_relative "spec_version_middleware"
|
|
9
|
+
|
|
10
|
+
module PineSandbox
|
|
11
|
+
# The project-scoped control plane: create / inspect / destroy a Computer's pod at
|
|
12
|
+
# https://api.<zone>, authenticated with a project JWS (Bearer) from the
|
|
13
|
+
# ControlTokenSource. Spec-version rides the shared middleware. Contract:
|
|
14
|
+
# specs/computer-api.yaml lifecycle routes (root, no /v1).
|
|
15
|
+
# @api private
|
|
16
|
+
class ControlPlane
|
|
17
|
+
Runtime = Struct.new(:id, :image, :status, :metadata, :entrypoint,
|
|
18
|
+
:expires_at, :created_at, :operation_id,
|
|
19
|
+
:retryable, :reason, :message, :etag, :lifecycle, :runtime_revision, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
# How often a keyed create the server answered busy (503
|
|
22
|
+
# COMPUTER_CREATE_BUSY) is repeated before that answer is raised.
|
|
23
|
+
MAX_BUSY_REPEATS = 10
|
|
24
|
+
BUSY_CODE = "COMPUTER_CREATE_BUSY".freeze
|
|
25
|
+
|
|
26
|
+
# max_transient_attempts: how many times a replay-safe request (per the
|
|
27
|
+
# public operation table) is sent on a transient transport failure
|
|
28
|
+
# (connection reset / timeout). clock drives deadlines, backoff and jitter;
|
|
29
|
+
# sleeper, when given, replaces only its waits (tests).
|
|
30
|
+
attr_reader :clock
|
|
31
|
+
|
|
32
|
+
def initialize(control_host:, token_source:, protocol: "https", spec_version: 1,
|
|
33
|
+
open_timeout: 5, request_timeout: 30, connection: nil,
|
|
34
|
+
max_transient_attempts: RetryPolicy::TRANSIENT_ATTEMPTS, sleeper: nil, gateway_url: nil,
|
|
35
|
+
clock: PineSandbox::SYSTEM_CLOCK)
|
|
36
|
+
@control_host = control_host.to_s
|
|
37
|
+
@token_source = token_source
|
|
38
|
+
@protocol = protocol
|
|
39
|
+
@gateway_url = gateway_url
|
|
40
|
+
@spec_version = spec_version
|
|
41
|
+
@open_timeout = open_timeout
|
|
42
|
+
@request_timeout = request_timeout
|
|
43
|
+
@connection = connection
|
|
44
|
+
@max_transient_attempts = [max_transient_attempts.to_i, 1].max
|
|
45
|
+
@clock = clock
|
|
46
|
+
@sleeper = sleeper || ->(seconds) { @clock.sleep(seconds) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Internal adapter for the public Computer contract. No generic sandbox
|
|
50
|
+
# create, image selection or task dispatch is available through this client.
|
|
51
|
+
def create_computer(body:, request_timeout: nil)
|
|
52
|
+
# A create ID permits identical retries without extending runtime expiry.
|
|
53
|
+
keyed = !!body[:create_id]
|
|
54
|
+
deadline = monotonic_now + request_timeout if request_timeout
|
|
55
|
+
busy_repeats = 0
|
|
56
|
+
loop do
|
|
57
|
+
resp = authed(:post, "computer-sandboxes", retry_transient: keyed && replay_safe?("createComputer"),
|
|
58
|
+
request_timeout: deadline && remaining_timeout(deadline)) do |req|
|
|
59
|
+
req.headers["Content-Type"] = "application/json"
|
|
60
|
+
req.body = JSON.generate(body)
|
|
61
|
+
end
|
|
62
|
+
# The server queues a burst of a project's creates only so far and
|
|
63
|
+
# asks the rest to come back. A keyed create repeats safely, so it
|
|
64
|
+
# waits and repeats while its deadline allows.
|
|
65
|
+
wait = busy_wait(resp) if keyed && busy_repeats < MAX_BUSY_REPEATS
|
|
66
|
+
return to_sandbox!(resp, ok: [200, 201, 202]) if wait.nil? || (deadline && wait >= deadline - monotonic_now)
|
|
67
|
+
|
|
68
|
+
busy_repeats += 1
|
|
69
|
+
@sleeper.call(wait)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def stop(sandbox_id, operation_id:, request_timeout: nil)
|
|
74
|
+
resp = authed(:post, "sandboxes/#{sandbox_id}/stop", retry_transient: replay_safe?("stopComputerRuntime"),
|
|
75
|
+
request_timeout: request_timeout) do |req|
|
|
76
|
+
req.headers["Content-Type"] = "application/json"
|
|
77
|
+
req.body = JSON.generate(id: operation_id)
|
|
78
|
+
end
|
|
79
|
+
raise_for_status!(resp) unless [202, 404].include?(resp.status)
|
|
80
|
+
true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def set_idle_timeout(sandbox_id, seconds:, etag:)
|
|
84
|
+
raise BindError, "Server did not provide a conditional lifecycle observation" unless etag
|
|
85
|
+
|
|
86
|
+
resp = authed(:put, "sandboxes/#{sandbox_id}/idle-timeout", retry_transient: replay_safe?("setComputerIdleTimeout")) do |req|
|
|
87
|
+
req.headers["Content-Type"] = "application/json"
|
|
88
|
+
req.headers["If-Match"] = etag
|
|
89
|
+
req.body = JSON.generate(seconds: seconds)
|
|
90
|
+
end
|
|
91
|
+
to_sandbox!(resp, ok: [200])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# GET /sandboxes/{id}
|
|
95
|
+
def get(sandbox_id, request_timeout: nil)
|
|
96
|
+
resp = authed(:get, "sandboxes/#{sandbox_id}", retry_transient: replay_safe?("getComputer"), request_timeout: request_timeout)
|
|
97
|
+
to_sandbox!(resp, ok: [200])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Reuse the status ETag across bounded waits. Any server can answer the
|
|
101
|
+
# wait; the client keeps no operation state.
|
|
102
|
+
def wait_for_change(sandbox_id, previous:, request_timeout:, interval: 2)
|
|
103
|
+
started = monotonic_now
|
|
104
|
+
deadline = started + request_timeout
|
|
105
|
+
begin
|
|
106
|
+
resp = authed(:get, "sandboxes/#{sandbox_id}", retry_transient: replay_safe?("getComputer"),
|
|
107
|
+
request_timeout: [request_timeout, 35].min) do |req|
|
|
108
|
+
req.headers["If-None-Match"] = previous.etag if previous.etag
|
|
109
|
+
end
|
|
110
|
+
current = resp.status == 304 ? previous : to_sandbox!(resp, ok: [200])
|
|
111
|
+
rescue PineSandbox::ConnectionError, PineSandbox::TimeoutError, PineSandbox::ServerError => error
|
|
112
|
+
raise if error.is_a?(PineSandbox::ServerError) && ![502, 503, 504].include?(error.status)
|
|
113
|
+
|
|
114
|
+
# An observation failure changes no lifecycle state.
|
|
115
|
+
remaining_timeout(deadline)
|
|
116
|
+
current = previous
|
|
117
|
+
end
|
|
118
|
+
# Pace only quick, unchanged responses or failures. Time already spent
|
|
119
|
+
# waiting on the server counts; changed state never incurs this delay.
|
|
120
|
+
if current.status == previous.status && (current.etag.nil? || current.etag == previous.etag)
|
|
121
|
+
delay = interval - (monotonic_now - started)
|
|
122
|
+
@sleeper.call([delay, remaining_timeout(deadline)].min) if delay.positive?
|
|
123
|
+
end
|
|
124
|
+
current
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# DELETE /sandboxes/{id} — idempotent (404 = already gone), so it is sent
|
|
128
|
+
# again after a transport fault, conditional or not.
|
|
129
|
+
def destroy(sandbox_id, request_timeout: nil, if_match: nil)
|
|
130
|
+
resp = authed(:delete, "sandboxes/#{sandbox_id}", retry_transient: replay_safe?("destroyComputer"),
|
|
131
|
+
request_timeout: request_timeout) do |req|
|
|
132
|
+
req.headers["If-Match"] = if_match if if_match
|
|
133
|
+
end
|
|
134
|
+
raise_for_status!(resp) unless [200, 202, 204, 404].include?(resp.status)
|
|
135
|
+
true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# These methods report acceptance. RuntimeHandle waits for completion.
|
|
139
|
+
# Keep the boolean result for integrations using older Docker servers.
|
|
140
|
+
# Neither is replay-safe, so a transport fault raises instead of resending.
|
|
141
|
+
def pause(sandbox_id, request_timeout: nil)
|
|
142
|
+
resp = authed(:post, "sandboxes/#{sandbox_id}/pause", retry_transient: replay_safe?("pauseComputer"),
|
|
143
|
+
request_timeout: request_timeout)
|
|
144
|
+
raise_for_status!(resp) unless [200, 202, 204, 409].include?(resp.status)
|
|
145
|
+
resp.status != 409
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def resume(sandbox_id, request_timeout: nil)
|
|
149
|
+
resp = authed(:post, "sandboxes/#{sandbox_id}/resume", retry_transient: replay_safe?("resumeComputer"),
|
|
150
|
+
request_timeout: request_timeout)
|
|
151
|
+
raise_for_status!(resp) unless [200, 202, 204, 409].include?(resp.status)
|
|
152
|
+
resp.status != 409
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def replay_safe?(operation_id) = Operations.fetch(operation_id).replay_safe?
|
|
158
|
+
|
|
159
|
+
# Bearer the project JWS; on a 401 force ONE token refresh + retry once (the JWS
|
|
160
|
+
# likely expired). A 401 means the request was rejected, not applied — so retrying
|
|
161
|
+
# is safe even for create. When retry_transient is set (idempotent reads + keyed
|
|
162
|
+
# creates — see the call sites), a transient transport failure (connection reset /
|
|
163
|
+
# timeout) is also re-sent with bounded backoff. Legacy unkeyed creates
|
|
164
|
+
# remain single-shot on transport failure.
|
|
165
|
+
def authed(method, path, retry_transient: false, request_timeout: nil, &block)
|
|
166
|
+
attempt = 0
|
|
167
|
+
deadline = monotonic_now + request_timeout if request_timeout
|
|
168
|
+
begin
|
|
169
|
+
remaining_timeout(deadline)
|
|
170
|
+
resp = send_request(method, path, @token_source.token, deadline: deadline, &block)
|
|
171
|
+
return resp unless resp.status == 401
|
|
172
|
+
|
|
173
|
+
remaining_timeout(deadline)
|
|
174
|
+
send_request(method, path, @token_source.token(force_refresh: true), deadline: deadline, &block)
|
|
175
|
+
rescue PineSandbox::ConnectionError, PineSandbox::TimeoutError
|
|
176
|
+
attempt += 1
|
|
177
|
+
raise unless retry_transient && attempt < @max_transient_attempts
|
|
178
|
+
delay = transient_backoff(attempt)
|
|
179
|
+
delay = [delay, remaining_timeout(deadline)].min if deadline
|
|
180
|
+
@sleeper.call(delay)
|
|
181
|
+
retry
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Exponential backoff with small jitter, capped — for transient transport retries.
|
|
186
|
+
def transient_backoff(attempt)
|
|
187
|
+
RetryPolicy.transient_backoff(attempt, @clock)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def send_request(method, path, token, deadline: nil, &block)
|
|
191
|
+
remaining = remaining_timeout(deadline)
|
|
192
|
+
connection.public_send(method, path) do |req|
|
|
193
|
+
if remaining
|
|
194
|
+
req.options.timeout = remaining
|
|
195
|
+
req.options.open_timeout = [@open_timeout, remaining].min
|
|
196
|
+
end
|
|
197
|
+
req.headers["Authorization"] = "Bearer #{token}"
|
|
198
|
+
req.headers["Accept"] = "application/json"
|
|
199
|
+
block&.call(req)
|
|
200
|
+
end
|
|
201
|
+
rescue Faraday::TimeoutError => e
|
|
202
|
+
raise PineSandbox::TimeoutError,
|
|
203
|
+
PineSandbox.with_context("control plane request timed out: #{e.message}",
|
|
204
|
+
op: "#{method.upcase} /#{path.to_s.split("?").first}")
|
|
205
|
+
rescue Faraday::ConnectionFailed => e
|
|
206
|
+
raise PineSandbox::ConnectionError,
|
|
207
|
+
PineSandbox.with_context("control plane request failed: #{e.message}",
|
|
208
|
+
op: "#{method.upcase} /#{path.to_s.split("?").first}")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def remaining_timeout(deadline)
|
|
212
|
+
return unless deadline
|
|
213
|
+
|
|
214
|
+
remaining = deadline - monotonic_now
|
|
215
|
+
raise PineSandbox::TimeoutError, "control plane request deadline exceeded" if remaining <= 0
|
|
216
|
+
|
|
217
|
+
remaining
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def monotonic_now
|
|
221
|
+
@clock.monotonic
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def to_sandbox!(resp, ok:)
|
|
225
|
+
raise_for_status!(resp) unless ok.include?(resp.status)
|
|
226
|
+
payload = parse_json(resp)
|
|
227
|
+
Runtime.new(id: payload["id"], image: payload["image"], metadata: payload["metadata"] || {},
|
|
228
|
+
entrypoint: payload["entrypoint"], status: runtime_state(payload.dig("status", "state")),
|
|
229
|
+
expires_at: runtime_time(payload["expiresAt"]), created_at: runtime_time(payload["createdAt"]),
|
|
230
|
+
lifecycle: RuntimeLifetime.from_payload(payload["lifecycle"]),
|
|
231
|
+
runtime_revision: payload["runtime_revision"],
|
|
232
|
+
operation_id: payload.dig("status", "operation_id"),
|
|
233
|
+
retryable: payload.dig("status", "retryable"),
|
|
234
|
+
reason: payload.dig("status", "reason"), message: payload.dig("status", "message"), etag: resp.headers["etag"])
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# "Running" → :running, "PendingResume" → :pending_resume.
|
|
238
|
+
def runtime_state(state)
|
|
239
|
+
state&.to_s&.gsub(/([a-z\d])([A-Z])/, '\1_\2')&.downcase&.to_sym
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def runtime_time(value) = value.nil? || value.is_a?(Time) ? value : Time.iso8601(value)
|
|
243
|
+
|
|
244
|
+
def raise_for_status!(resp)
|
|
245
|
+
body = parse_json(resp)
|
|
246
|
+
error_code = body.is_a?(Hash) ? body["code"] : nil
|
|
247
|
+
klass =
|
|
248
|
+
case resp.status
|
|
249
|
+
when 400 then PineSandbox::BadRequestError
|
|
250
|
+
when 401 then PineSandbox::UnauthorizedError
|
|
251
|
+
when 403 then PineSandbox::ForbiddenError
|
|
252
|
+
when 404 then PineSandbox::NotFoundError
|
|
253
|
+
when 409, 412 then PineSandbox::ConflictError
|
|
254
|
+
when 422 then PineSandbox::UnprocessableEntityError
|
|
255
|
+
when 429
|
|
256
|
+
case error_code
|
|
257
|
+
when PineSandbox::CapacityExceededError::CODE then PineSandbox::CapacityExceededError
|
|
258
|
+
when PineSandbox::CreateRateLimitedError::CODE then PineSandbox::CreateRateLimitedError
|
|
259
|
+
else PineSandbox::ApiError
|
|
260
|
+
end
|
|
261
|
+
when 500..599 then PineSandbox::ServerError
|
|
262
|
+
else PineSandbox::ApiError
|
|
263
|
+
end
|
|
264
|
+
# Control-plane errors are {code,message}, not problem+json, so they never carry
|
|
265
|
+
# request_id in the body — but the gateway still stamps X-Request-Id on every
|
|
266
|
+
# response. Capture it PLUS the operation ("<METHOD> /sandboxes/..." — WHICH operation,
|
|
267
|
+
# with the sandbox id in the path where the call is sandbox-scoped) into
|
|
268
|
+
# problem_extensions, so ApiError#build_message renders the resource-first context and
|
|
269
|
+
# #request_id is populated. No standalone sandbox_id here — this is the control plane;
|
|
270
|
+
# the op path already carries the id for get/destroy/pause/resume.
|
|
271
|
+
rid = resp.headers["x-request-id"] || resp.headers["X-Request-Id"]
|
|
272
|
+
op = op_for(resp)
|
|
273
|
+
ext = {}
|
|
274
|
+
ext["op"] = op if op && !op.to_s.empty?
|
|
275
|
+
ext["request_id"] = rid unless rid.to_s.empty?
|
|
276
|
+
fields = { status: resp.status, error_code: error_code, error_message: status_message(resp),
|
|
277
|
+
response_body: resp.body.to_s, problem_extensions: (ext.empty? ? nil : ext) }
|
|
278
|
+
error = klass.new(**fields)
|
|
279
|
+
error.retry_after = retry_after(resp)
|
|
280
|
+
raise error
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Seconds to wait before repeating a create the server answered busy
|
|
284
|
+
# (503 COMPUTER_CREATE_BUSY); nil for any other answer.
|
|
285
|
+
def busy_wait(resp)
|
|
286
|
+
return unless resp.status == 503
|
|
287
|
+
|
|
288
|
+
body = parse_json(resp)
|
|
289
|
+
return unless body.is_a?(Hash) && body["code"] == BUSY_CODE
|
|
290
|
+
|
|
291
|
+
retry_after(resp) || 1
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Retry-After in whole seconds, the only form the lifecycle server sends;
|
|
295
|
+
# nil when absent or not a positive integer.
|
|
296
|
+
def retry_after(resp)
|
|
297
|
+
value = resp.headers["retry-after"].to_s.strip
|
|
298
|
+
seconds = value.match?(/\A\d+\z/) ? value.to_i : 0
|
|
299
|
+
seconds.positive? ? seconds : nil
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# "<METHOD> <path>" off the Faraday response env — WHICH control-plane operation failed.
|
|
303
|
+
# nil when the env/url is unavailable (a diagnostic detail must never mask the real error).
|
|
304
|
+
def op_for(resp)
|
|
305
|
+
env = resp.respond_to?(:env) ? resp.env : nil
|
|
306
|
+
return nil unless env && env.method
|
|
307
|
+
|
|
308
|
+
url = env.url
|
|
309
|
+
path = url.respond_to?(:path) ? url.path : url.to_s
|
|
310
|
+
"#{env.method.to_s.upcase} #{path}"
|
|
311
|
+
rescue StandardError
|
|
312
|
+
nil
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Control-plane errors are {code,message}; bind/coord errors are RFC-9457
|
|
316
|
+
# problem+json {title,detail}. Surface whichever is present.
|
|
317
|
+
def status_message(resp)
|
|
318
|
+
body = parse_json(resp)
|
|
319
|
+
body["message"] || body["detail"] || body["title"] || body["error"] || "control plane error (HTTP #{resp.status})"
|
|
320
|
+
rescue StandardError
|
|
321
|
+
"control plane error (HTTP #{resp.status})"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def parse_json(resp)
|
|
325
|
+
return resp.body if resp.body.is_a?(Hash)
|
|
326
|
+
return {} if resp.body.to_s.empty?
|
|
327
|
+
|
|
328
|
+
JSON.parse(resp.body)
|
|
329
|
+
rescue JSON::ParserError
|
|
330
|
+
{}
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def connection
|
|
334
|
+
@connection ||= Zone.faraday(scheme: @protocol, host: @control_host, gateway_url: @gateway_url) do |f|
|
|
335
|
+
f.use PineSandbox::SpecVersionMiddleware, supported_major: @spec_version
|
|
336
|
+
f.options.open_timeout = @open_timeout
|
|
337
|
+
f.options.timeout = @request_timeout
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module PineSandbox
|
|
4
|
+
# A session's control state and its ETag, returned by Session#control_state,
|
|
5
|
+
# #update_control, #take_control and #release_control. It is the Hash those
|
|
6
|
+
# methods have always returned — `{ state: <ControlStateV1 Hash>, etag: }`,
|
|
7
|
+
# with `state` string-keyed as the wire sends it — with readers for the
|
|
8
|
+
# fields. Pass #etag as `if_match:` on the next update.
|
|
9
|
+
class ControlState < Hash
|
|
10
|
+
def self.wrap(state:, etag:)
|
|
11
|
+
new.update(state: state, etag: etag)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def state = self[:state]
|
|
15
|
+
def etag = self[:etag]
|
|
16
|
+
|
|
17
|
+
# Who drives the session: "human", "agent" or "locked".
|
|
18
|
+
def controller = field("controller")
|
|
19
|
+
def epoch = field("epoch")
|
|
20
|
+
def session_name = field("session_name")
|
|
21
|
+
def idle_paused = field("idle_paused")
|
|
22
|
+
def idle_deadline = field("idle_deadline")
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def field(name)
|
|
27
|
+
body = state
|
|
28
|
+
body.is_a?(Hash) ? body[name] : nil
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
require "base64"
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module PineSandbox
|
|
8
|
+
# Exchanges a project client key (`pk_…`) at the portal's `POST /v1/control-token`
|
|
9
|
+
# for a short-TTL EdDSA project JWS, and caches it until just before expiry.
|
|
10
|
+
# Freshness is derived from the token's own `exp` claim when present; the
|
|
11
|
+
# response expiry is only a fallback. The JWS is what the control plane
|
|
12
|
+
# (`api.<zone>`) accepts as `Authorization: Bearer`.
|
|
13
|
+
#
|
|
14
|
+
# Thread-safe with single-flight refresh (one mint in flight; concurrent callers
|
|
15
|
+
# reuse it). `429`/`5xx` are retried with bounded jittered backoff; a
|
|
16
|
+
# `Retry-After` is a floor on that wait, and one longer than the backoff cap
|
|
17
|
+
# ends the mint at once with the error carrying the server's wait
|
|
18
|
+
# (`#retry_after`) instead of retrying early. `401`/`403` are terminal. The `pk_` and the minted JWS never
|
|
19
|
+
# appear in `#inspect`. The clock, sleeper and jitter source are injectable for tests.
|
|
20
|
+
class ControlTokenSource
|
|
21
|
+
PATH = "/v1/control-token"
|
|
22
|
+
DEFAULT_SKEW_SECONDS = 60 # refresh this long before exp (mirrors the portal's iat/nbf back-date)
|
|
23
|
+
DEFAULT_MAX_ATTEMPTS = 4
|
|
24
|
+
MAX_BACKOFF_SECONDS = 10.0
|
|
25
|
+
|
|
26
|
+
def initialize(api_key:, host:, protocol: "https",
|
|
27
|
+
clock: -> { Time.now }, sleeper: ->(s) { Kernel.sleep(s) }, random: -> { Kernel.rand },
|
|
28
|
+
skew: DEFAULT_SKEW_SECONDS, max_attempts: DEFAULT_MAX_ATTEMPTS,
|
|
29
|
+
open_timeout: 5, request_timeout: 30, connection: nil, gateway_url: nil)
|
|
30
|
+
raise ArgumentError, "api_key required (a pk_ client key)" if api_key.to_s.empty?
|
|
31
|
+
|
|
32
|
+
@api_key = api_key.to_s
|
|
33
|
+
@host = host.to_s
|
|
34
|
+
@protocol = protocol
|
|
35
|
+
@gateway_url = gateway_url
|
|
36
|
+
@clock = clock
|
|
37
|
+
@sleeper = sleeper
|
|
38
|
+
@random = random
|
|
39
|
+
@skew = skew.to_i
|
|
40
|
+
@max_attempts = [max_attempts.to_i, 1].max
|
|
41
|
+
@open_timeout = open_timeout
|
|
42
|
+
@request_timeout = request_timeout
|
|
43
|
+
@connection = connection
|
|
44
|
+
@mutex = Mutex.new
|
|
45
|
+
@token = nil
|
|
46
|
+
@expires_at = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The current project JWS, minting/refreshing as needed. `force_refresh: true`
|
|
50
|
+
# bypasses the cache (used after a control-plane 401).
|
|
51
|
+
def token(force_refresh: false)
|
|
52
|
+
cached = @token
|
|
53
|
+
return cached if cached && !force_refresh && fresh?(@clock.call)
|
|
54
|
+
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
return @token if @token && !force_refresh && fresh?(@clock.call)
|
|
57
|
+
|
|
58
|
+
mint!
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def inspect
|
|
63
|
+
"#<PineSandbox::ControlTokenSource host=#{@host.inspect} cached=#{!@token.nil?} [pk_+token redacted]>"
|
|
64
|
+
end
|
|
65
|
+
alias_method :to_s, :inspect
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def fresh?(now)
|
|
70
|
+
# @expires_at already reflects the token's own exp claim (compute_expiry prefers it
|
|
71
|
+
# over the response metadata at mint time), so freshness reads the cached value
|
|
72
|
+
# rather than re-parsing the JWS on every call.
|
|
73
|
+
@expires_at && now < (@expires_at - @skew)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def mint!
|
|
77
|
+
@max_attempts.times do |i|
|
|
78
|
+
last = (i + 1 >= @max_attempts)
|
|
79
|
+
begin
|
|
80
|
+
resp = post_mint
|
|
81
|
+
rescue PineSandbox::ConnectionError, PineSandbox::TimeoutError
|
|
82
|
+
# Transient (connection reset / timeout). Minting a token has no side
|
|
83
|
+
# effect, so it is always safe to retry with backoff.
|
|
84
|
+
raise if last
|
|
85
|
+
@sleeper.call(backoff(i + 1, nil))
|
|
86
|
+
next
|
|
87
|
+
end
|
|
88
|
+
case resp.status
|
|
89
|
+
when 200
|
|
90
|
+
return apply_success(resp)
|
|
91
|
+
when 401
|
|
92
|
+
raise InvalidClientKey.new(error_message(resp, "invalid or unknown project client key"),
|
|
93
|
+
status: 401, body: resp.body, request_id: request_id(resp), op: mint_op)
|
|
94
|
+
when 403
|
|
95
|
+
raise InsufficientScope.new(error_message(resp, "this key may not mint control-plane tokens (needs pk_session/pk_admin)"),
|
|
96
|
+
status: 403, body: resp.body, request_id: request_id(resp), op: mint_op)
|
|
97
|
+
when 429
|
|
98
|
+
retry_after = parse_retry_after(resp)
|
|
99
|
+
if last || beyond_cap?(retry_after)
|
|
100
|
+
raise RateLimited.new("portal rate-limited the control-token mint", status: 429,
|
|
101
|
+
body: resp.body, request_id: request_id(resp), op: mint_op, retry_after: retry_after)
|
|
102
|
+
end
|
|
103
|
+
@sleeper.call(backoff(i + 1, retry_after))
|
|
104
|
+
when 500..599
|
|
105
|
+
retry_after = parse_retry_after(resp)
|
|
106
|
+
if last || beyond_cap?(retry_after)
|
|
107
|
+
raise ControlTokenError.new("portal error minting control token", status: resp.status,
|
|
108
|
+
body: resp.body, request_id: request_id(resp), op: mint_op,
|
|
109
|
+
retry_after: retry_after)
|
|
110
|
+
end
|
|
111
|
+
@sleeper.call(backoff(i + 1, retry_after))
|
|
112
|
+
else
|
|
113
|
+
raise ControlTokenError.new("unexpected portal response minting control token",
|
|
114
|
+
status: resp.status, body: resp.body, request_id: request_id(resp), op: mint_op)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def post_mint
|
|
120
|
+
connection.post(PATH) do |req|
|
|
121
|
+
req.headers["Authorization"] = "Bearer #{@api_key}"
|
|
122
|
+
req.headers["Accept"] = "application/json"
|
|
123
|
+
end
|
|
124
|
+
rescue Faraday::TimeoutError => e
|
|
125
|
+
raise PineSandbox::TimeoutError,
|
|
126
|
+
PineSandbox.with_context("control-token mint timed out: #{e.message}", op: "POST #{PATH}")
|
|
127
|
+
rescue Faraday::ConnectionFailed => e
|
|
128
|
+
raise PineSandbox::ConnectionError,
|
|
129
|
+
PineSandbox.with_context("control-token mint connection failed: #{e.message}", op: "POST #{PATH}")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def apply_success(resp)
|
|
133
|
+
body = parse_json(resp)
|
|
134
|
+
token = body["token"]
|
|
135
|
+
raise ControlTokenError.new("mint response missing token", status: 200, body: resp.body, op: mint_op) if token.to_s.empty?
|
|
136
|
+
|
|
137
|
+
@token = token
|
|
138
|
+
@expires_at = compute_expiry(token, body)
|
|
139
|
+
@token
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The known op for every mint: POST the control-token endpoint. A 200-but-unusable
|
|
143
|
+
# response (missing token / bad expiry) still names WHICH portal call this was.
|
|
144
|
+
def mint_op
|
|
145
|
+
"POST #{PATH}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Prefer the token's own exp claim; fall back to response metadata only for
|
|
149
|
+
# older/non-JWT tests. A response with neither is unusable.
|
|
150
|
+
def compute_expiry(token, body)
|
|
151
|
+
token_exp = token_expiry(token)
|
|
152
|
+
return token_exp if token_exp
|
|
153
|
+
|
|
154
|
+
if body.key?("expires_in")
|
|
155
|
+
return @clock.call + body["expires_in"].to_i
|
|
156
|
+
elsif (abs = body["expires_at"])
|
|
157
|
+
begin
|
|
158
|
+
return Time.iso8601(abs.to_s)
|
|
159
|
+
rescue ArgumentError
|
|
160
|
+
raise ControlTokenError.new("mint response has an unparseable expires_at", status: 200, body: body, op: mint_op)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
raise ControlTokenError.new("mint response missing expiry (expires_in / expires_at)", status: 200, body: body, op: mint_op)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def token_expiry(token)
|
|
167
|
+
parts = token.to_s.split(".")
|
|
168
|
+
return nil unless parts.length >= 2
|
|
169
|
+
|
|
170
|
+
payload = parts[1]
|
|
171
|
+
padding = "=" * ((4 - payload.length % 4) % 4)
|
|
172
|
+
claims = JSON.parse(Base64.urlsafe_decode64(payload + padding))
|
|
173
|
+
# A JWS payload should be a JSON object; anything else (a bare null/true/number) has
|
|
174
|
+
# no exp — fall back to the response expiry instead of raising NoMethodError on it.
|
|
175
|
+
return nil unless claims.is_a?(Hash)
|
|
176
|
+
|
|
177
|
+
raw = claims["exp"]
|
|
178
|
+
raw.nil? ? nil : Time.at(Integer(raw))
|
|
179
|
+
rescue ArgumentError, JSON::ParserError, TypeError
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The jittered exponential backoff, capped, never shorter than Retry-After.
|
|
184
|
+
def backoff(attempt, retry_after)
|
|
185
|
+
[[0.2 * (2**(attempt - 1)) + @random.call * 0.1, MAX_BACKOFF_SECONDS].min, retry_after.to_f].max
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A Retry-After longer than the backoff cap: the server asks for a wait
|
|
189
|
+
# the mint won't make, so the caller gets the error and the wait.
|
|
190
|
+
def beyond_cap?(retry_after)
|
|
191
|
+
!retry_after.nil? && retry_after > MAX_BACKOFF_SECONDS
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def parse_retry_after(resp)
|
|
195
|
+
raw = resp.headers["retry-after"] || resp.headers["Retry-After"]
|
|
196
|
+
return nil if raw.to_s.strip.empty?
|
|
197
|
+
|
|
198
|
+
seconds = Integer(raw)
|
|
199
|
+
seconds.positive? ? seconds : nil
|
|
200
|
+
rescue ArgumentError
|
|
201
|
+
nil # an HTTP-date Retry-After is allowed but rare here; fall back to backoff
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def request_id(resp)
|
|
205
|
+
resp.headers["x-request-id"] || resp.headers["X-Request-Id"]
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def error_message(resp, default)
|
|
209
|
+
body = parse_json(resp)
|
|
210
|
+
msg = body["message"] || body["error"]
|
|
211
|
+
msg.to_s.empty? ? default : msg.to_s
|
|
212
|
+
rescue StandardError
|
|
213
|
+
default
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def parse_json(resp)
|
|
217
|
+
return resp.body if resp.body.is_a?(Hash)
|
|
218
|
+
return {} if resp.body.to_s.empty?
|
|
219
|
+
|
|
220
|
+
JSON.parse(resp.body)
|
|
221
|
+
rescue JSON::ParserError
|
|
222
|
+
{}
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def connection
|
|
226
|
+
@connection ||= Zone.faraday(scheme: @protocol, host: @host, gateway_url: @gateway_url) do |f|
|
|
227
|
+
f.options.open_timeout = @open_timeout
|
|
228
|
+
f.options.timeout = @request_timeout
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module PineSandbox
|
|
2
|
+
# The browser-safe connection envelope — the ONLY thing a server hands a
|
|
3
|
+
# browser to drive a Computer's desktop. It carries NO `ct_`/`ps_`/project-JWS:
|
|
4
|
+
# only the data-plane base URL (`computer_host`, a full URI
|
|
5
|
+
# `https://<id>.computer.<zone>` so the web SDK derives the desktop ws/wss
|
|
6
|
+
# scheme from it), the visible session's name, the pinned spec major, and a
|
|
7
|
+
# freshly-minted, short-lived `dt_` desktop token (+ its expiry).
|
|
8
|
+
# Mirrors `DelegatedConnection` in the Computer API reference.
|
|
9
|
+
#
|
|
10
|
+
# `dt_` is a short-TTL connect ticket (re-minted per (re)connect) and view-scoped; drive is the control lease,
|
|
11
|
+
# not this token. Because the browser cannot mint a `dt_` (that needs `ct_`,
|
|
12
|
+
# server-held), the integrator re-mints server-side and refreshes the envelope
|
|
13
|
+
# before each (re)connect — the web SDK calls an integrator-supplied refresh
|
|
14
|
+
# callback on your server.
|
|
15
|
+
class DelegatedConnection
|
|
16
|
+
# The SDK's pinned Computer API major version. The web SDK pins the same
|
|
17
|
+
# major and fails fast on a mismatch.
|
|
18
|
+
SPEC_VERSION = 1
|
|
19
|
+
|
|
20
|
+
attr_reader :computer_host, :session_name, :spec_version, :desktop_token, :desktop_token_expires_at
|
|
21
|
+
|
|
22
|
+
def initialize(computer_host:, session_name:, desktop_token:, desktop_token_expires_at:,
|
|
23
|
+
spec_version: SPEC_VERSION)
|
|
24
|
+
@computer_host = computer_host
|
|
25
|
+
@session_name = session_name
|
|
26
|
+
@spec_version = spec_version
|
|
27
|
+
@desktop_token = desktop_token
|
|
28
|
+
@desktop_token_expires_at = desktop_token_expires_at
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The wire shape the web SDK consumes. Session is reduced to its name —
|
|
32
|
+
# never the `Session` object (which holds the `ps_` token). The portal
|
|
33
|
+
# serializes this with `to_h` (the SDK stays framework-free); it may add its
|
|
34
|
+
# own scoped refresh endpoint to the payload before sending to the browser.
|
|
35
|
+
def to_h
|
|
36
|
+
{
|
|
37
|
+
computer_host: @computer_host,
|
|
38
|
+
session: { name: @session_name },
|
|
39
|
+
spec_version: @spec_version,
|
|
40
|
+
transports: {
|
|
41
|
+
desktop_token: { token: @desktop_token, expires_at: @desktop_token_expires_at }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|