pwn 0.5.729 → 0.5.732
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 -3
- data/documentation/Agent-Tool-Registry.md +1 -0
- data/documentation/Contributing.md +75 -0
- data/etc/default_skills/pwn/ai/agent/tools/mcp/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/mcp/SKILL.md +59 -0
- data/etc/default_skills/pwn/ai/mcp/combo_nation/SKILL.md +65 -0
- data/etc/default_skills/pwn/plugins/repl/SKILL.md +1 -0
- data/lib/pwn/ai/agent/tools/mcp.rb +40 -0
- data/lib/pwn/ai/mcp/combo_nation.rb +480 -0
- data/lib/pwn/ai/mcp.rb +377 -0
- data/lib/pwn/ai/tools/mcp.yaml +15 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/plugins/repl.rb +140 -1
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/tools/mcp_spec.rb +70 -0
- data/spec/lib/pwn/ai/agent/verification_spec.rb +51 -9
- data/spec/lib/pwn/ai/mcp/combo_nation_spec.rb +215 -0
- data/spec/lib/pwn/ai/mcp_spec.rb +123 -0
- data/spec/lib/pwn/ffi/adalm_pluto_spec.rb +25 -11
- data/spec/lib/pwn/ffi/fftw_spec.rb +7 -2
- data/spec/lib/pwn/ffi/hack_rf_spec.rb +8 -2
- data/spec/lib/pwn/ffi/liquid_spec.rb +21 -5
- data/spec/lib/pwn/ffi/rtl_sdr_spec.rb +27 -4
- data/spec/lib/pwn/ffi/soapy_sdr_spec.rb +8 -2
- data/spec/lib/pwn/ffi/volk_spec.rb +10 -4
- data/spec/lib/pwn/plugins/binary_analysis_spec.rb +41 -11
- data/spec/lib/pwn/plugins/repl_spec.rb +55 -1
- data/spec/lib/pwn/sdr/decoder/dsp_spec.rb +40 -15
- data/spec/spec_helper.rb +7 -0
- data/third_party/pwn_rdoc.jsonl +51 -0
- metadata +17 -7
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
|
|
7
|
+
module PWN
|
|
8
|
+
module AI
|
|
9
|
+
module MCP
|
|
10
|
+
# JSON-RPC 2.0 / MCP 2024-11-05 stdio client for combo.nation.
|
|
11
|
+
# Speaks the native menu-oriented tools over newline-delimited JSON;
|
|
12
|
+
# it does not scrape a terminal or enable hardware unless requested.
|
|
13
|
+
module ComboNation
|
|
14
|
+
PROTOCOL_VERSION = '2024-11-05'
|
|
15
|
+
DEFAULT_COMMAND = '/opt/combo.nation/combo.nation'
|
|
16
|
+
TOOLS = %w[
|
|
17
|
+
menu_catalog menu_main menu_dial_calibration menu_guess
|
|
18
|
+
hardware_connect operation_status operation_answer operation_cancel servo_pause
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
public_class_method def self.required_bins
|
|
22
|
+
%w[combo.nation]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Build the combo.nation MCP argv. Hardware remains off unless requested.
|
|
26
|
+
|
|
27
|
+
public_class_method def self.argv(opts = {})
|
|
28
|
+
command = (opts[:command] || DEFAULT_COMMAND).to_s
|
|
29
|
+
raise ArgumentError, 'command is required' if command.empty?
|
|
30
|
+
|
|
31
|
+
argv = [command, '--mcp']
|
|
32
|
+
Array(opts[:args]).each { |arg| argv << arg.to_s }
|
|
33
|
+
argv << '--mcp-allow-hardware' if opts[:allow_hardware]
|
|
34
|
+
argv
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Spawn or attach to combo.nation --mcp and complete initialize.
|
|
38
|
+
|
|
39
|
+
public_class_method def self.connect(opts = {})
|
|
40
|
+
timeout = (opts[:timeout] || 5).to_f
|
|
41
|
+
allow_hardware = opts[:allow_hardware] ? true : false
|
|
42
|
+
reader = opts[:reader]
|
|
43
|
+
writer = opts[:writer]
|
|
44
|
+
stdin = stdout = stderr = wait_thr = nil
|
|
45
|
+
unless reader && writer
|
|
46
|
+
command = argv(opts)
|
|
47
|
+
begin
|
|
48
|
+
stdin, stdout, stderr, wait_thr = Open3.popen3(*command)
|
|
49
|
+
rescue Errno::ENOENT
|
|
50
|
+
raise IOError, "combo.nation MCP executable not found: #{command.first}. Build combo.nation or pass command: to #{self}.connect."
|
|
51
|
+
end
|
|
52
|
+
reader = stdout
|
|
53
|
+
writer = stdin
|
|
54
|
+
end
|
|
55
|
+
session = {
|
|
56
|
+
reader: reader,
|
|
57
|
+
writer: writer,
|
|
58
|
+
stdin: stdin,
|
|
59
|
+
stdout: stdout,
|
|
60
|
+
stderr: stderr,
|
|
61
|
+
wait_thr: wait_thr,
|
|
62
|
+
mutex: Mutex.new,
|
|
63
|
+
next_id: 1,
|
|
64
|
+
timeout: timeout,
|
|
65
|
+
allow_hardware: allow_hardware,
|
|
66
|
+
stderr_buf: +''
|
|
67
|
+
}
|
|
68
|
+
drain_stderr(session: session)
|
|
69
|
+
begin
|
|
70
|
+
result = request(
|
|
71
|
+
session: session,
|
|
72
|
+
method: 'initialize',
|
|
73
|
+
params: {
|
|
74
|
+
'protocolVersion' => PROTOCOL_VERSION,
|
|
75
|
+
'capabilities' => {},
|
|
76
|
+
'clientInfo' => { 'name' => 'pwn', 'version' => PWN::VERSION.to_s }
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
notify(session: session, method: 'notifications/initialized')
|
|
80
|
+
session[:protocol_version] = result['protocolVersion']
|
|
81
|
+
session[:server_info] = result['serverInfo']
|
|
82
|
+
session
|
|
83
|
+
rescue StandardError
|
|
84
|
+
disconnect(session: session)
|
|
85
|
+
raise
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Close the MCP stdio session and reap a spawned combo.nation process.
|
|
90
|
+
|
|
91
|
+
public_class_method def self.disconnect(opts = {})
|
|
92
|
+
session = opts[:session]
|
|
93
|
+
return unless session.is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
close_io(io: session[:writer])
|
|
96
|
+
if session[:wait_thr]
|
|
97
|
+
begin
|
|
98
|
+
Timeout.timeout(session[:timeout] || 5) { session[:wait_thr].value }
|
|
99
|
+
rescue Timeout::Error
|
|
100
|
+
begin
|
|
101
|
+
Process.kill('TERM', session[:wait_thr].pid)
|
|
102
|
+
rescue StandardError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
begin
|
|
106
|
+
session[:wait_thr].value
|
|
107
|
+
rescue StandardError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
close_io(io: session[:reader])
|
|
113
|
+
close_io(io: session[:stderr])
|
|
114
|
+
session[:stderr_thread]&.join(1)
|
|
115
|
+
session
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Send a JSON-RPC request and return its result object.
|
|
119
|
+
|
|
120
|
+
public_class_method def self.request(opts = {})
|
|
121
|
+
session = session!(opts)
|
|
122
|
+
method = opts[:method].to_s
|
|
123
|
+
raise ArgumentError, 'method is required' if method.empty?
|
|
124
|
+
|
|
125
|
+
params = opts[:params]
|
|
126
|
+
timeout = (opts[:timeout] || session[:timeout] || 5).to_f
|
|
127
|
+
session[:mutex].synchronize do
|
|
128
|
+
ident = session[:next_id]
|
|
129
|
+
session[:next_id] += 1
|
|
130
|
+
message = { 'jsonrpc' => '2.0', 'id' => ident, 'method' => method }
|
|
131
|
+
message['params'] = params unless params.nil?
|
|
132
|
+
write_line(session: session, message: message)
|
|
133
|
+
response = read_json(session: session, timeout: timeout)
|
|
134
|
+
raise IOError, 'combo.nation MCP closed stdout' if response.nil?
|
|
135
|
+
raise IOError, 'combo.nation MCP response id mismatch' unless response['id'] == ident
|
|
136
|
+
|
|
137
|
+
if response['error']
|
|
138
|
+
error = response['error']
|
|
139
|
+
raise IOError, "combo.nation MCP error #{error['code']}: #{error['message']}"
|
|
140
|
+
end
|
|
141
|
+
response['result']
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Send a JSON-RPC notification with no response.
|
|
146
|
+
|
|
147
|
+
public_class_method def self.notify(opts = {})
|
|
148
|
+
session = session!(opts)
|
|
149
|
+
method = opts[:method].to_s
|
|
150
|
+
raise ArgumentError, 'method is required' if method.empty?
|
|
151
|
+
|
|
152
|
+
message = { 'jsonrpc' => '2.0', 'method' => method }
|
|
153
|
+
message['params'] = opts[:params] unless opts[:params].nil?
|
|
154
|
+
session[:mutex].synchronize { write_line(session: session, message: message) }
|
|
155
|
+
method
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# MCP ping.
|
|
159
|
+
|
|
160
|
+
public_class_method def self.ping(opts = {})
|
|
161
|
+
request(session: opts[:session], method: 'ping')
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# MCP tools/list.
|
|
165
|
+
|
|
166
|
+
public_class_method def self.list_tools(opts = {})
|
|
167
|
+
result = request(session: opts[:session], method: 'tools/list')
|
|
168
|
+
Array(result && result['tools'])
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# MCP tools/call with JSON text payloads parsed when possible.
|
|
172
|
+
|
|
173
|
+
public_class_method def self.call_tool(opts = {})
|
|
174
|
+
name = opts[:name].to_s
|
|
175
|
+
raise ArgumentError, 'name is required' if name.empty?
|
|
176
|
+
|
|
177
|
+
arguments = opts[:arguments]
|
|
178
|
+
arguments = {} if arguments.nil?
|
|
179
|
+
raise ArgumentError, 'arguments must be a Hash' unless arguments.is_a?(Hash)
|
|
180
|
+
|
|
181
|
+
result = request(
|
|
182
|
+
session: opts[:session],
|
|
183
|
+
method: 'tools/call',
|
|
184
|
+
params: { 'name' => name, 'arguments' => stringify(value: arguments) }
|
|
185
|
+
)
|
|
186
|
+
parse_tool_result(result: result)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# List actual main, dial calibration, and guess menus with string IDs.
|
|
190
|
+
|
|
191
|
+
public_class_method def self.menu_catalog(opts = {})
|
|
192
|
+
call_tool(session: opts[:session], name: 'menu_catalog')
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Select a main-menu option by exact string ID.
|
|
196
|
+
|
|
197
|
+
public_class_method def self.menu_main(opts = {})
|
|
198
|
+
menu_call(session: opts[:session], name: 'menu_main', option: opts[:option])
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Select a dial-calibration option by exact string ID.
|
|
202
|
+
|
|
203
|
+
public_class_method def self.menu_dial_calibration(opts = {})
|
|
204
|
+
menu_call(session: opts[:session], name: 'menu_dial_calibration', option: opts[:option])
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Select a guess-menu option by exact string ID such as 5.10.
|
|
208
|
+
|
|
209
|
+
public_class_method def self.menu_guess(opts = {})
|
|
210
|
+
menu_call(session: opts[:session], name: 'menu_guess', option: opts[:option])
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Start hardware initialization; requires --mcp-allow-hardware on the server.
|
|
214
|
+
|
|
215
|
+
public_class_method def self.hardware_connect(opts = {})
|
|
216
|
+
call_tool(session: opts[:session], name: 'hardware_connect')
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Read the current operation, pending prompt, and hardware flags.
|
|
220
|
+
|
|
221
|
+
public_class_method def self.operation_status(opts = {})
|
|
222
|
+
call_tool(session: opts[:session], name: 'operation_status')
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Answer the pending prompt by id.
|
|
226
|
+
|
|
227
|
+
public_class_method def self.operation_answer(opts = {})
|
|
228
|
+
prompt_id = opts[:prompt_id]
|
|
229
|
+
raise ArgumentError, 'prompt_id is required' if prompt_id.nil?
|
|
230
|
+
raise ArgumentError, 'value is required' unless opts.key?(:value)
|
|
231
|
+
|
|
232
|
+
call_tool(
|
|
233
|
+
session: opts[:session],
|
|
234
|
+
name: 'operation_answer',
|
|
235
|
+
arguments: { 'prompt_id' => prompt_id, 'value' => opts[:value] }
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Request cooperative cancellation of the active operation.
|
|
240
|
+
|
|
241
|
+
public_class_method def self.operation_cancel(opts = {})
|
|
242
|
+
call_tool(session: opts[:session], name: 'operation_cancel')
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Pause or resume the current servo move.
|
|
246
|
+
|
|
247
|
+
public_class_method def self.servo_pause(opts = {})
|
|
248
|
+
action = opts[:action].to_s
|
|
249
|
+
raise ArgumentError, 'action is required' unless %w[pause resume].include?(action)
|
|
250
|
+
|
|
251
|
+
call_tool(session: opts[:session], name: 'servo_pause', arguments: { 'action' => action })
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Poll operation_status until a terminal or requested state.
|
|
255
|
+
|
|
256
|
+
public_class_method def self.poll_until(opts = {})
|
|
257
|
+
session = opts[:session]
|
|
258
|
+
wanted = Array(opts[:states] || %w[completed failed cancelled]).map(&:to_s)
|
|
259
|
+
interval = (opts[:interval] || 1).to_f
|
|
260
|
+
timeout = (opts[:timeout] || 30).to_f
|
|
261
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
262
|
+
loop do
|
|
263
|
+
status = operation_status(session: session)
|
|
264
|
+
state = status.dig(:parsed, 'state').to_s
|
|
265
|
+
return status if wanted.include?(state)
|
|
266
|
+
|
|
267
|
+
raise Timeout::Error, "combo.nation operation still #{state.inspect}" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
268
|
+
|
|
269
|
+
sleep interval if interval.positive?
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
274
|
+
|
|
275
|
+
public_class_method def self.authors
|
|
276
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
public_class_method def self.help
|
|
280
|
+
puts "USAGE:
|
|
281
|
+
# List host binaries this module expects to be installed.
|
|
282
|
+
#{self}.required_bins
|
|
283
|
+
|
|
284
|
+
# Build combo.nation MCP argv without launching a process.
|
|
285
|
+
#{self}.argv(
|
|
286
|
+
command: 'optional - combo.nation executable path (default #{DEFAULT_COMMAND})',
|
|
287
|
+
args: 'optional - extra argv after --mcp',
|
|
288
|
+
allow_hardware: 'optional - append --mcp-allow-hardware (default false)'
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Spawn or attach to combo.nation --mcp and complete MCP initialize.
|
|
292
|
+
#{self}.connect(
|
|
293
|
+
command: 'optional - combo.nation executable path (default #{DEFAULT_COMMAND})',
|
|
294
|
+
args: 'optional - extra argv after --mcp',
|
|
295
|
+
allow_hardware: 'optional - pass --mcp-allow-hardware (default false; never implied)',
|
|
296
|
+
timeout: 'optional - seconds for handshake and subsequent reads (default 5)',
|
|
297
|
+
reader: 'optional - IO providing server stdout for tests',
|
|
298
|
+
writer: 'optional - IO consuming server stdin for tests'
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Close pipes and reap a spawned combo.nation process.
|
|
302
|
+
#{self}.disconnect(
|
|
303
|
+
session: 'required - Hash returned by connect'
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Send a JSON-RPC 2.0 request and return its result object.
|
|
307
|
+
#{self}.request(
|
|
308
|
+
session: 'required - Hash returned by connect',
|
|
309
|
+
method: 'required - JSON-RPC method name such as ping or tools/list',
|
|
310
|
+
params: 'optional - Hash or Array params object',
|
|
311
|
+
timeout: 'optional - seconds to wait for this response'
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Send a JSON-RPC notification that expects no response.
|
|
315
|
+
#{self}.notify(
|
|
316
|
+
session: 'required - Hash returned by connect',
|
|
317
|
+
method: 'required - notification method such as notifications/initialized',
|
|
318
|
+
params: 'optional - Hash params object'
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Send an MCP ping request.
|
|
322
|
+
#{self}.ping(
|
|
323
|
+
session: 'required - Hash returned by connect'
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# List tools advertised by the combo.nation MCP server.
|
|
327
|
+
#{self}.list_tools(
|
|
328
|
+
session: 'required - Hash returned by connect'
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
# MCP tools/call; JSON text content is parsed when valid.
|
|
332
|
+
#{self}.call_tool(
|
|
333
|
+
session: 'required - Hash returned by connect',
|
|
334
|
+
name: 'required - tool name advertised by tools/list',
|
|
335
|
+
arguments: 'optional - Hash of tool arguments (string menu IDs stay strings)'
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# List actual application menus and exact string option IDs.
|
|
339
|
+
#{self}.menu_catalog(
|
|
340
|
+
session: 'required - Hash returned by connect'
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# Select a main-menu option by exact string ID.
|
|
344
|
+
#{self}.menu_main(
|
|
345
|
+
session: 'required - Hash returned by connect',
|
|
346
|
+
option: 'required - exact menu ID string such as 0.3'
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Select a dial-calibration option by exact string ID.
|
|
350
|
+
#{self}.menu_dial_calibration(
|
|
351
|
+
session: 'required - Hash returned by connect',
|
|
352
|
+
option: 'required - exact menu ID string such as 3.99'
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# Select a guess-menu option by exact string ID.
|
|
356
|
+
#{self}.menu_guess(
|
|
357
|
+
session: 'required - Hash returned by connect',
|
|
358
|
+
option: 'required - exact menu ID string such as 5.10'
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# Start hardware initialization; the server still requires --mcp-allow-hardware.
|
|
362
|
+
#{self}.hardware_connect(
|
|
363
|
+
session: 'required - Hash returned by connect'
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Read the current operation, pending prompt, updates, and hardware flags.
|
|
367
|
+
#{self}.operation_status(
|
|
368
|
+
session: 'required - Hash returned by connect'
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# Answer the pending prompt by id.
|
|
372
|
+
#{self}.operation_answer(
|
|
373
|
+
session: 'required - Hash returned by connect',
|
|
374
|
+
prompt_id: 'required - integer prompt id from operation_status',
|
|
375
|
+
value: 'required - number, exact option string, rotation plan, or true for ack'
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
# Request cooperative cancellation of the active operation.
|
|
379
|
+
#{self}.operation_cancel(
|
|
380
|
+
session: 'required - Hash returned by connect'
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# Pause or resume the current servo move.
|
|
384
|
+
#{self}.servo_pause(
|
|
385
|
+
session: 'required - Hash returned by connect',
|
|
386
|
+
action: 'required - pause or resume'
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# Poll operation_status until a requested state; the first reply is not completion.
|
|
390
|
+
#{self}.poll_until(
|
|
391
|
+
session: 'required - Hash returned by connect',
|
|
392
|
+
states: 'optional - Array of state strings (default completed failed cancelled)',
|
|
393
|
+
interval: 'optional - seconds between polls (default 1)',
|
|
394
|
+
timeout: 'optional - seconds before Timeout::Error (default 30)'
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Print the AUTHOR(S) string for this module.
|
|
398
|
+
#{self}.authors
|
|
399
|
+
"
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
private_class_method def self.session!(opts = {})
|
|
403
|
+
session = opts[:session]
|
|
404
|
+
raise ArgumentError, 'session is required' unless session.is_a?(Hash) && session[:reader] && session[:writer]
|
|
405
|
+
|
|
406
|
+
session
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
private_class_method def self.menu_call(opts = {})
|
|
410
|
+
option = opts[:option].to_s
|
|
411
|
+
raise ArgumentError, 'option is required' if option.empty?
|
|
412
|
+
|
|
413
|
+
call_tool(session: opts[:session], name: opts[:name], arguments: { 'option' => option })
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
private_class_method def self.write_line(opts = {})
|
|
417
|
+
session = opts[:session]
|
|
418
|
+
session[:writer].write("#{JSON.generate(opts[:message])}\n")
|
|
419
|
+
session[:writer].flush
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
private_class_method def self.read_json(opts = {})
|
|
423
|
+
session = opts[:session]
|
|
424
|
+
line = Timeout.timeout(opts[:timeout]) { session[:reader].gets }
|
|
425
|
+
return if line.nil?
|
|
426
|
+
|
|
427
|
+
JSON.parse(line)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
private_class_method def self.parse_tool_result(opts = {})
|
|
431
|
+
result = opts[:result] || {}
|
|
432
|
+
text = result.dig('content', 0, 'text')
|
|
433
|
+
parsed = begin
|
|
434
|
+
JSON.parse(text)
|
|
435
|
+
rescue JSON::ParserError, TypeError
|
|
436
|
+
text
|
|
437
|
+
end
|
|
438
|
+
{
|
|
439
|
+
content: result['content'],
|
|
440
|
+
is_error: result['isError'] == true,
|
|
441
|
+
text: text,
|
|
442
|
+
parsed: parsed
|
|
443
|
+
}
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
private_class_method def self.stringify(opts = {})
|
|
447
|
+
value = opts[:value]
|
|
448
|
+
case value
|
|
449
|
+
when Hash
|
|
450
|
+
value.to_h { |key, child| [key.to_s, stringify(value: child)] }
|
|
451
|
+
when Array
|
|
452
|
+
value.map { |child| stringify(value: child) }
|
|
453
|
+
else
|
|
454
|
+
value
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
private_class_method def self.drain_stderr(opts = {})
|
|
459
|
+
session = opts[:session]
|
|
460
|
+
stderr = session[:stderr]
|
|
461
|
+
return unless stderr
|
|
462
|
+
|
|
463
|
+
session[:stderr_thread] = Thread.new do
|
|
464
|
+
Thread.current.report_on_exception = false
|
|
465
|
+
stderr.each { |chunk| session[:stderr_buf] << chunk }
|
|
466
|
+
rescue IOError, Errno::EPIPE
|
|
467
|
+
nil
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
private_class_method def self.close_io(opts = {})
|
|
472
|
+
io = opts[:io]
|
|
473
|
+
io.close unless io.nil? || io.closed?
|
|
474
|
+
rescue IOError
|
|
475
|
+
nil
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|