pinesandbox 0.3.18 → 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.
- checksums.yaml +4 -4
- data/LICENSE +201 -0
- data/README.md +159 -157
- data/lib/pinesandbox/adapters/coordinator_adapter.rb +99 -74
- data/lib/pinesandbox/agent_events.rb +2 -2
- data/lib/pinesandbox/agent_turn.rb +24 -2
- data/lib/pinesandbox/attach_credentials_source.rb +13 -13
- data/lib/pinesandbox/bind_hpke.rb +4 -4
- data/lib/pinesandbox/client.rb +59 -36
- data/lib/pinesandbox/computer.rb +108 -108
- data/lib/pinesandbox/computer_binding.rb +14 -10
- data/lib/pinesandbox/control_plane.rb +10 -10
- data/lib/pinesandbox/error_taxonomy.rb +1 -0
- data/lib/pinesandbox/errors.rb +56 -76
- data/lib/pinesandbox/models/artifact.rb +1 -1
- data/lib/pinesandbox/operations.rb +0 -3
- data/lib/pinesandbox/{runtime_details.rb → sandbox_details.rb} +4 -4
- data/lib/pinesandbox/{runtime_handle.rb → sandbox_handle.rb} +14 -19
- data/lib/pinesandbox/session.rb +22 -2
- data/lib/pinesandbox/spec_version_middleware.rb +1 -1
- data/lib/pinesandbox/version.rb +1 -1
- data/lib/pinesandbox/zone.rb +8 -11
- data/lib/pinesandbox.rb +2 -2
- data/pinesandbox.gemspec +5 -3
- metadata +8 -6
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "faraday"
|
|
2
2
|
require "json"
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require "tempfile"
|
|
4
5
|
require "uri"
|
|
5
6
|
require_relative "../clock"
|
|
6
7
|
require_relative "../errors"
|
|
@@ -14,8 +15,9 @@ require_relative "../response_handler"
|
|
|
14
15
|
module PineSandbox
|
|
15
16
|
module Adapters
|
|
16
17
|
# @api private — the SDK's HTTP client for the Computer API, reached at the
|
|
17
|
-
#
|
|
18
|
-
# selects 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);
|
|
19
21
|
# the host is port-less and header-less — no routing header.
|
|
20
22
|
#
|
|
21
23
|
# The bearer goes in `Authorization: Bearer`. The Computer gates every
|
|
@@ -36,24 +38,28 @@ module PineSandbox
|
|
|
36
38
|
DEFAULT_OPEN_TIMEOUT = 5
|
|
37
39
|
# A non-2xx stream answer keeps this much of its body for the error.
|
|
38
40
|
STREAM_ERROR_BODY_CAP = 64 * 1024
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
#
|
|
42
|
-
|
|
43
|
-
|
|
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>`
|
|
44
51
|
# (scheme-less). `scheme` is http for a .localhost zone (local-dev loop),
|
|
45
52
|
# https otherwise. `clock` paces retries and reconnects.
|
|
46
|
-
attr_reader :
|
|
53
|
+
attr_reader :sandbox_id, :host, :scheme, :clock
|
|
47
54
|
|
|
48
55
|
# discovery_token is the Computer token that reads the capability
|
|
49
56
|
# manifest when a request's own token, such as a session's, cannot. An
|
|
50
57
|
# adopted binding passes it; #bind records the one it issues.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
raise ArgumentError, "runtime_id required" if @runtime_id.to_s.empty?
|
|
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?
|
|
55
61
|
|
|
56
|
-
@host = zone.data_host(@
|
|
62
|
+
@host = zone.data_host(@sandbox_id)
|
|
57
63
|
@scheme = zone.http_scheme # http for a .localhost zone (local-dev loop), https otherwise
|
|
58
64
|
@discovery_token = discovery_token
|
|
59
65
|
@clock = clock
|
|
@@ -63,13 +69,7 @@ module PineSandbox
|
|
|
63
69
|
@zone = zone
|
|
64
70
|
end
|
|
65
71
|
|
|
66
|
-
#
|
|
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
|
|
72
|
+
# The full data-plane origin `<scheme>://<sandbox_id>.computer.<zone>` — the
|
|
73
73
|
# DelegatedConnection's `computer_host` is a complete URI per the Computer API,
|
|
74
74
|
# so the web SDK derives the desktop's ws/wss scheme from it (no guessing).
|
|
75
75
|
def base_url
|
|
@@ -77,7 +77,7 @@ module PineSandbox
|
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
# Bind handshake. Posts the portal-issued bind_token JWS to
|
|
80
|
-
# /v1/coord/bind and returns the
|
|
80
|
+
# /v1/coord/bind and returns the Computer token (`ct_`) from the response.
|
|
81
81
|
#
|
|
82
82
|
# Auth model: bind_token's Ed25519 signature IS the auth — no
|
|
83
83
|
# bearer token is presented here (the bind endpoint refuses
|
|
@@ -85,7 +85,7 @@ module PineSandbox
|
|
|
85
85
|
#
|
|
86
86
|
# @param bind_token [String] compact JWS Ed25519 token minted by the
|
|
87
87
|
# portal attach-credentials endpoint (AttachCredentialsSource).
|
|
88
|
-
# @param pod_uid [String] echoed
|
|
88
|
+
# @param pod_uid [String] echoed Computer sandbox identity (must match the token).
|
|
89
89
|
# @param coord_boot_id [String] echoed process boot id.
|
|
90
90
|
# @param ciphertext [String] base64url HPKE wire (enc || aead_ct).
|
|
91
91
|
# @return [Hash] { "computer_token" => ct_, "epoch" => Integer }.
|
|
@@ -109,7 +109,7 @@ module PineSandbox
|
|
|
109
109
|
end
|
|
110
110
|
|
|
111
111
|
# Fetches the Computer's per-process ephemeral HPKE public key and
|
|
112
|
-
#
|
|
112
|
+
# Computer sandbox identity. Public route — no token. The SDK seals the bind
|
|
113
113
|
# payload to this key and echoes pod_uid/coord_boot_id back on bind.
|
|
114
114
|
#
|
|
115
115
|
# @return [Hash] { "pod_uid", "coord_boot_id", "ephem_pub_x25519"
|
|
@@ -192,22 +192,6 @@ module PineSandbox
|
|
|
192
192
|
true
|
|
193
193
|
end
|
|
194
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
195
|
def create_session(token:, name: nil, label: nil, browser: false, blind: false)
|
|
212
196
|
# Omit :name when blank so the coordinator mints a friendly name; only
|
|
213
197
|
# send :label when the caller set one. Name stays first so the on-wire
|
|
@@ -300,7 +284,7 @@ module PineSandbox
|
|
|
300
284
|
# ----- Artifacts: list / read / zip / upload -----
|
|
301
285
|
|
|
302
286
|
# GET /v1/sessions/{name}/artifacts?turn_id= — the session's artifacts
|
|
303
|
-
# (uploads +
|
|
287
|
+
# (uploads + browser downloads + every completed turn's durably-captured
|
|
304
288
|
# agent outputs).
|
|
305
289
|
def list_artifacts(token:, name:, turn_id: nil)
|
|
306
290
|
route = "v1/sessions/#{escape(name)}/artifacts"
|
|
@@ -525,7 +509,7 @@ module PineSandbox
|
|
|
525
509
|
event.data["exitCode"] || event.data[:exitCode]
|
|
526
510
|
exit_code = raw unless raw.nil?
|
|
527
511
|
end
|
|
528
|
-
block
|
|
512
|
+
pass_to_block(block, event) if block
|
|
529
513
|
end
|
|
530
514
|
# Clamp the HTTP timeout above timeout_ms so the client waits for
|
|
531
515
|
# the server's own timeout to fire an execution_complete event
|
|
@@ -541,7 +525,13 @@ module PineSandbox
|
|
|
541
525
|
req.headers["Accept"] = "text/event-stream"
|
|
542
526
|
req.body = body.to_json
|
|
543
527
|
req.options.on_data = proc do |chunk, _received, env|
|
|
544
|
-
if env.nil?
|
|
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)
|
|
545
535
|
parser.parse(chunk)
|
|
546
536
|
else
|
|
547
537
|
keep_error_body(error_body, chunk)
|
|
@@ -568,6 +558,8 @@ module PineSandbox
|
|
|
568
558
|
end
|
|
569
559
|
ExecResult.new(stdout: stdout, stderr: stderr, exit_code: exit_code,
|
|
570
560
|
error_message: error_msg)
|
|
561
|
+
rescue BlockError => e
|
|
562
|
+
raise e.error
|
|
571
563
|
end
|
|
572
564
|
|
|
573
565
|
# Minimal struct surfaced by #exec. Matches the shape tests expect:
|
|
@@ -701,7 +693,7 @@ module PineSandbox
|
|
|
701
693
|
end
|
|
702
694
|
|
|
703
695
|
# Explicit qualification for a binding. No cache, downgrade or mutation.
|
|
704
|
-
# A missing discovery route is an old
|
|
696
|
+
# A missing discovery route is an old Computer sandbox; the gateway's typed
|
|
705
697
|
# sandbox-not-found response remains a lifecycle failure.
|
|
706
698
|
def require_agent_protocol!(token:)
|
|
707
699
|
manifest = capabilities(token: token)
|
|
@@ -709,7 +701,7 @@ module PineSandbox
|
|
|
709
701
|
|
|
710
702
|
self
|
|
711
703
|
rescue NotFoundError => e
|
|
712
|
-
raise if e.is_a?(
|
|
704
|
+
raise if e.is_a?(SandboxGoneError)
|
|
713
705
|
|
|
714
706
|
raise AgentProtocolUnsupported
|
|
715
707
|
end
|
|
@@ -899,7 +891,13 @@ module PineSandbox
|
|
|
899
891
|
req.headers["Accept"] = "text/event-stream"
|
|
900
892
|
req.headers["Last-Event-ID"] = last_event_id.to_s unless last_event_id.to_s.empty?
|
|
901
893
|
req.options.on_data = proc do |chunk, _bytes, env|
|
|
902
|
-
if env.nil?
|
|
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)
|
|
903
901
|
parser.feed(chunk) { |frame| yield frame }
|
|
904
902
|
else
|
|
905
903
|
keep_error_body(error_body, chunk)
|
|
@@ -927,6 +925,16 @@ module PineSandbox
|
|
|
927
925
|
end
|
|
928
926
|
private_constant :BlockError
|
|
929
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
|
+
|
|
930
938
|
# The resuming event-stream engine. Each connection opens from the last
|
|
931
939
|
# event id seen; frames advance the cursor before any decode, so a
|
|
932
940
|
# skipped frame is never replayed. A dropped connection (a transport
|
|
@@ -1008,24 +1016,45 @@ module PineSandbox
|
|
|
1008
1016
|
# raises, before any chunk. A transport fault after the first chunk
|
|
1009
1017
|
# raises too, so the caller knows the body is incomplete. The default
|
|
1010
1018
|
# connection's read timeout bounds each wait for the next chunk.
|
|
1011
|
-
def stream_bytes(path, token:, accept
|
|
1019
|
+
def stream_bytes(path, token:, accept:, &block)
|
|
1012
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
|
|
1013
1026
|
with_transport_norm("GET", path) do
|
|
1014
1027
|
response = connection.get(route_path(path)) do |req|
|
|
1015
1028
|
apply_token(req, token)
|
|
1016
1029
|
req.headers["Accept"] = accept
|
|
1017
1030
|
req.options.on_data = proc do |chunk, _received, env|
|
|
1018
|
-
if env.
|
|
1019
|
-
|
|
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?
|
|
1020
1035
|
else
|
|
1021
1036
|
keep_error_body(error_body, chunk)
|
|
1022
1037
|
end
|
|
1023
1038
|
end
|
|
1024
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
|
|
1025
1050
|
response.env.body = error_body if !response.success? && response.body.to_s.empty?
|
|
1026
1051
|
ensure_success!(response, token: token) unless response.success?
|
|
1027
1052
|
end
|
|
1028
1053
|
nil
|
|
1054
|
+
rescue BlockError => e
|
|
1055
|
+
raise e.error
|
|
1056
|
+
ensure
|
|
1057
|
+
spool&.close!
|
|
1029
1058
|
end
|
|
1030
1059
|
|
|
1031
1060
|
def get(path, token:, accept: "application/json", headers: nil)
|
|
@@ -1105,7 +1134,7 @@ module PineSandbox
|
|
|
1105
1134
|
end
|
|
1106
1135
|
|
|
1107
1136
|
def normalize_transport_fault(error, method, path)
|
|
1108
|
-
suffix = context_suffix(@
|
|
1137
|
+
suffix = context_suffix(@sandbox_id, op_from_route(method, path), nil)
|
|
1109
1138
|
if error.is_a?(Faraday::TimeoutError)
|
|
1110
1139
|
PineSandbox::TimeoutError.new("Computer request timed out: #{error.message}#{suffix}")
|
|
1111
1140
|
else
|
|
@@ -1123,7 +1152,7 @@ module PineSandbox
|
|
|
1123
1152
|
|
|
1124
1153
|
# The coord route path on the gateway host. The gateway routes by subdomain
|
|
1125
1154
|
# and selects the role by PATH, so the path is just the coord route — no
|
|
1126
|
-
# proxy prefix, no
|
|
1155
|
+
# proxy prefix, no sandbox id, no port.
|
|
1127
1156
|
def route_path(suffix)
|
|
1128
1157
|
"/#{suffix}"
|
|
1129
1158
|
end
|
|
@@ -1151,12 +1180,12 @@ module PineSandbox
|
|
|
1151
1180
|
def classify_failure!(response, token)
|
|
1152
1181
|
check_success!(response)
|
|
1153
1182
|
rescue NotFoundError => original
|
|
1154
|
-
raise if original.is_a?(
|
|
1183
|
+
raise if original.is_a?(SandboxGoneError) || !original.problem_type.to_s.empty?
|
|
1155
1184
|
|
|
1156
|
-
# The session list is served by every live
|
|
1185
|
+
# The session list is served by every live Computer sandbox, so an untyped 404
|
|
1157
1186
|
# there means the gateway no longer routes to it.
|
|
1158
1187
|
operation = Operations.match(:computer, response.env.method.to_s.upcase, response.env.url.path)
|
|
1159
|
-
raise
|
|
1188
|
+
raise SandboxGoneError.new(original), cause: original if operation&.id == "listSessions"
|
|
1160
1189
|
|
|
1161
1190
|
# Healthy V2 calls need no discovery round trip. Qualify only an
|
|
1162
1191
|
# ambiguous 404, without retrying the original operation.
|
|
@@ -1185,7 +1214,7 @@ module PineSandbox
|
|
|
1185
1214
|
# edge / the ingress), fall back to the older shape so
|
|
1186
1215
|
# existing callers that rescue ApiError / UnauthorizedError /
|
|
1187
1216
|
# NotFoundError keep working unchanged.
|
|
1188
|
-
# The RESOURCE spine for every error this adapter raises: WHICH Computer (@
|
|
1217
|
+
# The RESOURCE spine for every error this adapter raises: WHICH Computer (@sandbox_id,
|
|
1189
1218
|
# held by the adapter) and WHICH operation ("<METHOD> <path>", off the Faraday env). We
|
|
1190
1219
|
# attach it to EVERY raised path so an integrator's generic handler sees the resource +
|
|
1191
1220
|
# op with no extra plumbing. request_id is the secondary precision handle.
|
|
@@ -1200,9 +1229,9 @@ module PineSandbox
|
|
|
1200
1229
|
begin
|
|
1201
1230
|
raise_for_status!(response)
|
|
1202
1231
|
rescue PineSandbox::ApiError => e
|
|
1203
|
-
e.attach_context!(
|
|
1232
|
+
e.attach_context!(sandbox_id: @sandbox_id, op: op)
|
|
1204
1233
|
if e.problem_type == "/errors/sandbox-not-found"
|
|
1205
|
-
raise PineSandbox::
|
|
1234
|
+
raise PineSandbox::SandboxGoneError.new(e), cause: e
|
|
1206
1235
|
end
|
|
1207
1236
|
raise
|
|
1208
1237
|
end
|
|
@@ -1222,7 +1251,7 @@ module PineSandbox
|
|
|
1222
1251
|
if response.status == 408 || response.status == 504
|
|
1223
1252
|
message = parse_legacy_error_message(response.body)
|
|
1224
1253
|
timeout = PineSandbox::TimeoutError.new(
|
|
1225
|
-
"Computer request failed: status=#{response.status} message=#{message}#{context_suffix(@
|
|
1254
|
+
"Computer request failed: status=#{response.status} message=#{message}#{context_suffix(@sandbox_id, op, rid)}"
|
|
1226
1255
|
)
|
|
1227
1256
|
timeout.retry_after = retry_after_seconds(response)
|
|
1228
1257
|
raise timeout
|
|
@@ -1240,7 +1269,7 @@ module PineSandbox
|
|
|
1240
1269
|
else PineSandbox::ApiError
|
|
1241
1270
|
end
|
|
1242
1271
|
ext = {}
|
|
1243
|
-
ext["sandbox_id"] = @
|
|
1272
|
+
ext["sandbox_id"] = @sandbox_id if @sandbox_id && !@sandbox_id.to_s.empty?
|
|
1244
1273
|
ext["op"] = op if op && !op.to_s.empty?
|
|
1245
1274
|
ext["request_id"] = rid if rid
|
|
1246
1275
|
raise klass.new(
|
|
@@ -1274,11 +1303,11 @@ module PineSandbox
|
|
|
1274
1303
|
end
|
|
1275
1304
|
|
|
1276
1305
|
# Resource-first troubleshooting suffix for the message-only error paths (TimeoutError
|
|
1277
|
-
# has no problem_extensions):
|
|
1306
|
+
# has no problem_extensions): sandbox (WHICH Computer sandbox) → op (WHICH operation) →
|
|
1278
1307
|
# request_id. Each part only when present; "" when all absent.
|
|
1279
|
-
def context_suffix(
|
|
1308
|
+
def context_suffix(sandbox_id, op, rid)
|
|
1280
1309
|
parts = []
|
|
1281
|
-
parts << "
|
|
1310
|
+
parts << "sandbox=#{sandbox_id}" if sandbox_id && !sandbox_id.to_s.empty?
|
|
1282
1311
|
parts << "op=#{op}" if op && !op.to_s.empty?
|
|
1283
1312
|
parts << "request_id=#{rid}" if rid && !rid.to_s.empty?
|
|
1284
1313
|
parts.empty? ? "" : " (#{parts.join(', ')})"
|
|
@@ -1297,7 +1326,7 @@ module PineSandbox
|
|
|
1297
1326
|
end
|
|
1298
1327
|
|
|
1299
1328
|
# Faraday connection to the Computer's gateway host. The base URL sets the
|
|
1300
|
-
# Host (and TLS SNI) to `<
|
|
1329
|
+
# Host (and TLS SNI) to `<sandbox_id>.computer.<zone>` — covered by the
|
|
1301
1330
|
# gateway wildcard cert `*.computer.<zone>`. Port-less and header-less: the
|
|
1302
1331
|
# gateway routes the subdomain to the pod and the PATH selects the role; no
|
|
1303
1332
|
# API key (coord gates every route on the bearer).
|
|
@@ -1310,18 +1339,14 @@ module PineSandbox
|
|
|
1310
1339
|
end
|
|
1311
1340
|
end
|
|
1312
1341
|
|
|
1313
|
-
# Long-lived stream connection
|
|
1314
|
-
# `
|
|
1315
|
-
#
|
|
1316
|
-
#
|
|
1317
|
-
#
|
|
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.
|
|
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.
|
|
1320
1347
|
#
|
|
1321
|
-
# Faraday 2.x trap: `req.options.timeout
|
|
1322
|
-
#
|
|
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
|
|
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
|
|
1325
1350
|
# block, which the adapter calls AFTER applying request_timeout.
|
|
1326
1351
|
def streaming_connection
|
|
1327
1352
|
@streaming_connection ||= @zone.faraday(@host) do |conn|
|
|
@@ -1329,7 +1354,7 @@ module PineSandbox
|
|
|
1329
1354
|
conn.options.open_timeout = DEFAULT_OPEN_TIMEOUT
|
|
1330
1355
|
# DELIBERATELY no conn.options.timeout — adapter block is authoritative.
|
|
1331
1356
|
conn.adapter Faraday.default_adapter do |http|
|
|
1332
|
-
http.read_timeout =
|
|
1357
|
+
http.read_timeout = STREAM_IDLE_TIMEOUT
|
|
1333
1358
|
end
|
|
1334
1359
|
end
|
|
1335
1360
|
end
|
|
@@ -165,11 +165,11 @@ module PineSandbox
|
|
|
165
165
|
end
|
|
166
166
|
|
|
167
167
|
# How the turn ended, including with a question (a `result` frame): the
|
|
168
|
-
# same
|
|
168
|
+
# same AgentResult the turn's #result returns, or nil for another event.
|
|
169
169
|
def result
|
|
170
170
|
return nil unless type == "result" && payload.is_a?(Hash)
|
|
171
171
|
|
|
172
|
-
payload
|
|
172
|
+
AgentResult.wrap(payload)
|
|
173
173
|
end
|
|
174
174
|
|
|
175
175
|
private
|
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
require "time"
|
|
2
2
|
|
|
3
3
|
module PineSandbox
|
|
4
|
+
# How a turn ended (spec PublicTurnResult), from AgentTurn#result and
|
|
5
|
+
# AgentEvent#result: the result's own string-keyed Hash, extended with these
|
|
6
|
+
# readers. It stays a plain Hash, so a change to it is kept and YAML and JSON
|
|
7
|
+
# store it as they did before the readers existed. A frozen result can't be
|
|
8
|
+
# extended, so it reads as a frozen copy.
|
|
9
|
+
module AgentResult
|
|
10
|
+
def self.wrap(value)
|
|
11
|
+
return value unless value.is_a?(Hash)
|
|
12
|
+
return value if value.is_a?(self)
|
|
13
|
+
return value.dup.extend(self).freeze if value.frozen?
|
|
14
|
+
|
|
15
|
+
value.extend(self)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def terminal_reason = self["terminal_reason"]
|
|
19
|
+
# The agent's closing message; the question for "needs_input".
|
|
20
|
+
def summary = self["summary"]
|
|
21
|
+
def artifacts = self["artifacts"]
|
|
22
|
+
def author = self["author"]
|
|
23
|
+
def usage = self["usage"]
|
|
24
|
+
end
|
|
25
|
+
|
|
4
26
|
# One unit of agent work (spec PublicTurn), returned by AgentMode#run,
|
|
5
27
|
# #wait, #answer, #steer and #cancel. A turn starts when a run or an answer is
|
|
6
28
|
# accepted and ends when the agent finishes, fails, is canceled or asks a
|
|
@@ -25,8 +47,8 @@ module PineSandbox
|
|
|
25
47
|
def state = self["state"]
|
|
26
48
|
# The turn this turn's answer opened; present once it is "answered".
|
|
27
49
|
def next_turn_id = self["next_turn_id"]
|
|
28
|
-
# How the turn ended, with
|
|
29
|
-
def result = self["result"]
|
|
50
|
+
# How the turn ended, an AgentResult with its usage; nil while it runs.
|
|
51
|
+
def result = AgentResult.wrap(self["result"])
|
|
30
52
|
def first_event_id = self["first_event_id"]
|
|
31
53
|
def last_event_id = self["last_event_id"]
|
|
32
54
|
|
|
@@ -12,17 +12,17 @@ module PineSandbox
|
|
|
12
12
|
# * POST /v1/computers — optional legacy
|
|
13
13
|
# pre-registration helper. Normal attach does not require it.
|
|
14
14
|
# * POST /v1/computers/:computer_id/attach-credentials — body carries the
|
|
15
|
-
#
|
|
15
|
+
# start's coordinates {pod_uid, coord_boot_id}; returns {bind_token,
|
|
16
16
|
# broker_grant, key_assertion, binding_revision}. The portal authenticates
|
|
17
17
|
# the pk_ and atomically creates/rechecks its minimal authorization row,
|
|
18
18
|
# advances the key floor, supersedes the prior binding, and mints.
|
|
19
19
|
# * DELETE /v1/computers/:computer_id — permanent delete.
|
|
20
20
|
# 204 for deleted, already deleted, unknown, and cross-project ids alike.
|
|
21
21
|
#
|
|
22
|
-
# This source caches nothing. Computer#run_bind calls it exactly once for
|
|
23
|
-
#
|
|
24
|
-
# (the bind is idempotent on jti + ciphertext). A
|
|
25
|
-
# requires a
|
|
22
|
+
# This source caches nothing. Computer#run_bind calls it exactly once for each
|
|
23
|
+
# start, then convergence retries re-send the held envelope verbatim
|
|
24
|
+
# (the bind is idempotent on jti + ciphertext). A change of pod_uid or
|
|
25
|
+
# coord_boot_id requires a new start; it never authorizes the same start twice.
|
|
26
26
|
# The pk_ never appears in #inspect.
|
|
27
27
|
class AttachCredentialsSource
|
|
28
28
|
REGISTER_PATH = "/v1/computers"
|
|
@@ -97,7 +97,7 @@ module PineSandbox
|
|
|
97
97
|
payload
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
-
# One authorization-registry read before Start; never a
|
|
100
|
+
# One authorization-registry read before Start; never a Computer status poll.
|
|
101
101
|
def current_binding(computer_id)
|
|
102
102
|
resp = get("#{REGISTER_PATH}/#{computer_id}", op: "computer binding lookup")
|
|
103
103
|
if resp.status == 404
|
|
@@ -134,9 +134,9 @@ module PineSandbox
|
|
|
134
134
|
end
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
-
# Mint the per-attach credentials for one
|
|
138
|
-
#
|
|
139
|
-
#
|
|
137
|
+
# Mint the per-attach credentials for one Computer sandbox and boot. `sandbox_id`
|
|
138
|
+
# is the sandbox id this attach targets — the portal records which Computer it
|
|
139
|
+
# belongs to at mint (its renewal gate keys off it).
|
|
140
140
|
# Returns a Hash with string keys: "bind_token", "broker_grant",
|
|
141
141
|
# "key_assertion", "binding_revision", and the effective "location"
|
|
142
142
|
# (plus expiry metadata).
|
|
@@ -214,7 +214,7 @@ module PineSandbox
|
|
|
214
214
|
problem["current_binding_revision"], problem["current_sandbox_id"]
|
|
215
215
|
),
|
|
216
216
|
current_binding_revision: problem["current_binding_revision"],
|
|
217
|
-
|
|
217
|
+
current_sandbox_id: problem["current_sandbox_id"],
|
|
218
218
|
**error_attributes(resp)
|
|
219
219
|
)
|
|
220
220
|
else
|
|
@@ -347,12 +347,12 @@ module PineSandbox
|
|
|
347
347
|
|
|
348
348
|
# The attach revision is a fence against two live attaches of one
|
|
349
349
|
# Computer, so the SDK never retries past it; the message says what to do.
|
|
350
|
-
def binding_conflict_message(message, current_revision,
|
|
350
|
+
def binding_conflict_message(message, current_revision, current_sandbox_id)
|
|
351
351
|
return message unless current_revision.is_a?(Integer)
|
|
352
352
|
|
|
353
|
-
holder =
|
|
353
|
+
holder = current_sandbox_id.to_s.empty? ? "" : " (Computer sandbox #{current_sandbox_id})"
|
|
354
354
|
"#{message}. Another start changed the Computer binding#{holder}. " \
|
|
355
|
-
"Do not retry automatically. Check the
|
|
355
|
+
"Do not retry automatically. Check whether the Computer is still running before starting again."
|
|
356
356
|
end
|
|
357
357
|
|
|
358
358
|
def error_message(resp, default)
|
|
@@ -15,7 +15,7 @@ module PineSandbox
|
|
|
15
15
|
module_function
|
|
16
16
|
|
|
17
17
|
# info binds the sealed payload to the receiving process identity so a
|
|
18
|
-
# ciphertext sealed for one
|
|
18
|
+
# ciphertext sealed for one Computer sandbox/boot cannot be opened by another.
|
|
19
19
|
# MUST byte-match the receiver's info string.
|
|
20
20
|
#
|
|
21
21
|
# @param pod_uid [String]
|
|
@@ -26,8 +26,8 @@ module PineSandbox
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
# restore_info is the two-round v3 restore's round-2 domain: the
|
|
29
|
-
# integrator-unwrapped component secrets sealed back to the same
|
|
30
|
-
# key. Same suite, DISTINCT domain — a bind ciphertext can never be
|
|
29
|
+
# integrator-unwrapped component secrets sealed back to the same Computer
|
|
30
|
+
# sandbox key. Same suite, DISTINCT domain — a bind ciphertext can never be
|
|
31
31
|
# replayed as a secrets payload or vice versa. MUST byte-match the
|
|
32
32
|
# receiver's restore info string.
|
|
33
33
|
def restore_info(pod_uid, coord_boot_id)
|
|
@@ -36,7 +36,7 @@ module PineSandbox
|
|
|
36
36
|
|
|
37
37
|
# seal encrypts plaintext to the recipient's raw 32-byte X25519 public
|
|
38
38
|
# key. The AAD is fixed empty (the portal attach path carries no nonce —
|
|
39
|
-
# payload-to-
|
|
39
|
+
# payload-to-sandbox binding lives in the info string).
|
|
40
40
|
#
|
|
41
41
|
# @param recipient_pub_raw [String] 32 raw bytes (NOT base64).
|
|
42
42
|
# @param plaintext [String] bytes to seal.
|