pinesandbox 0.0.1 → 0.3.19

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