pinesandbox 0.0.1 → 0.3.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -0
  3. data/README.md +668 -4
  4. data/Rakefile +5 -0
  5. data/lib/pinesandbox/adapters/coordinator_adapter.rb +1374 -0
  6. data/lib/pinesandbox/adapters/snapshot_adapter.rb +37 -0
  7. data/lib/pinesandbox/agent_events.rb +213 -0
  8. data/lib/pinesandbox/agent_mode.rb +252 -0
  9. data/lib/pinesandbox/agent_turn.rb +75 -0
  10. data/lib/pinesandbox/attach_credentials_source.rb +392 -0
  11. data/lib/pinesandbox/author_terminal_translator.rb +96 -0
  12. data/lib/pinesandbox/bind_hpke.rb +54 -0
  13. data/lib/pinesandbox/capture_keypair.rb +43 -0
  14. data/lib/pinesandbox/client.rb +446 -0
  15. data/lib/pinesandbox/clock.rb +51 -0
  16. data/lib/pinesandbox/computer.rb +1075 -0
  17. data/lib/pinesandbox/computer_binding.rb +47 -0
  18. data/lib/pinesandbox/computer_credentials.rb +175 -0
  19. data/lib/pinesandbox/connection.rb +26 -0
  20. data/lib/pinesandbox/control_events.rb +156 -0
  21. data/lib/pinesandbox/control_plane.rb +341 -0
  22. data/lib/pinesandbox/control_state.rb +31 -0
  23. data/lib/pinesandbox/control_token_source.rb +232 -0
  24. data/lib/pinesandbox/delegated_connection.rb +46 -0
  25. data/lib/pinesandbox/diagnostics.rb +83 -0
  26. data/lib/pinesandbox/drive_mode.rb +67 -0
  27. data/lib/pinesandbox/error_taxonomy.rb +104 -0
  28. data/lib/pinesandbox/errors.rb +525 -0
  29. data/lib/pinesandbox/exec_stream_parser.rb +90 -0
  30. data/lib/pinesandbox/models/artifact.rb +51 -0
  31. data/lib/pinesandbox/models/computer_session.rb +57 -0
  32. data/lib/pinesandbox/models/computer_tab.rb +30 -0
  33. data/lib/pinesandbox/models/file_entry.rb +38 -0
  34. data/lib/pinesandbox/operations.rb +134 -0
  35. data/lib/pinesandbox/response_handler.rb +88 -0
  36. data/lib/pinesandbox/runtime_details.rb +25 -0
  37. data/lib/pinesandbox/runtime_handle.rb +252 -0
  38. data/lib/pinesandbox/session.rb +627 -0
  39. data/lib/pinesandbox/skills.rb +142 -0
  40. data/lib/pinesandbox/spec_version_middleware.rb +34 -0
  41. data/lib/pinesandbox/sse_frame_parser.rb +95 -0
  42. data/lib/pinesandbox/state_hpke.rb +57 -0
  43. data/lib/pinesandbox/version.rb +3 -0
  44. data/lib/pinesandbox/zone.rb +176 -0
  45. data/lib/pinesandbox.rb +40 -2
  46. data/pinesandbox.gemspec +47 -0
  47. metadata +155 -10
