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,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
|