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,1374 @@
1
+ require "faraday"
2
+ require "json"
3
+ require "securerandom"
4
+ require "uri"
5
+ require_relative "../clock"
6
+ require_relative "../errors"
7
+ require_relative "../operations"
8
+ require_relative "../control_state"
9
+ require_relative "../control_events"
10
+ require_relative "../sse_frame_parser"
11
+ require_relative "../exec_stream_parser"
12
+ require_relative "../response_handler"
13
+
14
+ module PineSandbox
15
+ module Adapters
16
+ # @api private — the SDK's HTTP client for the Computer API, reached at the
17
+ # runtime's own host `https://<runtime_id>.computer.<zone>`. The subdomain
18
+ # selects the runtime and the PATH selects the role (the API at root);
19
+ # the host is port-less and header-less — no routing header.
20
+ #
21
+ # The bearer goes in `Authorization: Bearer`. The Computer gates every
22
+ # route on the `ct_`/`ps_` bearer, so the data-plane host being publicly
23
+ # routable is fine — an unbound caller has no token. Each request uses
24
+ # either the computer token (`ct_*`, admin scope) or a session token
25
+ # (`ps_*`, one session); callers pass the right token per call — this
26
+ # adapter does not pick.
27
+ #
28
+ # A request that is replay-safe in the public operation table is sent
29
+ # again after a transport fault (Operations, RetryPolicy); any other is
30
+ # sent once. A 401 for a Computer token raises TokenRejectedError; a 401
31
+ # for a session token is the plain UnauthorizedError.
32
+ class CoordinatorAdapter
33
+ include PineSandbox::ResponseHandler
34
+
35
+ DEFAULT_HTTP_TIMEOUT = 30
36
+ DEFAULT_OPEN_TIMEOUT = 5
37
+ # A non-2xx stream answer keeps this much of its body for the error.
38
+ STREAM_ERROR_BODY_CAP = 64 * 1024
39
+
40
+ # The runtime is reached at its own host `https://<runtime_id>.computer.<zone>`
41
+ # (derived from `zone`), port-less and header-less: the subdomain selects the
42
+ # runtime and the PATH selects the role. `zone` is a PineSandbox::Zone.
43
+ # `host` is the per-runtime data-plane host `<runtime_id>.computer.<zone>`
44
+ # (scheme-less). `scheme` is http for a .localhost zone (local-dev loop),
45
+ # https otherwise. `clock` paces retries and reconnects.
46
+ attr_reader :runtime_id, :host, :scheme, :clock
47
+
48
+ # discovery_token is the Computer token that reads the capability
49
+ # manifest when a request's own token, such as a session's, cannot. An
50
+ # adopted binding passes it; #bind records the one it issues.
51
+ # `sandbox_id:` is the deprecated spelling of `runtime_id:`.
52
+ def initialize(zone:, runtime_id: nil, sandbox_id: nil, discovery_token: nil, clock: PineSandbox::SYSTEM_CLOCK)
53
+ @runtime_id = runtime_id || sandbox_id
54
+ raise ArgumentError, "runtime_id required" if @runtime_id.to_s.empty?
55
+
56
+ @host = zone.data_host(@runtime_id)
57
+ @scheme = zone.http_scheme # http for a .localhost zone (local-dev loop), https otherwise
58
+ @discovery_token = discovery_token
59
+ @clock = clock
60
+ # This adapter's own connections dial as the zone says (#connection);
61
+ # base_url stays the public host, since a delegated connection hands
62
+ # it to a browser.
63
+ @zone = zone
64
+ end
65
+
66
+ # Deprecated: use #runtime_id.
67
+ def sandbox_id
68
+ PineSandbox.deprecated_name("CoordinatorAdapter#sandbox_id", "#runtime_id")
69
+ @runtime_id
70
+ end
71
+
72
+ # The full data-plane origin `<scheme>://<runtime_id>.computer.<zone>` — the
73
+ # DelegatedConnection's `computer_host` is a complete URI per the Computer API,
74
+ # so the web SDK derives the desktop's ws/wss scheme from it (no guessing).
75
+ def base_url
76
+ "#{@scheme}://#{@host}"
77
+ end
78
+
79
+ # Bind handshake. Posts the portal-issued bind_token JWS to
80
+ # /v1/coord/bind and returns the runtime's `ct_` from the response.
81
+ #
82
+ # Auth model: bind_token's Ed25519 signature IS the auth — no
83
+ # bearer token is presented here (the bind endpoint refuses
84
+ # tokens; `apply_token(req, nil)` short-circuits in `request`).
85
+ #
86
+ # @param bind_token [String] compact JWS Ed25519 token minted by the
87
+ # portal attach-credentials endpoint (AttachCredentialsSource).
88
+ # @param pod_uid [String] echoed runtime identity (must match the token).
89
+ # @param coord_boot_id [String] echoed process boot id.
90
+ # @param ciphertext [String] base64url HPKE wire (enc || aead_ct).
91
+ # @return [Hash] { "computer_token" => ct_, "epoch" => Integer }.
92
+ # key_assertion rides every v2 (asymmetric) round; restore_secrets is
93
+ # the two-round restore's commit payload. A 200 carrying
94
+ # "restore_challenge" (no "computer_token") means the caller must
95
+ # unwrap the per-component secrets and re-bind with restore_secrets.
96
+ def bind(bind_token:, pod_uid:, coord_boot_id:, ciphertext:,
97
+ key_assertion: nil, restore_secrets: nil)
98
+ body = {
99
+ pod_uid: pod_uid,
100
+ coord_boot_id: coord_boot_id,
101
+ ciphertext: ciphertext,
102
+ bind_token: bind_token
103
+ }
104
+ body[:key_assertion] = key_assertion if key_assertion
105
+ body[:restore_secrets] = restore_secrets if restore_secrets
106
+ resp = post("v1/coord/bind", token: nil, json: body)
107
+ @discovery_token = resp.body["computer_token"] if resp.body.is_a?(Hash) && resp.body["computer_token"]
108
+ resp.body
109
+ end
110
+
111
+ # Fetches the Computer's per-process ephemeral HPKE public key and
112
+ # runtime identity. Public route — no token. The SDK seals the bind
113
+ # payload to this key and echoes pod_uid/coord_boot_id back on bind.
114
+ #
115
+ # @return [Hash] { "pod_uid", "coord_boot_id", "ephem_pub_x25519"
116
+ # (base64url 32 bytes), "fetched_at" }.
117
+ def bind_pubkey
118
+ get("v1/coord/bind-pubkey", token: nil).body
119
+ end
120
+
121
+ # Snapshot admin API (ct_ only). Wraps GET /state — the
122
+ # most-recently-persisted snapshot for this Computer. Returns the
123
+ # raw response body (JSON shape — plaintext_v0 or envelope_v1; the
124
+ # SDK doesn't decode either format, the caller decides whether to
125
+ # forward to external storage or pass it back to a future
126
+ # restore-from-bytes flow). On 404 (no snapshot uploaded yet) the
127
+ # method returns nil; on 503 (PINE_STATE_DIR unset on coord) the
128
+ # adapter raises ServiceUnavailableError so callers see "state
129
+ # persistence not enabled on this Computer" rather than a silent nil.
130
+ def get_latest_snapshot(token:)
131
+ get("state", token: token).body
132
+ rescue PineSandbox::NotFoundError
133
+ nil
134
+ end
135
+
136
+ # Triggers a synchronous, durable capture (a checkpoint) of the Computer's
137
+ # state and returns when it is committed. The explicit durability
138
+ # primitive — NOT coupled to stop. Reuses coord's proven single-shot
139
+ # capture path (capture → encrypt → upload → commit witness at the current
140
+ # epoch), content-hash-gated: an unchanged state returns `{skipped:true}`
141
+ # and the prior snapshot stays the latest. Returns the snapshot descriptor
142
+ # (snapshot_id, captured_at, bytes, skipped). ct_ only.
143
+ def capture(token:)
144
+ post("v1/coord/capture", token: token, json: {}).body
145
+ end
146
+
147
+ # Computer-scoped custodial passkey management. All projections are
148
+ # metadata-only; private key material never crosses this adapter.
149
+ def list_passkeys(token:)
150
+ get("v1/passkeys", token: token).body.fetch("credentials", [])
151
+ end
152
+
153
+ def delete_passkey(token:, credential_id:)
154
+ delete("v1/passkeys/#{escape(credential_id)}", token: token)
155
+ true
156
+ end
157
+
158
+ def list_passkey_ceremonies(token:)
159
+ get("v1/passkeys/ceremonies", token: token).body.fetch("ceremonies", [])
160
+ end
161
+
162
+ def decide_passkey_ceremony(token:, ceremony_id:, decision:)
163
+ post("v1/passkeys/ceremonies/#{escape(ceremony_id)}", token: token,
164
+ json: { decision: decision })
165
+ true
166
+ end
167
+
168
+ def declare_passkey_intent(token:, name:, rp_id:)
169
+ post("v1/sessions/#{escape(name)}/passkeys/intents", token: token,
170
+ json: { rp_id: rp_id })
171
+ true
172
+ end
173
+
174
+ def list_session_passkey_ceremonies(token:, name:)
175
+ get("v1/sessions/#{escape(name)}/passkeys/ceremonies", token: token)
176
+ .body.fetch("ceremonies", [])
177
+ end
178
+
179
+ def decide_session_passkey_ceremony(token:, name:, ceremony_id:, decision:)
180
+ post("v1/sessions/#{escape(name)}/passkeys/ceremonies/#{escape(ceremony_id)}",
181
+ token: token, json: { decision: decision })
182
+ true
183
+ end
184
+
185
+ # Orphan tabs: browser tabs no session owns (ct_ only).
186
+ def list_orphan_tabs(token:)
187
+ get("orphans", token: token).body
188
+ end
189
+
190
+ def close_orphan_tab(token:, target_id:)
191
+ delete("orphans/#{escape(target_id)}", token: token)
192
+ true
193
+ end
194
+
195
+ # Orphan downloads: browser downloads no session owns (ct_ only).
196
+ def list_orphan_downloads(token:)
197
+ get("downloads/orphans", token: token).body
198
+ end
199
+
200
+ def claim_orphan_download(token:, guid:, session_name:, filename: nil)
201
+ body = { session_name: session_name }
202
+ body[:filename] = filename if filename
203
+ post("downloads/orphans/#{escape(guid)}/claim", token: token, json: body).body
204
+ end
205
+
206
+ def discard_orphan_download(token:, guid:)
207
+ delete("downloads/orphans/#{escape(guid)}", token: token)
208
+ true
209
+ end
210
+
211
+ def create_session(token:, name: nil, label: nil, browser: false, blind: false)
212
+ # Omit :name when blank so the coordinator mints a friendly name; only
213
+ # send :label when the caller set one. Name stays first so the on-wire
214
+ # body order is stable.
215
+ body = {}
216
+ body[:name] = name unless name.nil? || name.to_s.empty?
217
+ body[:browser] = browser
218
+ body[:label] = label unless label.nil? || label.to_s.empty?
219
+ body[:blind] = true if blind
220
+ post("sessions", token: token, json: body)
221
+ .body
222
+ .fetch("session")
223
+ end
224
+
225
+ def get_session(token:, name:)
226
+ get("sessions/#{escape(name)}", token: token).body.fetch("session")
227
+ end
228
+
229
+ def list_sessions(token:)
230
+ get("sessions", token: token).body.fetch("sessions", [])
231
+ end
232
+
233
+ # Destroy the session. Default leaves the session's files on disk so a
234
+ # subsequent create with the same name finds the prior workdir contents.
235
+ # Pass clean: true to delete them too.
236
+ def destroy_session(token:, name:, clean: false)
237
+ path = "sessions/#{escape(name)}"
238
+ path = "#{path}?clean=true" if clean
239
+ delete(path, token: token)
240
+ true
241
+ end
242
+
243
+ # Rebuild the bash terminal after an execd restart left the session
244
+ # with Terminal.Lost=true (coordinator returns 409 terminal_lost on
245
+ # /exec until this is called). The browser plane is preserved across
246
+ # the cycle; same primary_tab_id, window_id, epoch.
247
+ def recreate_terminal(token:, name:)
248
+ post("sessions/#{escape(name)}/terminal/recreate", token: token, json: {})
249
+ true
250
+ end
251
+
252
+ def focus(token:, name:)
253
+ post("sessions/#{escape(name)}/focus", token: token, json: {})
254
+ true
255
+ end
256
+
257
+ def epoch(token:, name:)
258
+ get("sessions/#{escape(name)}/epoch", token: token).body
259
+ end
260
+
261
+ def list_tabs(token:, name:)
262
+ body = get("sessions/#{escape(name)}/tabs", token: token).body
263
+ {
264
+ tabs: body.fetch("tabs", []),
265
+ session_generation: body["session_generation"]
266
+ }
267
+ end
268
+
269
+ def list_files(token:, name:, path: nil, pattern: nil)
270
+ query = {}
271
+ query[:path] = path unless path.nil?
272
+ query[:pattern] = pattern unless pattern.nil?
273
+ route = "v1/sessions/#{escape(name)}/files/list"
274
+ route += "?#{URI.encode_www_form(query)}" unless query.empty?
275
+ get(route, token: token)
276
+ .body
277
+ .fetch("entries", [])
278
+ .map { |entry| Models::FileEntry.from_payload(entry) }
279
+ end
280
+
281
+ def read_file(token:, name:, path:, &block)
282
+ route = "v1/sessions/#{escape(name)}/files?#{URI.encode_www_form(path: path)}"
283
+ return stream_bytes(route, token: token, accept: "application/octet-stream", &block) if block
284
+
285
+ bytes = get(route, token: token, accept: "application/octet-stream").body
286
+ bytes.is_a?(String) ? bytes.b : bytes
287
+ end
288
+
289
+ # PUT /v1/sessions/{name}/files?path=<name> — deposit a material (an agent
290
+ # INPUT) into the session's attachments/ root. `path` is a single filename
291
+ # ("/" and ".." are rejected — no traversal). NB: attachments/ is a SIBLING
292
+ # of the workdir that read_file/list_files serve, so the agent is told about
293
+ # a deposited material rather than finding it through read_file. Returns the parsed 201
294
+ # body: { "root" => "attachments", "relative_path" => ..., "size" => ... }.
295
+ def write_file(token:, name:, path:, content:)
296
+ route = "v1/sessions/#{escape(name)}/files?#{URI.encode_www_form(path: path)}"
297
+ put(route, token: token, body: content.to_s.b, content_type: "application/octet-stream").body
298
+ end
299
+
300
+ # ----- Artifacts: list / read / zip / upload -----
301
+
302
+ # GET /v1/sessions/{name}/artifacts?turn_id= — the session's artifacts
303
+ # (uploads + claimed downloads + every completed turn's durably-captured
304
+ # agent outputs).
305
+ def list_artifacts(token:, name:, turn_id: nil)
306
+ route = "v1/sessions/#{escape(name)}/artifacts"
307
+ route += "?#{URI.encode_www_form(turn_id: turn_id)}" if turn_id
308
+ get(route, token: token)
309
+ .body
310
+ .fetch("artifacts", [])
311
+ .map { |a| Models::Artifact.from_payload(a) }
312
+ end
313
+
314
+ # GET /v1/sessions/{name}/artifacts/{id} — raw artifact bytes.
315
+ def read_artifact(token:, name:, id:, &block)
316
+ route = "v1/sessions/#{escape(name)}/artifacts/#{escape(id)}"
317
+ return stream_bytes(route, token: token, accept: "application/octet-stream", &block) if block
318
+
319
+ bytes = get(route, token: token, accept: "application/octet-stream").body
320
+ bytes.is_a?(String) ? bytes.b : bytes
321
+ end
322
+
323
+ # GET /v1/sessions/{name}/artifacts/zip?turn_id= — a zip of the artifacts.
324
+ def zip_artifacts(token:, name:, turn_id: nil, &block)
325
+ route = "v1/sessions/#{escape(name)}/artifacts/zip"
326
+ route += "?#{URI.encode_www_form(turn_id: turn_id)}" if turn_id
327
+ return stream_bytes(route, token: token, accept: "application/zip", &block) if block
328
+
329
+ bytes = get(route, token: token, accept: "application/zip").body
330
+ bytes.is_a?(String) ? bytes.b : bytes
331
+ end
332
+
333
+ # POST /v1/sessions/{name}/artifacts?filename= — upload bytes as a new
334
+ # artifact (created_by:upload). Returns the created Models::Artifact.
335
+ def upload_artifact(token:, name:, filename:, content:)
336
+ route = "v1/sessions/#{escape(name)}/artifacts?#{URI.encode_www_form(filename: filename)}"
337
+ Models::Artifact.from_payload(
338
+ post(route, token: token, body: content.to_s.b, content_type: "application/octet-stream").body
339
+ )
340
+ end
341
+
342
+ def create_tab(token:, name:, url:, label: nil)
343
+ payload = { url: url }
344
+ payload[:label] = label if label
345
+ post("sessions/#{escape(name)}/tabs", token: token, json: payload).body.fetch("tab")
346
+ end
347
+
348
+ def patch_tab(token:, name:, target_id:, active: nil, label: nil)
349
+ body = {}
350
+ body[:active] = active unless active.nil?
351
+ body[:label] = label unless label.nil?
352
+ patch("sessions/#{escape(name)}/tabs/#{escape(target_id)}", token: token, json: body).body.fetch("tab")
353
+ end
354
+
355
+ def close_tab(token:, name:, target_id:)
356
+ delete("sessions/#{escape(name)}/tabs/#{escape(target_id)}", token: token)
357
+ true
358
+ end
359
+
360
+ # Control surface. All paths under
361
+ # `/v1/sessions/{name}/control[/*]` and `/v1/sessions/{name}/handoffs[/*]`.
362
+
363
+ # GET /v1/sessions/{name}/control. Returns a ControlState: the
364
+ # ControlStateV1 body plus the ETag extracted from response
365
+ # headers; callers use the ETag as `If-Match:` on the next PATCH.
366
+ def get_control(token:, name:)
367
+ resp = get("v1/sessions/#{escape(name)}/control", token: token)
368
+ ControlState.wrap(state: resp.body, etag: resp.headers["etag"])
369
+ end
370
+
371
+ # PATCH /v1/sessions/{name}/control. Returns the same shape as
372
+ # get_control (new state + new ETag). Caller passes typed args:
373
+ # `body` is the parsed JSON shape; `if_match` is required unless
374
+ # `force: true`; `idempotency_key` defaults to a fresh UUID.
375
+ def patch_control(token:, name:, body:, if_match: nil, force: false, idempotency_key: nil)
376
+ idempotency_key ||= SecureRandom.uuid
377
+ path = "v1/sessions/#{escape(name)}/control"
378
+ path += "?force=true" if force
379
+ headers = {}
380
+ headers["If-Match"] = if_match if if_match
381
+ headers["Idempotency-Key"] = idempotency_key if idempotency_key
382
+ resp = patch(path, token: token, json: body, headers: headers)
383
+ ControlState.wrap(state: resp.body, etag: resp.headers["etag"])
384
+ end
385
+
386
+ # POST /v1/sessions/{name}/control/notify. Requires If-Match per
387
+ # the spec (no `force` escape hatch — that's PATCH-only). Accepts
388
+ # `ct_` OR session-matching `ps_`.
389
+ def post_control_notify(token:, name:, reason:, detail: nil, if_match:)
390
+ path = "v1/sessions/#{escape(name)}/control/notify"
391
+ body = { reason: reason }
392
+ body[:detail] = detail if detail
393
+ post(path, token: token, json: body, headers: { "If-Match" => if_match })
394
+ true
395
+ end
396
+
397
+ # POST /v1/sessions/{name}/desktop-token. ct_-only mint of a short-TTL
398
+ # (short-TTL) browser-safe VNC desktop-stream connection token (dt_…). No
399
+ # request body. Returns { token:, expires_at: }. The portal hands the
400
+ # token to the browser as the /vnc/connect WS subprotocol
401
+ # (pine-vnc-token.dt_…); drive is the control lease, not this token.
402
+ def mint_desktop_token(token:, name:)
403
+ resp = post("v1/sessions/#{escape(name)}/desktop-token", token: token)
404
+ { token: resp.body.fetch("token"), expires_at: resp.body["expires_at"] }
405
+ end
406
+
407
+ # GET /v1/sessions/{name}/handoffs?limit=&before=
408
+ # Returns the full response body (the spec HandoffList) with string keys:
409
+ # { "handoffs" => [...], "next_before" => <cursor> } — pass next_before back
410
+ # as `before` to page older (absent = no more pages). String-keyed + the whole
411
+ # body (like #get_handoff) so it validates directly against the HandoffList
412
+ # schema and exposes every field.
413
+ def list_handoffs(token:, name:, limit: nil, before: nil)
414
+ query = []
415
+ query << "limit=#{limit}" if limit
416
+ query << "before=#{escape(before)}" if before
417
+ path = "v1/sessions/#{escape(name)}/handoffs"
418
+ path += "?" + query.join("&") unless query.empty?
419
+ get(path, token: token).body
420
+ end
421
+
422
+ # GET /v1/sessions/{name}/handoffs/{handoff_id}
423
+ def get_handoff(token:, name:, handoff_id:)
424
+ get("v1/sessions/#{escape(name)}/handoffs/#{escape(handoff_id)}", token: token).body
425
+ end
426
+
427
+ # stream_control_events opens ONE connection to GET
428
+ # /v1/sessions/{name}/control/events and yields a typed
429
+ # PineSandbox::ControlEvents::* struct per event. Honors
430
+ # `last_event_id` (sent as the Last-Event-ID header) for resume.
431
+ # Returns the latest event id once the server closes the stream or
432
+ # `stop_when` returns true for a delivered event. Session#control_events
433
+ # (#follow_control_events) reconnects for you.
434
+ def stream_control_events(token:, name:, last_event_id: nil, stop_when: nil, &block)
435
+ raise ArgumentError, "block required" unless block
436
+
437
+ rel = "v1/sessions/#{escape(name)}/control/events"
438
+ latest_id = last_event_id
439
+ catch(:pine_stream_stop) do
440
+ with_transport_norm("GET", rel) do
441
+ open_sse(rel, token: token, last_event_id: last_event_id) do |frame|
442
+ evt = decode_control_frame(frame)
443
+ next if evt.nil?
444
+
445
+ latest_id = evt.id if evt.id
446
+ block.call(evt)
447
+ throw :pine_stream_stop if stop_when&.call(evt)
448
+ end
449
+ end
450
+ end
451
+ latest_id
452
+ end
453
+
454
+ # Follows the session's control events across connections: a dropped
455
+ # stream reopens from the last event id, waiting RetryPolicy's stream
456
+ # backoff between attempts. After STREAM_RECONNECT_BUDGET reconnects in
457
+ # a row that delivered no event it raises StreamLostError; an error
458
+ # answer (401/403/404/…) raises at once. Returns the latest event id
459
+ # when `stop_when` returns true, or — when `max_reconnects` caps the
460
+ # reconnects — once that many have been used (a transport fault then
461
+ # raises instead).
462
+ def follow_control_events(token:, name:, last_event_id: nil, stop_when: nil, max_reconnects: nil, &block)
463
+ raise ArgumentError, "block required" unless block
464
+
465
+ rel = "v1/sessions/#{escape(name)}/control/events"
466
+ cursor = follow_events(
467
+ op: "GET /#{rel}", cursor: last_event_id, stop_when: stop_when, max_reconnects: max_reconnects,
468
+ open: ->(from, &on_frame) { open_sse(rel, token: token, last_event_id: from, &on_frame) },
469
+ decode: method(:decode_control_frame), &block
470
+ )
471
+ Integer(cursor, 10, exception: false) || cursor
472
+ end
473
+
474
+ # Issues an SSE-streamed exec. Yields each parsed event hash to the
475
+ # block; returns an ExecResult struct with `stdout`, `stderr`, and
476
+ # `exit_code` assembled from the stream — stdout/stderr accumulated
477
+ # from their respective events, exit_code read from the command error
478
+ # or inferred from clean completion. Caller's block can raise to abort early;
479
+ # the underlying connection then closes.
480
+ #
481
+ # POST /sessions/{name}/exec expects `{"command": "...", "cwd": "...",
482
+ # "timeout_ms": ...}` (NOT `code:`).
483
+ def exec(token:, name:, command:, cwd: nil, timeout_ms: nil, &block)
484
+ body = { command: command }
485
+ body[:cwd] = cwd if cwd
486
+ body[:timeout_ms] = timeout_ms if timeout_ms
487
+
488
+ stdout = +""
489
+ stderr = +""
490
+ exit_code = nil
491
+ complete = false
492
+ errored = false
493
+ error_msg = nil
494
+ parser = PineSandbox::ExecStreamParser.new do |event|
495
+ case event.type
496
+ # execd strips trailing \n from each line before wrapping it
497
+ # into an event. Re-append so stdout/stderr recompose with line
498
+ # boundaries — matters for regex like /^FOO=/ and for tests
499
+ # that iterate r.stdout.lines. Callers that want a raw trimmed
500
+ # value should use r.stdout.chomp.
501
+ when :stdout then stdout << event.text.to_s << "\n"
502
+ when :stderr then stderr << event.text.to_s << "\n"
503
+ when :execution_complete then complete = true
504
+ when :error
505
+ errored = true
506
+ if event.error.is_a?(Hash)
507
+ # The session-shell wire carries a nonzero command exit in
508
+ # error.evalue, not in a top-level exit_code.
509
+ value = event.error["evalue"]
510
+ exit_code = Integer(value, 10, exception: false) if value.is_a?(String)
511
+ traceback = Array(event.error["traceback"]).grep(String)
512
+ error_msg = traceback.join("\n") unless traceback.empty?
513
+ if error_msg.nil?
514
+ parts = [event.error["ename"], value].grep(String).reject(&:empty?)
515
+ error_msg = parts.join(": ") unless parts.empty?
516
+ end
517
+ elsif event.error.is_a?(String)
518
+ error_msg = event.error
519
+ end
520
+ error_msg ||= event.data.is_a?(Hash) ? (event.data["message"] || event.data[:message] || event.text) : event.text
521
+ end
522
+ # Take exit_code from ANY event that carries it explicitly.
523
+ if event.data.is_a?(Hash)
524
+ raw = event.data["exit_code"] || event.data[:exit_code] ||
525
+ event.data["exitCode"] || event.data[:exitCode]
526
+ exit_code = raw unless raw.nil?
527
+ end
528
+ block.call(event) if block
529
+ end
530
+ # Clamp the HTTP timeout above timeout_ms so the client waits for
531
+ # the server's own timeout to fire an execution_complete event
532
+ # rather than tearing the TCP stream down mid-stream. Fallback of
533
+ # 60s protects against a hung coordinator when the caller passes
534
+ # no timeout at all.
535
+ http_timeout = timeout_ms ? ((timeout_ms / 1000.0).ceil + 5) : 60
536
+ error_body = +""
537
+ with_transport_norm("POST", "sessions/#{escape(name)}/exec") do
538
+ response = connection.post(route_path("sessions/#{escape(name)}/exec")) do |req|
539
+ apply_token(req, token)
540
+ req.headers["Content-Type"] = "application/json"
541
+ req.headers["Accept"] = "text/event-stream"
542
+ req.body = body.to_json
543
+ req.options.on_data = proc do |chunk, _received, env|
544
+ if env.nil? || env.status.to_i.between?(200, 299)
545
+ parser.parse(chunk)
546
+ else
547
+ keep_error_body(error_body, chunk)
548
+ end
549
+ end
550
+ req.options.timeout = http_timeout
551
+ end
552
+ # Non-2xx (e.g. 409 terminal_lost) is a problem+json body, not a
553
+ # stream; raise it so callers get ConflictError (→ recreate_terminal!)
554
+ # instead of a silent empty ExecResult.
555
+ unless response.success?
556
+ response.env.body = error_body if response.body.to_s.empty?
557
+ ensure_success!(response, token: token)
558
+ end
559
+ end
560
+ parser.finish
561
+ # execd's `execution_complete` frame does NOT carry exit_code on
562
+ # successful runs (only `execution_time` + `timestamp`). Mirror
563
+ # CommandsAdapter#infer_exit_code: if we saw completion and no
564
+ # explicit exit_code, assume 0. If we saw an :error event, surface
565
+ # it (exit_code stays nil unless the error frame carried one).
566
+ if exit_code.nil? && complete && !errored
567
+ exit_code = 0
568
+ end
569
+ ExecResult.new(stdout: stdout, stderr: stderr, exit_code: exit_code,
570
+ error_message: error_msg)
571
+ end
572
+
573
+ # Minimal struct surfaced by #exec. Matches the shape tests expect:
574
+ # `r.exit_code`, `r.stdout`, `r.stderr`. `error_message` is populated
575
+ # when the stream surfaces an explicit :error event (e.g. command
576
+ # not found on execd's side, not the command's own stderr).
577
+ ExecResult = Struct.new(:stdout, :stderr, :exit_code, :error_message,
578
+ keyword_init: true) do
579
+ # The command's error, when the stream reported one (#error_message).
580
+ def error = error_message
581
+ end
582
+
583
+ # ----- Agent track: /v2/sessions/{name}/agent[/*] -----
584
+ # Work is addressed by turn: run and answer return a turn, and the turn
585
+ # read, answer, steer and cancel take its turn_id. Mutations
586
+ # (run/steer/answer/cancel/reset) are ct_-only; reads (task/turns/events)
587
+ # accept ct_ OR session-matching ps_.
588
+
589
+ # idempotency_key names this submission: a run with the same key returns
590
+ # the turn that key admitted instead of starting another.
591
+ def agent_run(token:, name:, goal:, context: nil, skills: nil, constraints: nil, secrets: nil, idempotency_key: nil)
592
+ body = { goal: goal }
593
+ body[:context] = context unless context.nil?
594
+ body[:skills] = skills unless skills.nil?
595
+ body[:constraints] = constraints unless constraints.nil?
596
+ body[:secrets] = secrets unless secrets.nil?
597
+ post("v2/sessions/#{escape(name)}/agent/run", token: token, json: body,
598
+ headers: { "Idempotency-Key" => idempotency_key }).body
599
+ end
600
+
601
+ def agent_steer(token:, name:, turn_id:, text:, secrets: nil)
602
+ body = { turn_id: turn_id, text: text }
603
+ body[:secrets] = secrets unless secrets.nil?
604
+ post("v2/sessions/#{escape(name)}/agent/steer", token: token, json: body).body
605
+ end
606
+
607
+ def agent_answer(token:, name:, turn_id:, answer:, secrets: nil)
608
+ body = { turn_id: turn_id, answer: answer }
609
+ body[:secrets] = secrets unless secrets.nil?
610
+ post("v2/sessions/#{escape(name)}/agent/answer", token: token, json: body).body
611
+ end
612
+
613
+ def agent_cancel(token:, name:, turn_id:)
614
+ post("v2/sessions/#{escape(name)}/agent/cancel", token: token, json: { turn_id: turn_id }).body
615
+ end
616
+
617
+ def agent_reset(token:, name:, expected_task_id:)
618
+ raise ArgumentError, "expected_task_id is required" if expected_task_id.to_s.empty?
619
+
620
+ post(agent_guarded_path(name, "/reset", expected_task_id: expected_task_id), token: token, json: {}).body
621
+ end
622
+
623
+ def agent_task(token:, name:, expected_task_id: nil)
624
+ get(agent_guarded_path(name, "", expected_task_id: expected_task_id), token: token).body
625
+ end
626
+
627
+ # GET .../agent/turns/{turn_id}. The Computer holds the response up to
628
+ # wait_seconds while the turn runs, so the read timeout allows for it.
629
+ def agent_turn(token:, name:, turn_id:, wait_seconds: 0)
630
+ path = "v2/sessions/#{escape(name)}/agent/turns/#{escape(turn_id)}?wait_seconds=#{Integer(wait_seconds)}"
631
+ request(:get, path, token: token, timeout: DEFAULT_HTTP_TIMEOUT + wait_seconds).body
632
+ end
633
+
634
+ # Public history: the retained turns and cursor-addressed activity pages.
635
+ def agent_turns(token:, name:, expected_task_id: nil)
636
+ get(agent_guarded_path(name, "/turns", expected_task_id: expected_task_id), token: token).body
637
+ end
638
+
639
+ def agent_events_page(token:, name:, after: nil, limit: nil, expected_task_id: nil, frames: true)
640
+ q = []
641
+ q << "after=#{Integer(after)}" unless after.nil?
642
+ q << "limit=#{Integer(limit)}" unless limit.nil?
643
+ q << "expected_task_id=#{escape(expected_task_id)}" unless expected_task_id.nil?
644
+ q << "frames=none" unless frames
645
+ path = "v2/sessions/#{escape(name)}/agent/events/page"
646
+ path += "?#{q.join("&")}" unless q.empty?
647
+ get(path, token: token).body
648
+ end
649
+
650
+ # --- Learned skills (Computer Skills) ------------------------------------
651
+
652
+ # GET /v1/skills — the served skills (curated + activated learned).
653
+ def list_skills(token:)
654
+ get("v1/skills", token: token).body.fetch("skills", [])
655
+ end
656
+
657
+ # GET /v1/skills/{name} — one skill's metadata + full SKILL.md body.
658
+ def get_skill(token:, name:)
659
+ get("v1/skills/#{escape(name)}", token: token).body
660
+ end
661
+
662
+ # GET /v1/skills/drafts — pending drafts, each WITH its body (for review).
663
+ def list_skill_drafts(token:)
664
+ get("v1/skills/drafts", token: token).body.fetch("drafts", [])
665
+ end
666
+
667
+ # GET /v1/skills/versions — all visible learned versions (metadata only).
668
+ def list_all_skill_versions(token:)
669
+ get("v1/skills/versions", token: token).body.fetch("versions", [])
670
+ end
671
+
672
+ # GET /v1/skills/{name}/versions — visible versions for one name (metadata only).
673
+ def list_skill_versions(token:, name:)
674
+ get("v1/skills/#{escape(name)}/versions", token: token).body.fetch("versions", [])
675
+ end
676
+
677
+ # GET /v1/skills/{name}/versions/{version} — one version's metadata + SKILL.md body.
678
+ def get_skill_version(token:, name:, version:)
679
+ get("v1/skills/#{escape(name)}/versions/#{escape(version)}", token: token).body
680
+ end
681
+
682
+ # POST /v1/skills/{name}/activate — serve a reviewed draft version.
683
+ def activate_skill(token:, name:, version:)
684
+ post("v1/skills/#{escape(name)}/activate", token: token, json: { version: version }).body
685
+ end
686
+
687
+ # POST /v1/skills/{name}/deactivate — un-serve a skill (the version is kept).
688
+ def deactivate_skill(token:, name:)
689
+ post("v1/skills/#{escape(name)}/deactivate", token: token, json: {}).body
690
+ end
691
+
692
+ # PUT /v1/skills/{name}/enabled — switch one of Pine's standard or feature skills on or
693
+ # off for the agent processes the Computer starts next.
694
+ def set_skill_enabled(token:, name:, enabled:)
695
+ request(:put, "v1/skills/#{escape(name)}/enabled", token: token, json: { enabled: enabled }).body
696
+ end
697
+
698
+ # GET /v1/capabilities — product features for this binding and the Computer's limits.
699
+ def capabilities(token:)
700
+ get("v1/capabilities", token: token).body
701
+ end
702
+
703
+ # Explicit qualification for a binding. No cache, downgrade or mutation.
704
+ # A missing discovery route is an old runtime; the gateway's typed
705
+ # sandbox-not-found response remains a lifecycle failure.
706
+ def require_agent_protocol!(token:)
707
+ manifest = capabilities(token: token)
708
+ raise AgentProtocolUnsupported unless public_agent_supported?(manifest)
709
+
710
+ self
711
+ rescue NotFoundError => e
712
+ raise if e.is_a?(RuntimeGoneError)
713
+
714
+ raise AgentProtocolUnsupported
715
+ end
716
+
717
+ def public_agent_supported?(manifest)
718
+ protocols = manifest["protocols"] if manifest.is_a?(Hash)
719
+ versions = protocols["agent"] if protocols.is_a?(Hash)
720
+ versions.is_a?(Array) && versions.all? { |v| v.is_a?(Integer) && v.positive? } &&
721
+ versions.uniq == versions && versions.include?(2)
722
+ end
723
+ private :public_agent_supported?
724
+
725
+ # DELETE /v1/skills/{name}/versions/{version} — hide a version and un-serve it if active.
726
+ def delete_skill_version(token:, name:, version:)
727
+ delete("v1/skills/#{escape(name)}/versions/#{escape(version)}", token: token).body
728
+ end
729
+
730
+ # POST /v2/sessions/{name}/learn — author a draft from the session's latest finished run.
731
+ # `scaffold:` is an optional seed of the SKILL.md the authoring run continues
732
+ # from (preserved frontmatter / section headers / explicit parameter names).
733
+ # Capped at 32 KiB.
734
+ def learn_skill(token:, name:, scaffold: nil)
735
+ body = {}
736
+ body[:scaffold] = scaffold unless scaffold.nil? || scaffold.empty?
737
+ post("v2/sessions/#{escape(name)}/learn", token: token, json: body).body
738
+ end
739
+
740
+ # POST /v2/sessions/{name}/teach — author a draft from a human demonstration.
741
+ def teach_skill(token:, name:, goal:, handoff_id: nil, clarifications: nil, scaffold: nil)
742
+ body = { goal: goal }
743
+ body[:handoff_id] = handoff_id unless handoff_id.nil?
744
+ body[:clarifications] = clarifications unless clarifications.nil?
745
+ body[:scaffold] = scaffold unless scaffold.nil? || scaffold.empty?
746
+ post("v2/sessions/#{escape(name)}/teach", token: token, json: body).body
747
+ end
748
+
749
+ # POST /v2/sessions/{name}/refine — revise an existing skill version with a
750
+ # plain-language instruction. `name` is the SESSION (path); `skill`/`version`
751
+ # name the version to revise and `guidance` is the instruction. No navigation.
752
+ def refine_skill(token:, name:, skill:, version:, guidance:)
753
+ body = { name: skill, version: version, guidance: guidance }
754
+ post("v2/sessions/#{escape(name)}/refine", token: token, json: body).body
755
+ end
756
+
757
+ # GET /v2/sessions/{name}/skills/author/{author_id}/events — ONE
758
+ # connection to the SSE stream of an in-flight authoring run. Yields each
759
+ # parsed event to the block; tracks `id:` for Last-Event-ID resume. The
760
+ # closing terminal frame ends the response (its Result.terminal_reason is
761
+ # in the AuthorTerminal* vocabulary). Mirrors agent_events.
762
+ def author_events(token:, name:, author_id:, last_event_id: nil, &block)
763
+ raise ArgumentError, "block required" unless block
764
+
765
+ stream_json_events(
766
+ path: "v2/sessions/#{escape(name)}/skills/author/#{escape(author_id)}/events",
767
+ token: token, last_event_id: last_event_id, &block
768
+ )
769
+ end
770
+
771
+ # Follows an authoring run's events across connections until its
772
+ # `result` event (see #follow_control_events for the reconnect budget).
773
+ # Returns the last event id.
774
+ def follow_author_events(token:, name:, author_id:, last_event_id: nil, &block)
775
+ raise ArgumentError, "block required" unless block
776
+
777
+ path = "v2/sessions/#{escape(name)}/skills/author/#{escape(author_id)}/events"
778
+ follow_events(
779
+ op: "GET /#{path}", cursor: last_event_id, terminal: ->(event) { event["type"] == "result" },
780
+ open: ->(from, &on_frame) { open_sse(path, token: token, last_event_id: from, &on_frame) },
781
+ decode: method(:decode_json_frame), &block
782
+ )
783
+ end
784
+
785
+ # POST /v2/sessions/{name}/skills/author/{author_id}/cancel — cancel a
786
+ # running authoring run. Returns the parsed 202 body.
787
+ def cancel_author(token:, name:, author_id:)
788
+ post("v2/sessions/#{escape(name)}/skills/author/#{escape(author_id)}/cancel", token: token, json: {}).body
789
+ end
790
+
791
+ # await_author_terminal subscribes to the bridged author SSE stream and
792
+ # returns the canonical `result` event (its terminal_reason is in the
793
+ # AuthorTerminal* vocabulary).
794
+ # Used by Session#learn / #teach's sync shim to translate the streaming
795
+ # outcome back into the legacy LearnResult / TeachResult shape so existing
796
+ # callers don't see the API shape change. Returns nil if the stream ended
797
+ # without a terminal (unexpected; the broker always finalizes).
798
+ def await_author_terminal(token:, name:, author_id:)
799
+ terminal = nil
800
+ follow_author_events(token: token, name: name, author_id: author_id) do |evt|
801
+ terminal = evt if evt["type"] == "result"
802
+ end
803
+ terminal
804
+ end
805
+
806
+ # POST /v1/sessions/{name}/skills — register a draft directly (a SKILL.md your own agent wrote).
807
+ # `name` is the SESSION; `skill_name` is the skill's kebab-case name (also in its frontmatter).
808
+ def author_skill_draft(token:, name:, skill_name:, skill_md:, reason:, origin: nil, tainted: nil)
809
+ body = { name: skill_name, skill_md: skill_md, reason: reason }
810
+ body[:origin] = origin unless origin.nil?
811
+ body[:tainted] = tainted unless tainted.nil?
812
+ post("v1/sessions/#{escape(name)}/skills", token: token, json: body).body
813
+ end
814
+
815
+ # Streams ONE connection of the session's TaskEvent SSE feed, yielding
816
+ # each parsed event (the `data:` JSON) and tracking `id:` for
817
+ # Last-Event-ID resume. Returns the last seen event id at the end of the
818
+ # stream. A non-2xx is a problem+json body, not a stream — raise it so the
819
+ # caller treats it as terminal, not a clean EOF. frames: false asks the
820
+ # Computer to leave `frame` events out.
821
+ def agent_events(token:, name:, last_event_id: nil, frames: true, expected_task_id: nil, &block)
822
+ raise ArgumentError, "block required" unless block
823
+
824
+ stream_json_events(
825
+ path: agent_guarded_path(name, "/events", expected_task_id: expected_task_id, frames: ("none" unless frames)),
826
+ token: token, last_event_id: last_event_id, &block
827
+ )
828
+ end
829
+
830
+ # Follows the session's TaskEvent feed across connections (see
831
+ # #follow_control_events for the reconnect budget). The feed is
832
+ # continuous: this returns only when the block breaks out.
833
+ def follow_agent_events(token:, name:, last_event_id: nil, frames: true, expected_task_id: nil, &block)
834
+ raise ArgumentError, "block required" unless block
835
+
836
+ path = agent_guarded_path(name, "/events", expected_task_id: expected_task_id, frames: ("none" unless frames))
837
+ follow_events(
838
+ op: "GET /v2/sessions/#{escape(name)}/agent/events", cursor: last_event_id,
839
+ open: ->(from, &on_frame) { open_sse(path, token: token, last_event_id: from, &on_frame) },
840
+ decode: method(:decode_json_frame), &block
841
+ )
842
+ end
843
+
844
+ # ----- Drive track: observe / computer-use / upload_file -----
845
+
846
+ def observe(token:, name:)
847
+ post("v1/sessions/#{escape(name)}/observe", token: token, json: {}).body
848
+ end
849
+
850
+ def computer_use(token:, name:, action:, params: {})
851
+ post("v1/sessions/#{escape(name)}/computer-use", token: token,
852
+ json: { action: action }.merge(params || {})).body
853
+ end
854
+
855
+ def upload_file(token:, name:, selector:, file:)
856
+ post("v1/sessions/#{escape(name)}/upload_file", token: token,
857
+ json: { selector: selector, file: file }).body
858
+ end
859
+
860
+ private
861
+
862
+ def agent_guarded_path(name, suffix, **query)
863
+ path = "v2/sessions/#{escape(name)}/agent#{suffix}"
864
+ query = query.compact
865
+ query.empty? ? path : "#{path}?#{URI.encode_www_form(query)}"
866
+ end
867
+
868
+ # One connection of the agent or author feed — same wire (each frame's
869
+ # data: payload is a JSON object; id: is the resume cursor). Yields each
870
+ # parsed JSON object, tracks + returns the latest event id (the next
871
+ # reconnect cursor), and raises a typed error on a non-2xx (a
872
+ # problem+json body, not a stream — so the caller treats it as terminal,
873
+ # not a clean EOF).
874
+ def stream_json_events(path:, token:, last_event_id:, &block)
875
+ latest = last_event_id
876
+ with_transport_norm("GET", path) do
877
+ open_sse(path, token: token, last_event_id: last_event_id) do |frame|
878
+ # Advance the resume cursor for ANY id-bearing frame BEFORE the
879
+ # empty-data skip, so a reconnect can't replay a frame we've already
880
+ # seen and skipped (an id-bearing keepalive).
881
+ latest = frame.id if frame.id && !frame.id.empty?
882
+ parsed = decode_json_frame(frame)
883
+ block.call(parsed) unless parsed.nil?
884
+ end
885
+ end
886
+ latest
887
+ end
888
+
889
+ # Opens one event-stream GET and yields each SSE frame as it arrives.
890
+ # A transport fault raises the raw Faraday error so the caller decides
891
+ # whether to normalize it or reconnect; a non-2xx raises its typed error
892
+ # (keeping a bounded prefix of the body, so a problem keeps its type and
893
+ # extensions). Returns the response.
894
+ def open_sse(path, token:, last_event_id:)
895
+ parser = ::PineSandbox::SseFrameParser.new
896
+ error_body = +""
897
+ response = streaming_connection.get(route_path(path)) do |req|
898
+ apply_token(req, token)
899
+ req.headers["Accept"] = "text/event-stream"
900
+ req.headers["Last-Event-ID"] = last_event_id.to_s unless last_event_id.to_s.empty?
901
+ req.options.on_data = proc do |chunk, _bytes, env|
902
+ if env.nil? || env.status.to_i.between?(200, 299)
903
+ parser.feed(chunk) { |frame| yield frame }
904
+ else
905
+ keep_error_body(error_body, chunk)
906
+ end
907
+ end
908
+ end
909
+ parser.finish
910
+ unless response.success?
911
+ response.env.body = error_body if response.body.to_s.empty?
912
+ ensure_success!(response, token: token)
913
+ end
914
+ response
915
+ end
916
+
917
+ # Raised through Faraday's read loop in place of an exception from the
918
+ # caller's block, so the block's own error is never mistaken for a
919
+ # dropped connection.
920
+ class BlockError < StandardError
921
+ attr_reader :error
922
+
923
+ def initialize(error)
924
+ @error = error
925
+ super(error.message)
926
+ end
927
+ end
928
+ private_constant :BlockError
929
+
930
+ # The resuming event-stream engine. Each connection opens from the last
931
+ # event id seen; frames advance the cursor before any decode, so a
932
+ # skipped frame is never replayed. A dropped connection (a transport
933
+ # fault or the end of the body) reconnects after RetryPolicy's stream
934
+ # backoff. The budget counts consecutive reconnects that delivered no
935
+ # event, so a healthy feed the server recycles runs indefinitely and a
936
+ # dead one ends with StreamLostError. An error answer raises at once.
937
+ # Returns the cursor when `terminal` or `stop_when` matches an event, or
938
+ # when `max_reconnects` (a caller cap on total reconnects) is used up.
939
+ def follow_events(op:, cursor:, open:, decode:, terminal: nil, stop_when: nil, max_reconnects: nil)
940
+ cursor = cursor.to_s
941
+ failures = 0
942
+ reconnects = 0
943
+ request_id = nil
944
+ catch(:pine_stream_stop) do
945
+ loop do
946
+ delivered = false
947
+ fault = nil
948
+ begin
949
+ response = open.call(cursor) do |frame|
950
+ cursor = frame.id if frame.id && !frame.id.empty?
951
+ next if frame.data.empty?
952
+
953
+ event = decode.call(frame)
954
+ next if event.nil?
955
+
956
+ delivered = true
957
+ begin
958
+ yield event
959
+ rescue StandardError => e
960
+ raise BlockError.new(e)
961
+ end
962
+ throw :pine_stream_stop if terminal&.call(event) || stop_when&.call(event)
963
+ end
964
+ request_id = request_id_header(response) || request_id
965
+ rescue BlockError => e
966
+ raise e.error
967
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Faraday::SSLError => e
968
+ fault = normalize_transport_fault(e, "GET", op.delete_prefix("GET /"))
969
+ end
970
+ failures = 0 if delivered
971
+ reconnects += 1
972
+ if max_reconnects && reconnects > max_reconnects
973
+ raise fault if fault
974
+
975
+ throw :pine_stream_stop
976
+ end
977
+ failures += 1
978
+ if failures > RetryPolicy::STREAM_RECONNECT_BUDGET
979
+ raise StreamLostError.new(op: op, request_id: request_id, cause: fault)
980
+ end
981
+
982
+ @clock.sleep(RetryPolicy.stream_backoff(failures, @clock))
983
+ end
984
+ end
985
+ cursor
986
+ end
987
+
988
+ def decode_json_frame(frame)
989
+ parsed = JSON.parse(frame.data)
990
+ parsed.is_a?(Hash) ? parsed : nil
991
+ rescue JSON::ParserError
992
+ nil
993
+ end
994
+
995
+ def decode_control_frame(frame)
996
+ id = frame.id.to_s.empty? ? nil : Integer(frame.id, 10, exception: false)
997
+ ::PineSandbox::ControlEvents.decode_event(frame.event, id, frame.data)
998
+ end
999
+
1000
+ def keep_error_body(buffer, chunk)
1001
+ remaining = STREAM_ERROR_BODY_CAP - buffer.bytesize
1002
+ buffer << chunk.byteslice(0, remaining) if remaining.positive?
1003
+ end
1004
+
1005
+ # Passes a binary GET's body to the block in chunks as it arrives instead
1006
+ # of buffering it. Only a 2xx body reaches the block: an error answer is
1007
+ # kept (bounded) and raised as the same typed error the buffered read
1008
+ # raises, before any chunk. A transport fault after the first chunk
1009
+ # raises too, so the caller knows the body is incomplete. The default
1010
+ # connection's read timeout bounds each wait for the next chunk.
1011
+ def stream_bytes(path, token:, accept:)
1012
+ error_body = +""
1013
+ with_transport_norm("GET", path) do
1014
+ response = connection.get(route_path(path)) do |req|
1015
+ apply_token(req, token)
1016
+ req.headers["Accept"] = accept
1017
+ req.options.on_data = proc do |chunk, _received, env|
1018
+ if env.status.to_i.between?(200, 299)
1019
+ yield chunk.b unless chunk.empty?
1020
+ else
1021
+ keep_error_body(error_body, chunk)
1022
+ end
1023
+ end
1024
+ end
1025
+ response.env.body = error_body if !response.success? && response.body.to_s.empty?
1026
+ ensure_success!(response, token: token) unless response.success?
1027
+ end
1028
+ nil
1029
+ end
1030
+
1031
+ def get(path, token:, accept: "application/json", headers: nil)
1032
+ request(:get, path, token: token, accept: accept, headers: headers)
1033
+ end
1034
+
1035
+ def post(path, token:, json: nil, body: nil, content_type: nil, headers: nil)
1036
+ request(:post, path, token: token, json: json, body: body, content_type: content_type, headers: headers)
1037
+ end
1038
+
1039
+ def patch(path, token:, json: nil, headers: nil)
1040
+ request(:patch, path, token: token, json: json, headers: headers)
1041
+ end
1042
+
1043
+ # PUT with a raw (non-JSON) body — e.g. octet-stream file uploads. JSON PUTs
1044
+ # would pass `json:` instead; `body:` + `content_type:` send bytes verbatim.
1045
+ def put(path, token:, body: nil, content_type: nil, accept: "application/json", headers: nil)
1046
+ request(:put, path, token: token, body: body, content_type: content_type, accept: accept, headers: headers)
1047
+ end
1048
+
1049
+ def delete(path, token:)
1050
+ request(:delete, path, token: token)
1051
+ end
1052
+
1053
+ # Sends one request. A transport fault (no response) is retried with
1054
+ # RetryPolicy's transient backoff only when the route is a replay-safe
1055
+ # public operation (one whose safety rests on an Idempotency-Key only
1056
+ # when the request carries one); a response of any status is never
1057
+ # resent here.
1058
+ def request(method, path, token:, json: nil, body: nil, content_type: nil, accept: "application/json", headers: nil, timeout: nil)
1059
+ verb = method.to_s.upcase
1060
+ operation = Operations.match(:computer, verb, route_path(path.to_s.split(/[?#]/, 2).first))
1061
+ idempotency_key = headers&.find { |name, _| name.to_s.casecmp?("Idempotency-Key") }&.last
1062
+ attempts = operation&.resendable?(idempotency_key) ? RetryPolicy::TRANSIENT_ATTEMPTS : 1
1063
+ attempt = 0
1064
+ begin
1065
+ attempt += 1
1066
+ response = with_transport_norm(verb, path) do
1067
+ connection.run_request(method, route_path(path), nil, nil) do |req|
1068
+ req.options.timeout = timeout if timeout
1069
+ apply_token(req, token)
1070
+ req.headers["Accept"] = accept
1071
+ if json
1072
+ req.headers["Content-Type"] = "application/json"
1073
+ req.body = json.to_json
1074
+ elsif !body.nil?
1075
+ req.headers["Content-Type"] = content_type || "application/octet-stream"
1076
+ req.body = body
1077
+ end
1078
+ headers&.each { |k, v| req.headers[k] = v if v }
1079
+ end
1080
+ end
1081
+ rescue PineSandbox::TimeoutError, PineSandbox::ConnectionError
1082
+ raise if attempt >= attempts
1083
+
1084
+ @clock.sleep(RetryPolicy.transient_backoff(attempt, @clock))
1085
+ retry
1086
+ end
1087
+ ensure_success!(response, token: token)
1088
+ ParsedResponse.new(response, accept)
1089
+ end
1090
+
1091
+ # Map Faraday transport faults (timeout / connection drop / TLS failure)
1092
+ # to the SDK's typed errors so NO coord call — unary OR streaming — leaks
1093
+ # a raw Faraday class to an integrator (parity with the control-plane /
1094
+ # attach / token adapters). HTTP-status errors stay typed via
1095
+ # ensure_success!; a stop throw is not a Faraday error and passes through
1096
+ # untouched. A transport fault has no response, so the op is built from
1097
+ # method + path — QUERY-STRIPPED (a filename / cursor / selector must
1098
+ # never leak into the message) — and folded resource-first via
1099
+ # context_suffix, matching the response-status paths (which read the
1100
+ # already-stripped env.url.path).
1101
+ def with_transport_norm(method, path)
1102
+ yield
1103
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Faraday::SSLError => e
1104
+ raise normalize_transport_fault(e, method, path)
1105
+ end
1106
+
1107
+ def normalize_transport_fault(error, method, path)
1108
+ suffix = context_suffix(@runtime_id, op_from_route(method, path), nil)
1109
+ if error.is_a?(Faraday::TimeoutError)
1110
+ PineSandbox::TimeoutError.new("Computer request timed out: #{error.message}#{suffix}")
1111
+ else
1112
+ PineSandbox::ConnectionError.new("Computer connection failed: #{error.message}#{suffix}")
1113
+ end
1114
+ end
1115
+
1116
+ # "<METHOD> /<path>" with any query string / fragment stripped — the transport-fault
1117
+ # counterpart to op_for (which reads env.url.path). `path` is the SDK route, sans the
1118
+ # leading slash route_path adds.
1119
+ def op_from_route(method, path)
1120
+ route = path.to_s.split(/[?#]/, 2).first
1121
+ "#{method.to_s.upcase} /#{route}"
1122
+ end
1123
+
1124
+ # The coord route path on the gateway host. The gateway routes by subdomain
1125
+ # and selects the role by PATH, so the path is just the coord route — no
1126
+ # proxy prefix, no runtime id, no port.
1127
+ def route_path(suffix)
1128
+ "/#{suffix}"
1129
+ end
1130
+
1131
+ def apply_token(req, token)
1132
+ return if token.nil?
1133
+
1134
+ req.headers["Authorization"] = "Bearer #{token}"
1135
+ end
1136
+
1137
+ # Raises the typed error for a non-2xx response. A 401 for a Computer
1138
+ # token is TokenRejectedError; one for a session token stays the plain
1139
+ # UnauthorizedError — only the binding's own token says the binding lost
1140
+ # its authorization.
1141
+ def ensure_success!(response, token: nil)
1142
+ return if response.success?
1143
+
1144
+ classify_failure!(response, token)
1145
+ rescue UnauthorizedError => e
1146
+ raise unless e.status.to_i == 401 && token.to_s.start_with?("ct_")
1147
+
1148
+ raise TokenRejectedError.for_api_error(e), cause: e
1149
+ end
1150
+
1151
+ def classify_failure!(response, token)
1152
+ check_success!(response)
1153
+ rescue NotFoundError => original
1154
+ raise if original.is_a?(RuntimeGoneError) || !original.problem_type.to_s.empty?
1155
+
1156
+ # The session list is served by every live runtime, so an untyped 404
1157
+ # there means the gateway no longer routes to it.
1158
+ operation = Operations.match(:computer, response.env.method.to_s.upcase, response.env.url.path)
1159
+ raise RuntimeGoneError.new(original), cause: original if operation&.id == "listSessions"
1160
+
1161
+ # Healthy V2 calls need no discovery round trip. Qualify only an
1162
+ # ambiguous 404, without retrying the original operation.
1163
+ if response.env.url.path.start_with?("/v2/")
1164
+ begin
1165
+ require_agent_protocol!(token: @discovery_token || token)
1166
+ rescue AgentProtocolUnsupported
1167
+ raise
1168
+ rescue PineSandbox::Error
1169
+ # Failed discovery cannot reclassify a resource error.
1170
+ end
1171
+ end
1172
+ raise original
1173
+ end
1174
+
1175
+ def check_success!(response)
1176
+ return if response.success?
1177
+
1178
+ # If the body is RFC 9457 problem+json (the v1 control surface
1179
+ # contract), delegate to the typed dispatcher in
1180
+ # Concerns::ResponseHandler so callers can rescue specific
1181
+ # exception classes (e.g. PineSandbox::InvalidField for
1182
+ # Idempotency-Key body-hash conflicts, PineSandbox::HandoffExpired
1183
+ # for 410 on /handoffs/{id}, etc). When the body is NOT
1184
+ # problem+json (v0 surface, or text/html error pages from a TLS
1185
+ # edge / the ingress), fall back to the older shape so
1186
+ # existing callers that rescue ApiError / UnauthorizedError /
1187
+ # NotFoundError keep working unchanged.
1188
+ # The RESOURCE spine for every error this adapter raises: WHICH Computer (@runtime_id,
1189
+ # held by the adapter) and WHICH operation ("<METHOD> <path>", off the Faraday env). We
1190
+ # attach it to EVERY raised path so an integrator's generic handler sees the resource +
1191
+ # op with no extra plumbing. request_id is the secondary precision handle.
1192
+ op = op_for(response)
1193
+
1194
+ ctype = response.headers["content-type"].to_s
1195
+ if ctype.start_with?("application/problem+json")
1196
+ # raise_for_status! (shared ResponseHandler) builds + freezes the typed error's
1197
+ # message before we can reach it, so fold the resource context in via #attach_context!
1198
+ # (updates extensions AND rebuilds the message) and re-raise the SAME typed object —
1199
+ # callers still rescue PineSandbox::InvalidField / HandoffExpired / … unchanged.
1200
+ begin
1201
+ raise_for_status!(response)
1202
+ rescue PineSandbox::ApiError => e
1203
+ e.attach_context!(runtime_id: @runtime_id, op: op)
1204
+ if e.problem_type == "/errors/sandbox-not-found"
1205
+ raise PineSandbox::RuntimeGoneError.new(e), cause: e
1206
+ end
1207
+ raise
1208
+ end
1209
+ end
1210
+
1211
+ # Every gateway-fronted response carries X-Request-Id (success or failure,
1212
+ # problem+json or not). The problem+json branch above captures it via
1213
+ # ResponseHandler; on these raw/legacy paths capture it here so a v0 / edge
1214
+ # error is still correlatable. TimeoutError has no problem_extensions, so its
1215
+ # context goes straight into the message; the ApiError fallback rides extensions.
1216
+ rid = request_id_header(response)
1217
+
1218
+ # Timeouts (408/504) are a transport concern; they should be
1219
+ # PineSandbox::TimeoutError regardless of body shape so the
1220
+ # lost-response classification above us (agent resends, turn waits)
1221
+ # sees the canonical class. A response is never resent by #request.
1222
+ if response.status == 408 || response.status == 504
1223
+ message = parse_legacy_error_message(response.body)
1224
+ timeout = PineSandbox::TimeoutError.new(
1225
+ "Computer request failed: status=#{response.status} message=#{message}#{context_suffix(@runtime_id, op, rid)}"
1226
+ )
1227
+ timeout.retry_after = retry_after_seconds(response)
1228
+ raise timeout
1229
+ end
1230
+
1231
+ # v0 + non-problem+json error: emit the older shape, now carrying the resource spine
1232
+ # in problem_extensions so ApiError#build_message renders it resource-first.
1233
+ message = parse_legacy_error_message(response.body)
1234
+ klass =
1235
+ case response.status
1236
+ when 401, 403 then PineSandbox::UnauthorizedError
1237
+ when 404 then PineSandbox::NotFoundError
1238
+ when 409 then PineSandbox::ConflictError
1239
+ when 503 then PineSandbox::ServiceUnavailableError
1240
+ else PineSandbox::ApiError
1241
+ end
1242
+ ext = {}
1243
+ ext["sandbox_id"] = @runtime_id if @runtime_id && !@runtime_id.to_s.empty?
1244
+ ext["op"] = op if op && !op.to_s.empty?
1245
+ ext["request_id"] = rid if rid
1246
+ raise klass.new(
1247
+ status: response.status,
1248
+ error_message: "Computer request failed: status=#{response.status} message=#{message}",
1249
+ response_body: response.body,
1250
+ problem_extensions: (ext.empty? ? nil : ext)
1251
+ )
1252
+ rescue ApiError => e
1253
+ e.retry_after ||= retry_after_seconds(response)
1254
+ raise
1255
+ end
1256
+
1257
+ # The response's Retry-After in whole seconds, or nil.
1258
+ def retry_after_seconds(response)
1259
+ seconds = Integer(response.headers["retry-after"].to_s.strip, exception: false)
1260
+ seconds if seconds&.positive?
1261
+ end
1262
+
1263
+ # "<METHOD> <path>" off the Faraday response env — WHICH operation failed. nil when the
1264
+ # env/url is unavailable (never let a diagnostic detail mask the real error).
1265
+ def op_for(response)
1266
+ env = response.respond_to?(:env) ? response.env : nil
1267
+ return nil unless env && env.method
1268
+
1269
+ url = env.url
1270
+ path = url.respond_to?(:path) ? url.path : url.to_s
1271
+ "#{env.method.to_s.upcase} #{path}"
1272
+ rescue StandardError
1273
+ nil
1274
+ end
1275
+
1276
+ # Resource-first troubleshooting suffix for the message-only error paths (TimeoutError
1277
+ # has no problem_extensions): runtime (WHICH Computer runtime) → op (WHICH operation) →
1278
+ # request_id. Each part only when present; "" when all absent.
1279
+ def context_suffix(runtime_id, op, rid)
1280
+ parts = []
1281
+ parts << "runtime=#{runtime_id}" if runtime_id && !runtime_id.to_s.empty?
1282
+ parts << "op=#{op}" if op && !op.to_s.empty?
1283
+ parts << "request_id=#{rid}" if rid && !rid.to_s.empty?
1284
+ parts.empty? ? "" : " (#{parts.join(', ')})"
1285
+ end
1286
+
1287
+ # X-Request-Id off a coord response (either header casing), nil/empty → nil.
1288
+ def request_id_header(response)
1289
+ rid = response.headers["x-request-id"] || response.headers["X-Request-Id"]
1290
+ rid unless rid.to_s.empty?
1291
+ end
1292
+
1293
+ def parse_legacy_error_message(body)
1294
+ JSON.parse(body)["error"] || body.to_s
1295
+ rescue JSON::ParserError, TypeError
1296
+ body.to_s
1297
+ end
1298
+
1299
+ # Faraday connection to the Computer's gateway host. The base URL sets the
1300
+ # Host (and TLS SNI) to `<runtime_id>.computer.<zone>` — covered by the
1301
+ # gateway wildcard cert `*.computer.<zone>`. Port-less and header-less: the
1302
+ # gateway routes the subdomain to the pod and the PATH selects the role; no
1303
+ # API key (coord gates every route on the bearer).
1304
+ def connection
1305
+ @connection ||= @zone.faraday(@host) do |conn|
1306
+ conn.headers["User-Agent"] = "pinesandbox-ruby/#{PineSandbox::VERSION}"
1307
+ conn.options.open_timeout = DEFAULT_OPEN_TIMEOUT
1308
+ conn.options.timeout = DEFAULT_HTTP_TIMEOUT
1309
+ conn.adapter Faraday.default_adapter
1310
+ end
1311
+ end
1312
+
1313
+ # Long-lived stream connection — disables the read timeout. The default
1314
+ # `connection` above ships a 30s `options.timeout`; for SSE that's a
1315
+ # papercut (a model-thinking turn or sparse control-event stream
1316
+ # legitimately exceeds 30s of silence). Coord ships periodic `: ka`
1317
+ # keepalive comments (every 20s) which reset Net::HTTP's read timer,
1318
+ # so even with the default this rarely fires in practice — but the
1319
+ # principled fix is to make the streaming socket actually long-lived.
1320
+ #
1321
+ # Faraday 2.x trap: `req.options.timeout = nil` does NOT mean "no
1322
+ # timeout" on the Net::HTTP adapter. faraday-net_http 3.x only sets
1323
+ # `http.read_timeout` when the option is truthy; nil falls through to
1324
+ # Ruby's 60s default. The fix is to set it explicitly via the adapter
1325
+ # block, which the adapter calls AFTER applying request_timeout.
1326
+ def streaming_connection
1327
+ @streaming_connection ||= @zone.faraday(@host) do |conn|
1328
+ conn.headers["User-Agent"] = "pinesandbox-ruby/#{PineSandbox::VERSION}"
1329
+ conn.options.open_timeout = DEFAULT_OPEN_TIMEOUT
1330
+ # DELIBERATELY no conn.options.timeout — adapter block is authoritative.
1331
+ conn.adapter Faraday.default_adapter do |http|
1332
+ http.read_timeout = nil
1333
+ end
1334
+ end
1335
+ end
1336
+
1337
+ # Wraps a Faraday::Response so callers can read `body` as the parsed
1338
+ # JSON shape regardless of whether the response was JSON or text. Keeps
1339
+ # the original response accessible for status/headers introspection.
1340
+ class ParsedResponse
1341
+ attr_reader :raw
1342
+
1343
+ def initialize(raw, accept)
1344
+ @raw = raw
1345
+ @accept = accept
1346
+ end
1347
+
1348
+ def body
1349
+ @body ||=
1350
+ if @accept == "application/json" && !@raw.body.to_s.empty?
1351
+ JSON.parse(@raw.body)
1352
+ else
1353
+ @raw.body
1354
+ end
1355
+ end
1356
+
1357
+ def status
1358
+ @raw.status
1359
+ end
1360
+
1361
+ def headers
1362
+ @raw.headers
1363
+ end
1364
+ end
1365
+
1366
+ def escape(seg)
1367
+ # Path-segment safe encode: keep coordinator's expectations simple.
1368
+ # Names are alphanumeric + dash + underscore in normal use; URL-safe
1369
+ # encode covers the edge cases without breaking ASCII paths.
1370
+ seg.to_s.gsub(/[^A-Za-z0-9_\-.]/) { |c| "%#{c.unpack1('H2').upcase}" }
1371
+ end
1372
+ end
1373
+ end
1374
+ end