@@ -0,0 +1,627 @@
1
+ require "securerandom"
2
+ require_relative "delegated_connection"
3
+ require_relative "agent_mode"
4
+ require_relative "drive_mode"
5
+
6
+ module PineSandbox
7
+ # Public names for what Session#exec yields and returns. These are aliases,
8
+ # not new classes: `is_a?` checks against the defining classes keep working.
9
+ ExecEvent = ExecStreamParser::Event
10
+ ExecResult = Adapters::CoordinatorAdapter::ExecResult
11
+
12
+ # One session on a Computer: a browser window and a shell.
13
+ #
14
+ # Returned by {Computer#create_session} and friends; `parent` is the
15
+ # Computer. Holds the session-scoped `ps_*` token, never the parent's `ct_*`.
16
+ # Operations that accept either token use the session's own, or the
17
+ # Computer token for a handle read without one ({Computer#session} returns
18
+ # the session's metadata without its token). Session-only operations (exec,
19
+ # tabs, focus, epoch, drive actions, passkey intent and decisions) need the
20
+ # session token.
21
+ class Session
22
+ attr_reader :info, :parent
23
+
24
+ def initialize(parent:, payload:)
25
+ @parent = parent
26
+ @info = Models::Session.from_payload(payload)
27
+ raise PineSandbox::ApiError.new(status: 0, error_message: "empty session payload") if @info.nil?
28
+ end
29
+
30
+ def name
31
+ info.name
32
+ end
33
+
34
+ # Optional human-meaningful label set at create (distinct from the addressing
35
+ # name). nil when none was given.
36
+ def label
37
+ info.label
38
+ end
39
+
40
+ def token
41
+ info.token
42
+ end
43
+
44
+ def browser
45
+ info.browser
46
+ end
47
+
48
+ def session_dir
49
+ info.session_dir
50
+ end
51
+
52
+ # session.agent.* — hand-off-the-loop driving (run/steer/answer/cancel/reset/
53
+ # status/result/events). The agent drives; you delegate goals. See {AgentMode}.
54
+ def agent
55
+ @agent ||= AgentMode.new(self)
56
+ end
57
+
58
+ # session.drive.* — drive the session with your own agent
59
+ # (observe/computer_use/upload_file). See {DriveMode}.
60
+ def drive
61
+ @drive ||= DriveMode.new(self)
62
+ end
63
+
64
+ # Attribute the next passkey enrollment for this RP to the current agent
65
+ # session. This grants no consent; use the session-scoped decision methods
66
+ # after the site starts WebAuthn.
67
+ def declare_passkey_intent(rp_id)
68
+ raise ArgumentError, "rp_id is required" if rp_id.to_s.empty?
69
+
70
+ coordinator.declare_passkey_intent(token: token, name: name, rp_id: rp_id)
71
+ end
72
+
73
+ # Non-secret first-enrollment decisions attributed to this agent session.
74
+ def pending_passkey_ceremonies
75
+ coordinator.list_session_passkey_ceremonies(token: token, name: name)
76
+ end
77
+
78
+ def approve_passkey_ceremony(ceremony_id)
79
+ decide_session_passkey_ceremony(ceremony_id, "approve_custodial")
80
+ end
81
+
82
+ def decline_passkey_ceremony(ceremony_id)
83
+ decide_session_passkey_ceremony(ceremony_id, "decline")
84
+ end
85
+
86
+ # session.learn — author a learned-skill DRAFT from THIS session's latest completed run.
87
+ # Authoring is a separate model run, not a task tool. Returns a {LearnResult}: a
88
+ # registered draft, or a not-reusable run. (An ambiguous run is simply not authored —
89
+ # the clarification round lives on #teach, where a person can answer.) Lifecycle/serving is on
90
+ # the Computer — see {Computer#skills}. Uses the Computer token (ct_).
91
+ #
92
+ # **BLOCKS until the authoring run ends — typically 30–180 s.** Use this from
93
+ # CLI tools, backend workers, Sidekiq-class jobs, tests, or any caller that owns its own
94
+ # request budget. **If you're calling from a web handler behind a reverse proxy** (Cloudflare,
95
+ # ALB, GFE, Heroku — all have request timeouts in the 30–100 s range), use {#start_learn}
96
+ # and follow {#author_events} yourself — a sync call WILL hit the proxy's idle / total cap and
97
+ # the connection will be severed before the terminal event arrives, even though the Computer
98
+ # finishes the run.
99
+ #
100
+ # `scaffold:` is an OPTIONAL seed of the SKILL.md the authoring run should
101
+ # continue from — frontmatter, section headers, explicit parameter names, the "## Gotchas"
102
+ # you already know. The run preserves its structure and fills in the body from the
103
+ # navigation. Capped at 32 KiB. Nil/empty authors from scratch.
104
+ def learn(scaffold: nil)
105
+ kwargs = { token: @parent.computer_token, name: name }
106
+ kwargs[:scaffold] = scaffold unless scaffold.nil? || scaffold.empty?
107
+ # /learn returns 202 + {author_session, task_id} (streaming). Subscribe
108
+ # to the author event stream, await the terminal Result, and translate to
109
+ # the LearnResult shape existing callers expect.
110
+ accepted = ct_call { coordinator.learn_skill(**kwargs) }
111
+ LearnResult.new(self, AuthorTerminalTranslator.to_learn_result(await_author(accepted["author_session"])))
112
+ end
113
+
114
+ # session.start_learn(scaffold:) — non-blocking variant of #learn. Returns the raw
115
+ # 202 envelope (`{author_session, task_id, ...}`) WITHOUT awaiting the run's end.
116
+ # Follow {#author_events} with the envelope's `author_session` for progress and the
117
+ # terminal event.
118
+ #
119
+ # Use this when the wall-clock of the run could exceed the caller's request budget
120
+ # (e.g. a web handler behind a proxy with a ~100 s ceiling, while authoring runs can
121
+ # take minutes). Mirrors {AgentMode#run}'s 202 + events pattern. Uses the Computer
122
+ # token (ct_).
123
+ def start_learn(scaffold: nil)
124
+ kwargs = { token: @parent.computer_token, name: name }
125
+ kwargs[:scaffold] = scaffold unless scaffold.nil? || scaffold.empty?
126
+ ct_call { coordinator.learn_skill(**kwargs) }
127
+ end
128
+
129
+ # session.teach(goal:) — author a draft from the human demonstration just finalized on this
130
+ # session (take control → demonstrate → release finalizes a handoff; then teach reads it).
131
+ # Returns a clarify-able {TeachResult}; the draft is tainted (a demo may touch a login/MFA
132
+ # field), so you still review and activate it. Uses the Computer token (ct_).
133
+ #
134
+ # **BLOCKS until the authoring run ends — typically 30–180 s.** Same reverse-
135
+ # proxy caveat as {#learn}: if you're calling from a web handler behind a CDN / load
136
+ # balancer with a 30–100 s timeout, use {#start_teach} and follow {#author_events}
137
+ # yourself; the proxy will sever the sync call before the terminal event.
138
+ #
139
+ # `scaffold:` mirrors #learn — an optional seed the authoring run continues from.
140
+ # {TeachResult#clarify} forwards it to the clarification round so your skeleton
141
+ # is preserved across the round-trip.
142
+ def teach(goal:, handoff_id: nil, clarifications: nil, scaffold: nil)
143
+ kwargs = {
144
+ token: @parent.computer_token, name: name, goal: goal,
145
+ handoff_id: handoff_id, clarifications: clarifications
146
+ }
147
+ kwargs[:scaffold] = scaffold unless scaffold.nil? || scaffold.empty?
148
+ accepted = ct_call { coordinator.teach_skill(**kwargs) }
149
+ term = await_author(accepted["author_session"])
150
+ TeachResult.new(self, goal, scaffold,
151
+ AuthorTerminalTranslator.to_teach_result(term, handoff_id: accepted["handoff_id"]))
152
+ end
153
+
154
+ # session.start_teach(goal:, ...) — non-blocking variant of #teach. Same intent as
155
+ # {#start_learn}: starts teaching (or its clarification round when handoff_id +
156
+ # clarifications are provided) and returns the raw 202 envelope. Follow
157
+ # {#author_events} for the terminal event. Uses the Computer token (ct_).
158
+ def start_teach(goal:, handoff_id: nil, clarifications: nil, scaffold: nil)
159
+ kwargs = {
160
+ token: @parent.computer_token, name: name, goal: goal,
161
+ handoff_id: handoff_id, clarifications: clarifications
162
+ }
163
+ kwargs[:scaffold] = scaffold unless scaffold.nil? || scaffold.empty?
164
+ ct_call { coordinator.teach_skill(**kwargs) }
165
+ end
166
+
167
+ # session.start_refine(skill:, version:, guidance:) — non-blocking REFINE trigger.
168
+ # Revises an already-authored skill with a plain-language instruction. The named
169
+ # version's SKILL.md is the starting point, the instruction is the guidance, and —
170
+ # when this session holds a trustworthy completed turn — that turn's navigation is
171
+ # the evidence, so the session you refine FROM matters. The run registers a new
172
+ # draft version. Returns the raw 202 envelope (`{author_session, task_id, ...}`);
173
+ # follow {#author_events} for the terminal event. Guidance is capped at 64 KiB.
174
+ # Uses the Computer token (ct_).
175
+ def start_refine(skill:, version:, guidance:)
176
+ ct_call do
177
+ coordinator.refine_skill(
178
+ token: @parent.computer_token, name: name, skill: skill, version: version, guidance: guidance
179
+ )
180
+ end
181
+ end
182
+
183
+ # session.refine(skill:, version:, guidance:) — revise a skill version with a
184
+ # plain-language instruction and wait for the new draft. Returns a
185
+ # {LearnResult}. **BLOCKS until the authoring run ends**, like {#learn};
186
+ # behind a proxy with a short request timeout use {#start_refine} and
187
+ # follow {#author_events}. Uses the Computer token (ct_).
188
+ def refine(skill:, version:, guidance:)
189
+ accepted = ct_call do
190
+ coordinator.refine_skill(
191
+ token: @parent.computer_token, name: name, skill: skill, version: version, guidance: guidance
192
+ )
193
+ end
194
+ LearnResult.new(self, AuthorTerminalTranslator.to_learn_result(await_author(accepted["author_session"])))
195
+ end
196
+
197
+ # session.author_events(author_id:) — follow an authoring run started by {#start_learn},
198
+ # {#start_teach} or {#start_refine}. `author_id` is the `author_session` from the start
199
+ # call's 202 envelope. Yields each event as a typed, Hash-compatible {AgentEvent}; the
200
+ # run's last event is its `result` (`event.result`), whose
201
+ # `"terminal_reason"` says how the run ended. A dropped connection reopens
202
+ # from the last event seen; after five reconnects in a row that delivered
203
+ # nothing it raises StreamLostError. Pass `last_event_id:` (an event's
204
+ # `event_id`) to resume in another process. Returns the last seen event id. Without a
205
+ # block, returns an Enumerator that opens the stream only when iterated. Uses the
206
+ # session token, or the Computer token for a handle without one.
207
+ #
208
+ # @param author_id [String] the `author_session` from the start call.
209
+ # @param last_event_id [String, Integer, nil] resume after this event.
210
+ # @yieldparam event [PineSandbox::AgentEvent]
211
+ # @return [String, nil] the last seen event id.
212
+ def author_events(author_id:, last_event_id: nil, &block)
213
+ raise ArgumentError, "author_id is required" if author_id.to_s.empty?
214
+ return enum_for(:author_events, author_id: author_id, last_event_id: last_event_id) unless block
215
+
216
+ read_call do |read|
217
+ coordinator.follow_author_events(
218
+ token: read, name: name, author_id: author_id, last_event_id: last_event_id
219
+ ) { |event| block.call(AgentEvent.wrap(event)) }
220
+ end
221
+ end
222
+
223
+ # session.cancel_author(author_id:) — cancel an in-flight authoring run started by
224
+ # {#start_learn} / {#start_teach} / {#start_refine}. `author_id` is the `author_session`
225
+ # from the start call's 202 envelope (an opaque ID). The run ends with
226
+ # `terminal_reason: "canceled"` on {#author_events}. Returns the parsed 202
227
+ # body — `canceled: false` means the run had already finished (feedback, not an error).
228
+ # Uses the Computer token (ct_).
229
+ def cancel_author(author_id:)
230
+ ct_call { coordinator.cancel_author(token: @parent.computer_token, name: name, author_id: author_id) }
231
+ end
232
+
233
+ # session.author_skill — register a learned-skill draft DIRECTLY (your own agent
234
+ # wrote the SKILL.md, rather than an authoring run). `name:` is the skill's
235
+ # kebab-case name. Returns the draft meta. Uses the Computer token (ct_).
236
+ def author_skill(name:, skill_md:, reason:, origin: nil, tainted: nil)
237
+ ct_call do
238
+ coordinator.author_skill_draft(
239
+ token: @parent.computer_token, name: self.name, skill_name: name,
240
+ skill_md: skill_md, reason: reason, origin: origin, tainted: tainted
241
+ )
242
+ end
243
+ end
244
+
245
+ # Reads the session's latest /epoch payload. Use it to detect a browser
246
+ # or service restart and to read the current session_generation.
247
+ def epoch
248
+ coordinator.epoch(token: token, name: name)
249
+ end
250
+
251
+ def focus!
252
+ coordinator.focus(token: token, name: name)
253
+ self
254
+ end
255
+
256
+ # Streams an exec; yields each parsed PineSandbox::ExecEvent (type,
257
+ # text, data, ...). Returns a PineSandbox::ExecResult (stdout, stderr,
258
+ # exit_code, error_message). Block is optional.
259
+ def exec(command, cwd: nil, timeout_ms: nil, &block)
260
+ coordinator.exec(token: token, name: name, command: command, cwd: cwd, timeout_ms: timeout_ms, &block)
261
+ end
262
+
263
+ def tabs
264
+ payload = coordinator.list_tabs(token: token, name: name)
265
+ payload[:tabs].map { |t| Models::Tab.from_payload(t) }
266
+ end
267
+
268
+ # Lists files and directories under the session workdir.
269
+ #
270
+ # @param path [String, nil] workdir-relative directory to list; nil lists
271
+ # the workdir root.
272
+ # @param pattern [String, nil] optional base-name glob filter, e.g. "*.log".
273
+ # @return [Array<PineSandbox::Models::FileEntry>]
274
+ def list_files(path: nil, pattern: nil)
275
+ read_call { |read| coordinator.list_files(token: read, name: name, path: path, pattern: pattern) }
276
+ end
277
+
278
+ # Reads one file from the session workdir. With a block, yields the file
279
+ # in binary chunks as they arrive instead of returning it whole, so a large
280
+ # file never sits in memory; an error answer raises before any chunk.
281
+ #
282
+ # @param path [String] workdir-relative file path.
283
+ # @yieldparam chunk [String] the next bytes, binary encoding.
284
+ # @return [String, nil] raw file bytes with binary encoding; nil with a block.
285
+ def read_file(path, &block)
286
+ read_call { |read| coordinator.read_file(token: read, name: name, path: path, &block) }
287
+ end
288
+
289
+ # Deposits a material (an agent INPUT file) into the session's attachments/
290
+ # root. `filename` is a single name — path separators ("/", "..") are
291
+ # rejected. The attachments root is a SIBLING of the workdir that
292
+ # {#read_file}/{#list_files} serve, so the agent is told about a deposited
293
+ # material rather than finding it through {#read_file}.
294
+ #
295
+ # @param filename [String] single filename to deposit (no path separators).
296
+ # @param content [String] file bytes.
297
+ # @return [Hash] the response, e.g.
298
+ # { "root" => "attachments", "relative_path" => "in.txt", "size" => 5 }.
299
+ def write_file(filename, content)
300
+ read_call { |read| coordinator.write_file(token: read, name: name, path: filename, content: content) }
301
+ end
302
+
303
+ # Lists the session's artifacts — client uploads, claimed browser downloads,
304
+ # and every completed turn's agent outputs (durably captured at each turn's
305
+ # result). Newest-first.
306
+ #
307
+ # @param turn_id [String, nil] filter to one turn's artifacts.
308
+ # @return [Array<PineSandbox::Models::Artifact>]
309
+ def list_artifacts(turn_id: nil)
310
+ read_call { |read| coordinator.list_artifacts(token: read, name: name, turn_id: turn_id) }
311
+ end
312
+
313
+ # Reads one artifact's bytes by id. With a block, yields them in binary
314
+ # chunks as they arrive (see {#read_file}).
315
+ #
316
+ # @param id [String] the artifact id (from {#list_artifacts}).
317
+ # @yieldparam chunk [String] the next bytes, binary encoding.
318
+ # @return [String, nil] raw artifact bytes with binary encoding; nil with a block.
319
+ def read_artifact(id, &block)
320
+ read_call { |read| coordinator.read_artifact(token: read, name: name, id: id, &block) }
321
+ end
322
+
323
+ # An artifact's bytes as a stream, for copying onward without buffering
324
+ # the file: yields binary chunks as they arrive. Without a block, returns
325
+ # an Enumerator of the chunks that sends the request only when iterated
326
+ # (`session.open_artifact(id).each { |chunk| io.write(chunk) }`). An error
327
+ # answer raises before any chunk.
328
+ #
329
+ # @param id [String] the artifact id (from {#list_artifacts}).
330
+ # @yieldparam chunk [String] the next bytes, binary encoding.
331
+ # @return [nil, Enumerator]
332
+ def open_artifact(id, &block)
333
+ raise ArgumentError, "artifact id is required" if id.to_s.empty?
334
+ return enum_for(:open_artifact, id) unless block
335
+
336
+ read_call { |read| coordinator.read_artifact(token: read, name: name, id: id, &block) }
337
+ end
338
+
339
+ # Downloads the session's artifacts as a zip (optionally one turn's). With
340
+ # a block, yields the zip in binary chunks as they arrive (see {#read_file}).
341
+ #
342
+ # @param turn_id [String, nil] filter to one turn's artifacts.
343
+ # @yieldparam chunk [String] the next bytes, binary encoding.
344
+ # @return [String, nil] raw zip bytes with binary encoding; nil with a block.
345
+ def zip_artifacts(turn_id: nil, &block)
346
+ read_call { |read| coordinator.zip_artifacts(token: read, name: name, turn_id: turn_id, &block) }
347
+ end
348
+
349
+ # Deprecated: use #zip_artifacts.
350
+ def download_artifacts_zip(turn_id: nil, &block)
351
+ PineSandbox.deprecated_name("Session#download_artifacts_zip", "#zip_artifacts")
352
+ zip_artifacts(turn_id: turn_id, &block)
353
+ end
354
+
355
+ # Uploads bytes as a new artifact (created_by:upload). Storage is id-addressed
356
+ # and immutable — a repeated filename yields a distinct artifact.
357
+ #
358
+ # @param filename [String] a single filename (no path separators).
359
+ # @param content [String] the bytes.
360
+ # @return [PineSandbox::Models::Artifact]
361
+ def upload_artifact(filename, content)
362
+ read_call { |read| coordinator.upload_artifact(token: read, name: name, filename: filename, content: content) }
363
+ end
364
+
365
+ def session_generation
366
+ coordinator.list_tabs(token: token, name: name)[:session_generation]
367
+ end
368
+
369
+ def create_tab(url:, label: nil)
370
+ Models::Tab.from_payload(
371
+ coordinator.create_tab(token: token, name: name, url: url, label: label)
372
+ )
373
+ end
374
+
375
+ def patch_tab(target_id, active: nil, label: nil)
376
+ Models::Tab.from_payload(
377
+ coordinator.patch_tab(token: token, name: name, target_id: target_id, active: active, label: label)
378
+ )
379
+ end
380
+
381
+ def close_tab(target_id)
382
+ coordinator.close_tab(token: token, name: name, target_id: target_id)
383
+ end
384
+
385
+ # Destroy the session. Default leaves the session's files on disk; pass
386
+ # clean: true to delete them too. Deprecated alias of
387
+ # Computer#destroy_session, so it uses the Computer token as that does.
388
+ def destroy(clean: false)
389
+ PineSandbox.deprecated_name("Session#destroy", "Computer#destroy_session")
390
+ ct_call { coordinator.destroy_session(token: parent.computer_token, name: name, clean: clean) }
391
+ end
392
+
393
+ # Rebuild the shell after `Session#exec` returned 409 `terminal_lost`
394
+ # (the shell service restarted; the browser survived). After this, `exec`
395
+ # works again with the original PINE_* env + cwd. A second call against an
396
+ # already-active terminal returns 409 — treat that as success-equivalent
397
+ # (already rebuilt by another caller).
398
+ def recreate_terminal!
399
+ coordinator.recreate_terminal(token: token, name: name)
400
+ end
401
+
402
+ # Control surface.
403
+ #
404
+ # All methods below talk to `/v1/sessions/{name}/control[/*]` and
405
+ # `/v1/sessions/{name}/handoffs[/*]`. Mutating PATCH requires `ct_`
406
+ # (the Computer token, held by your backend) — `Session#token`
407
+ # is `ps_`-scoped, so the helpers use `parent.computer_token`. Read
408
+ # routes accept either; they pass `ps_` (Session#token).
409
+
410
+ # GET /v1/sessions/{name}/control. Returns a {ControlState} — the Hash
411
+ # { state:, etag: } — so callers can use the ETag as `If-Match:` on the
412
+ # next PATCH.
413
+ def control_state
414
+ read_call { |read| coordinator.get_control(token: read, name: name) }
415
+ end
416
+
417
+ # Raw PATCH /v1/sessions/{name}/control. Callers can supply an
418
+ # explicit `idempotency_key:` for cross-process retry orchestration.
419
+ #
420
+ # `actor_type:` is REQUIRED; the Computer returns 400
421
+ # /errors/actor-type-required when missing.
422
+ def update_control(controller: nil, idle_paused: nil, idle_deadline: nil,
423
+ actor_type:, if_match:, force: false, idempotency_key: nil)
424
+ body = { actor_type: actor_type }
425
+ body[:controller] = controller unless controller.nil?
426
+ body[:idle_paused] = idle_paused unless idle_paused.nil?
427
+ body[:idle_deadline] = idle_deadline unless idle_deadline.nil?
428
+ ct_call do
429
+ coordinator.patch_control(
430
+ token: parent.computer_token, name: name, body: body,
431
+ if_match: if_match, force: force, idempotency_key: idempotency_key
432
+ )
433
+ end
434
+ end
435
+
436
+ # Hand the session to a person: GET /control for the ETag, then PATCH
437
+ # with it. A concurrent change (412) re-reads the state and retries once
438
+ # with a fresh Idempotency-Key. `force: true` (or actor_type
439
+ # "admin_override") records an admin override, which skips If-Match and
440
+ # is not retried. Returns the new {ControlState}.
441
+ def take_control(actor_type: "user_click", force: false)
442
+ set_controller("human", actor_type: actor_type, force: force)
443
+ end
444
+
445
+ # Hand the session back to the agent. Same handshake as #take_control.
446
+ def release_control(actor_type: "user_click", force: false)
447
+ set_controller("agent", actor_type: actor_type, force: force)
448
+ end
449
+
450
+ # Deprecated: use #take_control.
451
+ def take_human!(actor_type:)
452
+ PineSandbox.deprecated_name("Session#take_human!", "#take_control")
453
+ take_control(actor_type: actor_type)
454
+ end
455
+
456
+ # Deprecated: use #release_control.
457
+ def release!(actor_type:)
458
+ PineSandbox.deprecated_name("Session#release!", "#release_control")
459
+ release_control(actor_type: actor_type)
460
+ end
461
+
462
+ # POST /v1/sessions/{name}/control/notify, with the current ETag read
463
+ # first. Accepts `ct_` OR the session's `ps_`; this uses the session's
464
+ # own token when the handle has one. Returns true.
465
+ def notify_human(reason:, detail: nil)
466
+ etag = control_state.fetch(:etag)
467
+ read_call do |read|
468
+ coordinator.post_control_notify(
469
+ token: read, name: name, reason: reason, detail: detail,
470
+ if_match: etag
471
+ )
472
+ end
473
+ end
474
+
475
+ # POST /v1/sessions/{name}/desktop-token — ct_-only (uses
476
+ # parent.computer_token). Mints a short-TTL browser-safe desktop-stream
477
+ # connection token (dt_…). Your server hands the returned token to the
478
+ # browser, which presents it when opening /vnc/connect; ct_/ps_ never
479
+ # reach the browser. Driving needs control of the session, not this
480
+ # token, so the same token serves a viewer and a driver. Re-mint before
481
+ # each (re)connect. Returns { token:, expires_at: }. 409
482
+ # (NoDesktopStream) when the session has no browser; 503
483
+ # (DesktopUnavailable) when streaming is off.
484
+ def desktop_token
485
+ ct_call { coordinator.mint_desktop_token(token: parent.computer_token, name: name) }
486
+ end
487
+
488
+ # Build the browser-safe {DelegatedConnection} for this session: the
489
+ # data-plane host, this session's name, the pinned spec major, and a
490
+ # freshly-minted short-TTL `dt_`. This is the ONE call a server makes to hand a
491
+ # browser everything it needs to connect the desktop — and NOTHING it
492
+ # shouldn't (no `ct_`/`ps_`/JWS). Re-call (server-side) to refresh `dt_`
493
+ # before each (re)connect; the web SDK drives that via a refresh callback
494
+ # to your server.
495
+ def delegate
496
+ dt = desktop_token
497
+ DelegatedConnection.new(
498
+ computer_host: coordinator.base_url,
499
+ session_name: name,
500
+ desktop_token: dt.fetch(:token),
501
+ desktop_token_expires_at: dt[:expires_at]
502
+ )
503
+ end
504
+
505
+ # GET /v1/sessions/{name}/handoffs?limit=&before=. Returns the response body
506
+ # { "handoffs" => [...], "next_before" => <cursor> } — pass next_before back as
507
+ # `before` to page older (absent = no more pages).
508
+ def handoffs(limit: nil, before: nil)
509
+ read_call { |read| coordinator.list_handoffs(token: read, name: name, limit: limit, before: before) }
510
+ end
511
+
512
+ # GET /v1/sessions/{name}/handoffs/{handoff_id}. Raises
513
+ # `PineSandbox::HandoffNotFound` (a subclass of NotFoundError) on
514
+ # 404, `HandoffExpired` (subclass of GoneError) on 410. The
515
+ # exception's `#handoff_id` accessor surfaces the same id the
516
+ # caller passed so error handlers can correlate without parsing
517
+ # the URL again.
518
+ def handoff(handoff_id)
519
+ read_call { |read| coordinator.get_handoff(token: read, name: name, handoff_id: handoff_id) }
520
+ end
521
+
522
+ # control_events streams the v1 SSE event feed and yields a typed
523
+ # struct (ControllerChanged / IdleWarning / HandoffCompleted /
524
+ # HandoffFailed) per emission. The block can raise to abort.
525
+ #
526
+ # Auto-resume: when the underlying HTTP stream closes (slow subscriber
527
+ # drop, server restart, network blip), it reconnects with the last seen
528
+ # event id as Last-Event-ID, waiting 0.5 s and doubling up to 5 s. After
529
+ # five reconnects in a row that delivered no event it raises
530
+ # StreamLostError; an error answer (401/403/404/…) raises at once.
531
+ # `max_reconnects:` additionally caps the total reconnects: once they are
532
+ # used up the stream returns (or raises its transport error).
533
+ # `reconnect_delay:` is accepted for compatibility and no longer used.
534
+ #
535
+ # Returns the most recently seen event id once the stream ends (block
536
+ # broke, max_reconnects exhausted, or `stop_when` returned true).
537
+ def control_events(last_event_id: nil, max_reconnects: nil, reconnect_delay: nil, stop_when: nil, &block)
538
+ raise ArgumentError, "block required" unless block
539
+
540
+ unless reconnect_delay.nil?
541
+ PineSandbox.deprecated_name("Session#control_events(reconnect_delay:)", "the shared reconnect backoff")
542
+ end
543
+ read_call do |read|
544
+ coordinator.follow_control_events(
545
+ token: read, name: name, last_event_id: last_event_id,
546
+ stop_when: stop_when, max_reconnects: max_reconnects, &block
547
+ )
548
+ end
549
+ end
550
+
551
+ private
552
+
553
+ def decide_session_passkey_ceremony(ceremony_id, decision)
554
+ raise ArgumentError, "ceremony_id is required" if ceremony_id.to_s.empty?
555
+
556
+ coordinator.decide_session_passkey_ceremony(
557
+ token: token, name: name, ceremony_id: ceremony_id, decision: decision
558
+ )
559
+ end
560
+
561
+ # Routes a parent-ct_ call through Computer#rebind_on_unauthorized so a
562
+ # rejected ct_ surfaces as the ONE documented typed signal
563
+ # (TokenRejectedError — a report, not an attach instruction) from Session
564
+ # methods too — never a raw 401. The ps_-scoped methods are deliberately
565
+ # NOT wrapped: a session token dies with its session, and re-attach
566
+ # cannot revive it.
567
+ def ct_call(&blk)
568
+ parent.rebind_on_unauthorized(&blk)
569
+ end
570
+
571
+ # The token for operations that accept either: the session's own, or the
572
+ # Computer token for a handle read without one (GET /sessions/{name}
573
+ # returns no ps_).
574
+ def read_token
575
+ token.to_s.empty? ? parent.computer_token : token
576
+ end
577
+
578
+ # Runs an either-token operation with #read_token. On the Computer token
579
+ # it gets the same rejected-ct_ signal as the ct_-tier methods; a session
580
+ # token's 401 stays an UnauthorizedError.
581
+ def read_call
582
+ return yield(token) unless token.to_s.empty?
583
+
584
+ ct_call { yield(parent.computer_token) }
585
+ end
586
+
587
+ # Waits out an authoring run started on this session.
588
+ def await_author(author_id)
589
+ read_call { |read| coordinator.await_author_terminal(token: read, name: name, author_id: author_id) }
590
+ end
591
+
592
+ def set_controller(controller, actor_type:, force:)
593
+ actor_type = "admin_override" if force
594
+ force = actor_type == "admin_override"
595
+ with_etag_retry(retry_precondition: !force) do |etag, key|
596
+ update_control(controller: controller, actor_type: actor_type, if_match: etag,
597
+ force: force, idempotency_key: key)
598
+ end
599
+ end
600
+
601
+ # with_etag_retry GETs the current state, calls the block with the
602
+ # ETag + an Idempotency-Key, retries ONCE on 412, then raises. The retry
603
+ # gets a FRESH key — see below.
604
+ def with_etag_retry(retry_precondition: true)
605
+ # The mutation is ct_-only (PATCH /control); GET /control accepts either
606
+ # token, so read with the session's own, or the ct_ for an adopted handle.
607
+ fetch = -> { coordinator.get_control(token: read_token, name: name) }
608
+ state = fetch.call
609
+ begin
610
+ yield state.fetch(:etag), SecureRandom.uuid
611
+ rescue PineSandbox::ApiError => e
612
+ raise unless retry_precondition && e.status == 412
613
+ # 412 → re-GET, retry with a NEW Idempotency-Key. A 412 leaves the
614
+ # first attempt's key reserved but never resolved, so reusing it would
615
+ # wait out the reservation and then fail with 409 "duplicate
616
+ # Idempotency-Key did not resolve within timeout". A fresh key is a
617
+ # fresh side-effect — exactly what the retry intends.
618
+ state = fetch.call
619
+ yield state.fetch(:etag), SecureRandom.uuid
620
+ end
621
+ end
622
+
623
+ def coordinator
624
+ parent.coordinator
625
+ end
626
+ end
627
+ end