pinesandbox 0.0.1 → 0.3.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile +3 -0
- data/README.md +668 -4
- data/Rakefile +5 -0
- data/lib/pinesandbox/adapters/coordinator_adapter.rb +1374 -0
- data/lib/pinesandbox/adapters/snapshot_adapter.rb +37 -0
- data/lib/pinesandbox/agent_events.rb +213 -0
- data/lib/pinesandbox/agent_mode.rb +252 -0
- data/lib/pinesandbox/agent_turn.rb +75 -0
- data/lib/pinesandbox/attach_credentials_source.rb +392 -0
- data/lib/pinesandbox/author_terminal_translator.rb +96 -0
- data/lib/pinesandbox/bind_hpke.rb +54 -0
- data/lib/pinesandbox/capture_keypair.rb +43 -0
- data/lib/pinesandbox/client.rb +446 -0
- data/lib/pinesandbox/clock.rb +51 -0
- data/lib/pinesandbox/computer.rb +1075 -0
- data/lib/pinesandbox/computer_binding.rb +47 -0
- data/lib/pinesandbox/computer_credentials.rb +175 -0
- data/lib/pinesandbox/connection.rb +26 -0
- data/lib/pinesandbox/control_events.rb +156 -0
- data/lib/pinesandbox/control_plane.rb +341 -0
- data/lib/pinesandbox/control_state.rb +31 -0
- data/lib/pinesandbox/control_token_source.rb +232 -0
- data/lib/pinesandbox/delegated_connection.rb +46 -0
- data/lib/pinesandbox/diagnostics.rb +83 -0
- data/lib/pinesandbox/drive_mode.rb +67 -0
- data/lib/pinesandbox/error_taxonomy.rb +104 -0
- data/lib/pinesandbox/errors.rb +525 -0
- data/lib/pinesandbox/exec_stream_parser.rb +90 -0
- data/lib/pinesandbox/models/artifact.rb +51 -0
- data/lib/pinesandbox/models/computer_session.rb +57 -0
- data/lib/pinesandbox/models/computer_tab.rb +30 -0
- data/lib/pinesandbox/models/file_entry.rb +38 -0
- data/lib/pinesandbox/operations.rb +134 -0
- data/lib/pinesandbox/response_handler.rb +88 -0
- data/lib/pinesandbox/runtime_details.rb +25 -0
- data/lib/pinesandbox/runtime_handle.rb +252 -0
- data/lib/pinesandbox/session.rb +627 -0
- data/lib/pinesandbox/skills.rb +142 -0
- data/lib/pinesandbox/spec_version_middleware.rb +34 -0
- data/lib/pinesandbox/sse_frame_parser.rb +95 -0
- data/lib/pinesandbox/state_hpke.rb +57 -0
- data/lib/pinesandbox/version.rb +3 -0
- data/lib/pinesandbox/zone.rb +176 -0
- data/lib/pinesandbox.rb +40 -2
- data/pinesandbox.gemspec +47 -0
- metadata +155 -10
|
@@ -0,0 +1,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
|