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