pwn 0.5.740 → 0.5.742
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 +1 -1
- data/etc/default_skills/pwn/plugins/sandbox/driver/SKILL.md +8 -1
- data/lib/pwn/ai/agent/registry.rb +20 -1
- data/lib/pwn/ai/grok.rb +5 -3
- data/lib/pwn/ai/ollama.rb +1 -1
- data/lib/pwn/ai/open_web_ui.rb +1 -1
- data/lib/pwn/plugins/repl/mesh.rb +129 -8
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/registry_spec.rb +27 -0
- data/spec/lib/pwn/ai/grok_spec.rb +105 -0
- data/spec/lib/pwn/ai/ollama_spec.rb +17 -0
- data/spec/lib/pwn/ai/open_web_ui_spec.rb +17 -0
- data/spec/lib/pwn/plugins/repl_mesh_channel_route_spec.rb +6 -0
- data/spec/lib/pwn/plugins/repl_mesh_history_spec.rb +122 -0
- data/third_party/pwn_rdoc.jsonl +1 -0
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aae26246e4b23381c5f727d27100f6a10d37d955f2c9a875ad63274d3397219b
|
|
4
|
+
data.tar.gz: 1a0abc7df30321bd4618a8db3d356fa72e9b6cc13f6055b5f71bf4fb1b4ec90a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b680d2d2d3eb756642d68a6309a36a5d551100131b58323954bd402c73f554515304ecfb005a42e96ab15a385f16d717495cf527c2bf17c87434567401d0dd0
|
|
7
|
+
data.tar.gz: 7b51570ad19e664e45ca69925619fdbdda6272f985d8627f4c247815b469de19cb11bdfa942433480bbc38792127a8b39e45e56c08c8c73ffe517318ad004396
|
data/Gemfile
CHANGED
|
@@ -28,13 +28,20 @@ Class methods take `(opts = {})` and read `opts`.
|
|
|
28
28
|
|
|
29
29
|
```ruby
|
|
30
30
|
PWNSandboxDriver.help
|
|
31
|
-
PWNSandboxDriver.
|
|
31
|
+
PWNSandboxDriver.available?(opts)
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
## Public methods
|
|
35
35
|
|
|
36
36
|
- `authors`
|
|
37
37
|
- `help`
|
|
38
|
+
- `available?`
|
|
39
|
+
- `bounded`
|
|
40
|
+
- `execute`
|
|
41
|
+
- `fuzz`
|
|
42
|
+
- `inside`
|
|
43
|
+
- `main`
|
|
44
|
+
- `snapshot`
|
|
38
45
|
|
|
39
46
|
## Source
|
|
40
47
|
|
|
@@ -144,12 +144,31 @@ module PWN
|
|
|
144
144
|
|
|
145
145
|
pool = apply_preference({ items: pool }.merge(pref_fwd))
|
|
146
146
|
pool.map do |entry|
|
|
147
|
-
schema = entry.schema
|
|
147
|
+
schema = entry.schema.merge(parameters: explicit_root_types(schema: entry.schema[:parameters]))
|
|
148
148
|
schema = schema.merge(description: "#{schema[:description]} #{mcp[:description]}") if entry.name == 'mcp'
|
|
149
149
|
{ type: 'function', function: schema }
|
|
150
150
|
end
|
|
151
151
|
end
|
|
152
152
|
|
|
153
|
+
# Providers inspect union branches independently of their object parent.
|
|
154
|
+
# Inherit only along same-instance combinators, never into properties or
|
|
155
|
+
# array items (which may legitimately be scalar/object unions).
|
|
156
|
+
private_class_method def self.explicit_root_types(opts = {})
|
|
157
|
+
schema = opts[:schema]
|
|
158
|
+
return schema unless schema.is_a?(Hash)
|
|
159
|
+
|
|
160
|
+
result = schema.dup
|
|
161
|
+
type = schema[:type] || schema['type'] || opts[:inherited_type]
|
|
162
|
+
result[:type] = type if type && !schema.key?(:type) && !schema.key?('type')
|
|
163
|
+
%i[anyOf oneOf allOf].each do |combiner|
|
|
164
|
+
key = schema.key?(combiner) ? combiner : combiner.to_s
|
|
165
|
+
next unless schema[key].is_a?(Array)
|
|
166
|
+
|
|
167
|
+
result[key] = schema[key].map { |branch| explicit_root_types(schema: branch, inherited_type: type) }
|
|
168
|
+
end
|
|
169
|
+
result
|
|
170
|
+
end
|
|
171
|
+
|
|
153
172
|
# Supported Method Parameters::
|
|
154
173
|
# order = PWN::AI::Agent::Registry.preference_order(
|
|
155
174
|
# order: 'optional - Array of tool names; explicit nil/[] disables',
|
data/lib/pwn/ai/grok.rb
CHANGED
|
@@ -476,7 +476,7 @@ module PWN
|
|
|
476
476
|
extra: '429 retries exhausted', error: e
|
|
477
477
|
)
|
|
478
478
|
end
|
|
479
|
-
|
|
479
|
+
raise
|
|
480
480
|
end
|
|
481
481
|
sleep(PWN::AI::HttpRetry.retry_after_s(response: e.response, retry_count: retry_count) + rand(0.3..5.0))
|
|
482
482
|
retry
|
|
@@ -497,8 +497,10 @@ module PWN
|
|
|
497
497
|
nil
|
|
498
498
|
end
|
|
499
499
|
rescue RestClient::ExceptionWithResponse => e
|
|
500
|
-
|
|
501
|
-
|
|
500
|
+
# Keep the HTTP type/response for callers, and surface provider diagnostics
|
|
501
|
+
# instead of turning failed requests into nil or unparseable JSON text.
|
|
502
|
+
e.message = "Grok HTTP #{e.http_code} (#{http_method.to_s.upcase} #{rest_call}): #{e.response&.body}"
|
|
503
|
+
raise
|
|
502
504
|
rescue StandardError => e
|
|
503
505
|
case e.message
|
|
504
506
|
when '400 Bad Request', '404 Resource Not Found'
|
data/lib/pwn/ai/ollama.rb
CHANGED
data/lib/pwn/ai/open_web_ui.rb
CHANGED
|
@@ -6,6 +6,7 @@ require 'json'
|
|
|
6
6
|
require 'base64'
|
|
7
7
|
require 'pry'
|
|
8
8
|
require 'reline'
|
|
9
|
+
require 'io/wait'
|
|
9
10
|
|
|
10
11
|
module PWN
|
|
11
12
|
module Plugins
|
|
@@ -134,10 +135,12 @@ module PWN
|
|
|
134
135
|
|
|
135
136
|
# Curses owns input and rendering until this command returns to Pry.
|
|
136
137
|
PWN.const_set(:MeshEvents, Queue.new)
|
|
137
|
-
PWN::Plugins::REPL.send(:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
PWN::Plugins::REPL.send(:mesh_capture_output, run: proc {
|
|
139
|
+
PWN::Plugins::REPL.send(:mesh_start_rx!, env: meshtastic_env, obj: mesh_obj)
|
|
140
|
+
PWN::Plugins::REPL.send(:mesh_refresh_ui!, env: meshtastic_env)
|
|
141
|
+
PWN::Plugins::REPL.send(:mesh_ui_puts, text: 'Ready. /menu opens settings. Incoming channel messages appear here.')
|
|
142
|
+
PWN::Plugins::REPL.send(:mesh_console_loop, pry: pi)
|
|
143
|
+
})
|
|
141
144
|
rescue StandardError => e
|
|
142
145
|
raise e
|
|
143
146
|
ensure
|
|
@@ -146,6 +149,15 @@ module PWN
|
|
|
146
149
|
end
|
|
147
150
|
end
|
|
148
151
|
PWN_MESH_TRANSPORTS = %i[serial bluetooth tcp mqtt].freeze
|
|
152
|
+
# meshtastic 0.0.184 loses protobuf defaults in Data#to_h. Empty
|
|
153
|
+
# decoded data is not an unsupported application payload.
|
|
154
|
+
EMPTY_DATA_DECODER = Module.new do
|
|
155
|
+
define_method(:decode_payload) do |opts = {}|
|
|
156
|
+
return nil if opts[:payload].nil? && opts[:msg_type].nil?
|
|
157
|
+
|
|
158
|
+
super(opts)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
149
161
|
|
|
150
162
|
# Meshtastic transport for pwn-mesh: auto (probe order) or a pinned kind.
|
|
151
163
|
class << PWN::Plugins::REPL # rubocop:disable Metrics/ClassLength
|
|
@@ -557,6 +569,7 @@ module PWN
|
|
|
557
569
|
|
|
558
570
|
# Subscribe for inbound TEXT_MESSAGE_APP frames.
|
|
559
571
|
def mesh_subscribe(opts = {})
|
|
572
|
+
Meshtastic::MeshInterface.prepend(Mesh::EMPTY_DATA_DECODER) unless Meshtastic::MeshInterface.ancestors.include?(Mesh::EMPTY_DATA_DECODER)
|
|
560
573
|
env = opts[:env] || {}
|
|
561
574
|
obj = opts[:obj]
|
|
562
575
|
psks = opts[:psks]
|
|
@@ -1002,7 +1015,13 @@ module PWN
|
|
|
1002
1015
|
event = PWN::MeshEvents.pop(true)
|
|
1003
1016
|
next if event[:obj] && (!PWN.const_defined?(:MeshObj) || !event[:obj].equal?(PWN::MeshObj))
|
|
1004
1017
|
|
|
1005
|
-
|
|
1018
|
+
if event[:notice]
|
|
1019
|
+
mesh_notice(text: event[:notice])
|
|
1020
|
+
elsif event[:msg]
|
|
1021
|
+
mesh_handle_rx(msg: event[:msg])
|
|
1022
|
+
else
|
|
1023
|
+
mesh_ui_puts(text: event[:text])
|
|
1024
|
+
end
|
|
1006
1025
|
end
|
|
1007
1026
|
rescue ThreadError
|
|
1008
1027
|
nil
|
|
@@ -1059,6 +1078,10 @@ module PWN
|
|
|
1059
1078
|
|
|
1060
1079
|
def mesh_ui_puts(opts = {})
|
|
1061
1080
|
text = opts[:text].to_s
|
|
1081
|
+
if PWN.const_defined?(:MeshEvents) && text.match?(/warning|error|failed|disconnect|persist skipped|unknown command/i)
|
|
1082
|
+
PWN::MeshEvents << { notice: text }
|
|
1083
|
+
return text
|
|
1084
|
+
end
|
|
1062
1085
|
if PWN.const_defined?(:MeshRxBodyWin) && PWN.const_defined?(:MeshMutex)
|
|
1063
1086
|
win = PWN.const_get(:MeshRxBodyWin)
|
|
1064
1087
|
mutex = PWN.const_get(:MeshMutex)
|
|
@@ -1079,6 +1102,79 @@ module PWN
|
|
|
1079
1102
|
text
|
|
1080
1103
|
end
|
|
1081
1104
|
|
|
1105
|
+
def mesh_capture_output(opts = {})
|
|
1106
|
+
original_out = $stdout
|
|
1107
|
+
original_err = $stderr
|
|
1108
|
+
input, output = IO.pipe
|
|
1109
|
+
output.sync = true
|
|
1110
|
+
events = PWN::MeshEvents
|
|
1111
|
+
collector = Thread.new do
|
|
1112
|
+
loop do
|
|
1113
|
+
text = input.readpartial(4096)
|
|
1114
|
+
begin
|
|
1115
|
+
text << input.readpartial(4096) while text.bytesize < 16_384 && input.wait_readable(0.02)
|
|
1116
|
+
rescue EOFError
|
|
1117
|
+
# Flush the last diagnostic even when the TUI is closing.
|
|
1118
|
+
end
|
|
1119
|
+
events << { notice: text.force_encoding(Encoding::UTF_8).scrub }
|
|
1120
|
+
end
|
|
1121
|
+
rescue IOError
|
|
1122
|
+
nil
|
|
1123
|
+
end
|
|
1124
|
+
$stdout = output
|
|
1125
|
+
$stderr = output
|
|
1126
|
+
opts[:run].call
|
|
1127
|
+
ensure
|
|
1128
|
+
$stdout = original_out
|
|
1129
|
+
$stderr = original_err
|
|
1130
|
+
output&.close
|
|
1131
|
+
collector&.join(1)
|
|
1132
|
+
collector&.kill
|
|
1133
|
+
input&.close
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
def mesh_notice(opts = {})
|
|
1137
|
+
width = [Curses.cols - 2, 80].min
|
|
1138
|
+
lines = mesh_wrap_text(text: opts[:text].to_s, width: [width - 4, 1].max)
|
|
1139
|
+
height = [lines.size + 5, Curses.lines - 2].min
|
|
1140
|
+
win = Curses::Window.new(height, width, (Curses.lines - height) / 2, (Curses.cols - width) / 2)
|
|
1141
|
+
win.keypad(true)
|
|
1142
|
+
offset = 0
|
|
1143
|
+
loop do
|
|
1144
|
+
win.erase
|
|
1145
|
+
mesh_box!(win: win)
|
|
1146
|
+
win.setpos(0, 2)
|
|
1147
|
+
win.attron(Curses.color_pair(20) | Curses::A_BOLD)
|
|
1148
|
+
win.addstr(' NOTICE ')
|
|
1149
|
+
win.attroff(Curses.color_pair(20) | Curses::A_BOLD)
|
|
1150
|
+
lines.drop(offset).first([height - 4, 1].max).each_with_index do |line, index|
|
|
1151
|
+
win.setpos(index + 1, 2)
|
|
1152
|
+
win.addstr(line)
|
|
1153
|
+
end
|
|
1154
|
+
win.setpos(height - 2, (width - 2) / 2)
|
|
1155
|
+
win.attron(Curses.color_pair(20) | Curses::A_BOLD | Curses::A_REVERSE)
|
|
1156
|
+
win.addstr('Ok')
|
|
1157
|
+
win.attroff(Curses.color_pair(20) | Curses::A_BOLD | Curses::A_REVERSE)
|
|
1158
|
+
win.refresh
|
|
1159
|
+
key = opts[:getch] ? opts[:getch].call : win.getch
|
|
1160
|
+
break if [10, 13, "\n", "\r", Curses::KEY_ENTER].include?(key)
|
|
1161
|
+
|
|
1162
|
+
offset = [offset - 1, 0].max if key == Curses::KEY_UP
|
|
1163
|
+
offset += 1 if key == Curses::KEY_DOWN && offset < lines.size - (height - 4)
|
|
1164
|
+
end
|
|
1165
|
+
ensure
|
|
1166
|
+
win&.close
|
|
1167
|
+
%i[MeshRxHeaderWin MeshRxFrameWin MeshRxBodyWin MeshTxWin].each do |name|
|
|
1168
|
+
next unless PWN.const_defined?(name)
|
|
1169
|
+
|
|
1170
|
+
pane = PWN.const_get(name)
|
|
1171
|
+
pane.touch
|
|
1172
|
+
pane.refresh
|
|
1173
|
+
end
|
|
1174
|
+
end
|
|
1175
|
+
|
|
1176
|
+
private :mesh_capture_output, :mesh_notice
|
|
1177
|
+
|
|
1082
1178
|
# Drop the submitted TX buffer so the next prompt is empty (Reline keeps the old line).
|
|
1083
1179
|
def mesh_reset_input!(opts = {})
|
|
1084
1180
|
return opts if PWN.const_defined?(:MeshEvents)
|
|
@@ -1264,7 +1360,25 @@ module PWN
|
|
|
1264
1360
|
psks = mesh_active_psks(env: env) if psks.empty?
|
|
1265
1361
|
PWN.const_set(:MeshRxState, { last_from: nil, last_line: nil }) unless PWN.const_defined?(:MeshRxState)
|
|
1266
1362
|
events = PWN.const_defined?(:MeshEvents) ? PWN::MeshEvents : Queue.new
|
|
1363
|
+
kind = mesh_bound_transport(env: env)
|
|
1364
|
+
interval = Float(opts.fetch(:heartbeat_interval, 30))
|
|
1365
|
+
raise ArgumentError, 'heartbeat interval must be positive and finite' unless interval.positive? && interval.finite?
|
|
1366
|
+
|
|
1267
1367
|
thread = Thread.new do
|
|
1368
|
+
heartbeat = if obj.is_a?(Hash) && %i[serial bluetooth tcp].include?(kind)
|
|
1369
|
+
Thread.new do
|
|
1370
|
+
transport = { serial: Meshtastic::Serial, bluetooth: Meshtastic::Bluetooth, tcp: Meshtastic::TCP }.fetch(kind)
|
|
1371
|
+
bytes = Meshtastic::ToRadio.new(heartbeat: Meshtastic::Heartbeat.new(nonce: 0)).to_proto
|
|
1372
|
+
loop do
|
|
1373
|
+
break if obj[:closing]
|
|
1374
|
+
|
|
1375
|
+
transport.send_to_radio({ "#{kind}_obj": obj, to_radio: bytes })
|
|
1376
|
+
sleep interval
|
|
1377
|
+
end
|
|
1378
|
+
rescue StandardError => e
|
|
1379
|
+
events << { text: "RX keepalive failed: #{e.class}: #{e.message}", obj: obj }
|
|
1380
|
+
end
|
|
1381
|
+
end
|
|
1268
1382
|
mesh_subscribe(
|
|
1269
1383
|
env: env,
|
|
1270
1384
|
obj: obj,
|
|
@@ -1276,6 +1390,9 @@ module PWN
|
|
|
1276
1390
|
events << { text: 'RX stopped: transport stream closed. Use /transport to reconnect.', obj: obj }
|
|
1277
1391
|
rescue StandardError => e
|
|
1278
1392
|
events << { text: "RX failed: #{e.class}: #{e.message}", obj: obj }
|
|
1393
|
+
ensure
|
|
1394
|
+
heartbeat&.kill
|
|
1395
|
+
heartbeat&.join
|
|
1279
1396
|
end
|
|
1280
1397
|
PWN.send(:remove_const, :MeshSubThread) if PWN.const_defined?(:MeshSubThread)
|
|
1281
1398
|
PWN.const_set(:MeshSubThread, thread)
|
|
@@ -1546,13 +1663,17 @@ module PWN
|
|
|
1546
1663
|
if decoded[:emoji].to_i.zero?
|
|
1547
1664
|
messages[[scope, id]] = { from: opts[:from], text: text } if id.positive?
|
|
1548
1665
|
messages.shift while messages.size > 1000
|
|
1549
|
-
return text
|
|
1666
|
+
return text unless decoded[:reply_id].to_i.positive?
|
|
1550
1667
|
end
|
|
1551
1668
|
|
|
1552
1669
|
original = messages[[scope, decoded[:reply_id].to_i]]
|
|
1553
1670
|
original ||= mesh_reaction_original(reply_id: decoded[:reply_id], index: opts[:index])
|
|
1554
1671
|
target = original ? "#{original[:from]} >> #{original[:text]}" : "original message unavailable (packet #{decoded[:reply_id].to_i})"
|
|
1555
|
-
|
|
1672
|
+
if decoded[:emoji].to_i.positive?
|
|
1673
|
+
"Reacted to: \"#{target}\" with: #{text}."
|
|
1674
|
+
else
|
|
1675
|
+
"Replied to: \"#{target}\" with: #{text}"
|
|
1676
|
+
end
|
|
1556
1677
|
end
|
|
1557
1678
|
|
|
1558
1679
|
def mesh_reaction_original(opts = {})
|
|
@@ -2058,7 +2179,7 @@ module PWN
|
|
|
2058
2179
|
end
|
|
2059
2180
|
true
|
|
2060
2181
|
rescue StandardError => e
|
|
2061
|
-
|
|
2182
|
+
mesh_ui_puts(text: "[pwn-mesh] persist skipped: #{e.class}: #{e.message}")
|
|
2062
2183
|
false
|
|
2063
2184
|
end
|
|
2064
2185
|
|
data/lib/pwn/version.rb
CHANGED
|
@@ -47,6 +47,33 @@ describe PWN::AI::Agent::Registry do
|
|
|
47
47
|
expect(names.length).to eq(described_class::CORE_TOOLS.length)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
it 'exports explicitly object-typed root alternatives without changing registered schemas' do
|
|
51
|
+
described_class.discover
|
|
52
|
+
original = Marshal.dump(described_class.lookup(name: 'artifact_read').schema)
|
|
53
|
+
definitions = described_class.definitions(core_only: false, top_k: 0)
|
|
54
|
+
check = lambda do |schema|
|
|
55
|
+
%i[anyOf oneOf allOf].each do |key|
|
|
56
|
+
Array(schema[key]).each do |branch|
|
|
57
|
+
expect(branch[:type]).to eq('object')
|
|
58
|
+
check.call(branch)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
definitions.each { |tool| check.call(tool[:function][:parameters]) }
|
|
63
|
+
tool = definitions.find { |entry| entry[:function][:name] == 'artifact_read' }
|
|
64
|
+
expect(tool[:function][:parameters][:anyOf].map { |branch| branch[:required] }).to eq([%w[handle], %w[path], %w[ref]])
|
|
65
|
+
expect(Marshal.dump(described_class.lookup(name: 'artifact_read').schema)).to eq(original)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'preserves property-level scalar unions and explicit branch types' do
|
|
69
|
+
schema = { 'type' => 'object', 'oneOf' => [{ 'required' => ['value'] }],
|
|
70
|
+
'properties' => { 'value' => { 'anyOf' => [{ 'type' => 'string' }, { 'type' => 'object' }] } } }
|
|
71
|
+
normalized = described_class.send(:explicit_root_types, schema: schema)
|
|
72
|
+
expect(normalized['oneOf'].first[:type]).to eq('object')
|
|
73
|
+
expect(normalized['properties']).to eq(schema['properties'])
|
|
74
|
+
expect(schema['oneOf'].first).to eq('required' => ['value'])
|
|
75
|
+
end
|
|
76
|
+
|
|
50
77
|
it 'advertises the MCP broker for a named backend in the normal core-only prompt path' do
|
|
51
78
|
described_class.discover
|
|
52
79
|
request = 'Use combo.nation to inspect the menu catalog'
|
|
@@ -18,6 +18,111 @@ describe PWN::AI::Grok do
|
|
|
18
18
|
expect(src).to match(/openai_wire_messages/)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
describe '.chat_with_tools HTTP failures' do
|
|
22
|
+
let(:messages) { [{ role: 'user', content: 'hello' }] }
|
|
23
|
+
let(:body) { '{"error":"Invalid schema: tools[0].function.parameters requires properties"}' }
|
|
24
|
+
let(:response) { instance_double(RestClient::Response, code: 400, body: body, to_s: body) }
|
|
25
|
+
let(:error) { RestClient::BadRequest.new(response) }
|
|
26
|
+
|
|
27
|
+
before do
|
|
28
|
+
stub_const('PWN::Env', { ai: { grok: { key: 'test-key', model: 'test-model' } } })
|
|
29
|
+
allow(PWN::Plugins::TransparentBrowser).to receive(:open).with(browser_type: :rest).and_return(browser: RestClient)
|
|
30
|
+
allow(PWN::AI::Agent::ToolGuard).to receive(:protect_http!)
|
|
31
|
+
allow(PWN::AI::Agent::PromptCache).to receive(:enabled?).and_return(false)
|
|
32
|
+
allow(PWN::Plugins::TTYSpinner).to receive(:stop)
|
|
33
|
+
allow(RestClient::Request).to receive(:execute).and_raise(error)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'serializes explicit artifact_read branch types from the real registry' do
|
|
37
|
+
tools = PWN::AI::Agent::Registry.definitions(core_only: true)
|
|
38
|
+
allow(RestClient::Request).to receive(:execute).and_return(
|
|
39
|
+
'{"choices":[{"message":{"role":"assistant","content":"hello"}}]}'
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
described_class.chat_with_tools(messages: messages, tools: tools)
|
|
43
|
+
|
|
44
|
+
expect(RestClient::Request).to have_received(:execute) do |request|
|
|
45
|
+
payload = JSON.parse(request[:payload])
|
|
46
|
+
artifact_read = payload.fetch('tools').find { |tool| tool.dig('function', 'name') == 'artifact_read' }
|
|
47
|
+
expect(artifact_read).not_to be_nil
|
|
48
|
+
branches = artifact_read.fetch('function').fetch('parameters').fetch('anyOf')
|
|
49
|
+
expect(branches).not_to be_empty
|
|
50
|
+
expect(branches).to all(include('type' => 'object'))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
context 'when rate limits exhaust the retry budget' do
|
|
55
|
+
let(:body) { '{"error":"rate limit exceeded"}' }
|
|
56
|
+
let(:response) { instance_double(RestClient::Response, code: 429, body: body, to_s: body, headers: { retry_after: '2' }) }
|
|
57
|
+
let(:error) { RestClient::TooManyRequests.new(response) }
|
|
58
|
+
|
|
59
|
+
it 'raises the HTTP error after five attempts and four Retry-After waits' do
|
|
60
|
+
allow(described_class).to receive(:rand).with(0.3..5.0).and_return(0.5)
|
|
61
|
+
allow(described_class).to receive(:sleep)
|
|
62
|
+
allow(PWN::AI::HttpRetry).to receive(:report_event)
|
|
63
|
+
|
|
64
|
+
expect do
|
|
65
|
+
described_class.chat_with_tools(messages: messages)
|
|
66
|
+
end.to raise_error(RestClient::TooManyRequests) { |raised|
|
|
67
|
+
expect(raised.message).to include('HTTP 429', body)
|
|
68
|
+
}
|
|
69
|
+
expect(RestClient::Request).to have_received(:execute).exactly(5).times
|
|
70
|
+
expect(described_class).to have_received(:sleep).with(2.5).exactly(4).times
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
context 'when the request times out' do
|
|
75
|
+
let(:error) { RestClient::Exceptions::ReadTimeout.new }
|
|
76
|
+
|
|
77
|
+
it 'preserves the default five attempts and nil on exhaustion' do
|
|
78
|
+
allow(PWN::AI::HttpRetry).to receive(:report_event)
|
|
79
|
+
expect(described_class.chat_with_tools(messages: messages)).to be_nil
|
|
80
|
+
expect(RestClient::Request).to have_received(:execute).with(hash_including(timeout: 180)).exactly(5).times
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'keeps quiet sidecars single-shot and silent' do
|
|
84
|
+
expect(PWN::AI::HttpRetry).not_to receive(:report_event)
|
|
85
|
+
expect(described_class.chat_with_tools(messages: messages, quiet: true)).to be_nil
|
|
86
|
+
expect(RestClient::Request).to have_received(:execute).once
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'keeps short timeouts single-shot' do
|
|
90
|
+
allow(PWN::AI::HttpRetry).to receive(:report_event)
|
|
91
|
+
expect(described_class.chat_with_tools(messages: messages, timeout: 10)).to be_nil
|
|
92
|
+
expect(RestClient::Request).to have_received(:execute).with(hash_including(timeout: 10)).once
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'returns the successful response after a transient timeout' do
|
|
96
|
+
calls = 0
|
|
97
|
+
allow(RestClient::Request).to receive(:execute) do
|
|
98
|
+
calls += 1
|
|
99
|
+
raise error if calls == 1
|
|
100
|
+
|
|
101
|
+
'{"choices":[{"message":{"role":"assistant","content":"hello"}}]}'
|
|
102
|
+
end
|
|
103
|
+
allow(PWN::AI::HttpRetry).to receive(:report_event)
|
|
104
|
+
result = described_class.chat_with_tools(messages: messages)
|
|
105
|
+
expect(result.dig(:assistant_message, :content)).to eq('hello')
|
|
106
|
+
expect(RestClient::Request).to have_received(:execute).twice
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
[false, true].each do |quiet|
|
|
111
|
+
it "propagates actionable schema errors without printing (quiet=#{quiet})" do
|
|
112
|
+
expect do
|
|
113
|
+
expect do
|
|
114
|
+
described_class.chat_with_tools(messages: messages, quiet: quiet)
|
|
115
|
+
end.to raise_error(RestClient::BadRequest) { |raised|
|
|
116
|
+
expect(raised.response).to equal(response)
|
|
117
|
+
expect(raised.message).to include('Grok', 'HTTP 400', 'chat/completions', body)
|
|
118
|
+
}
|
|
119
|
+
end.not_to output.to_stdout
|
|
120
|
+
expect(RestClient::Request).to have_received(:execute).once
|
|
121
|
+
expect(PWN::Plugins::TTYSpinner).to have_received(:stop)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
21
126
|
it 'does not expose get_plan_usage' do
|
|
22
127
|
expect(described_class).not_to respond_to(:get_plan_usage)
|
|
23
128
|
end
|
|
@@ -3,6 +3,23 @@
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
|
|
5
5
|
describe PWN::AI::Ollama do
|
|
6
|
+
[false, true].each do |quiet|
|
|
7
|
+
it "raises an actionable exhausted 429 instead of parsing error text (quiet=#{quiet})" do
|
|
8
|
+
stub_const('PWN::Env', { ai: { ollama: { model: 'fixture', base_uri: 'http://example.test' } } })
|
|
9
|
+
allow(PWN::Plugins::TransparentBrowser).to receive(:open).and_return(browser: RestClient)
|
|
10
|
+
response = instance_double(RestClient::Response, code: 429, body: 'rate limit exceeded', to_s: 'rate limit exceeded', headers: { retry_after: '2' })
|
|
11
|
+
allow(RestClient::Request).to receive(:execute).and_raise(RestClient::TooManyRequests.new(response))
|
|
12
|
+
allow(described_class).to receive(:sleep)
|
|
13
|
+
allow(described_class).to receive(:rand).and_return(0.5)
|
|
14
|
+
allow(PWN::AI::HttpRetry).to receive(:report_event)
|
|
15
|
+
expect do
|
|
16
|
+
described_class.chat_with_tools(messages: [{ role: 'user', content: 'hello' }], quiet: quiet)
|
|
17
|
+
end.to raise_error(RuntimeError, /Ollama.*429.*rate limit exceeded/)
|
|
18
|
+
expect(RestClient::Request).to have_received(:execute).exactly(5).times
|
|
19
|
+
expect(described_class).to have_received(:sleep).with(2.5).exactly(4).times
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
6
23
|
it 'should display information for authors' do
|
|
7
24
|
authors_response = PWN::AI::Ollama
|
|
8
25
|
expect(authors_response).to respond_to :authors
|
|
@@ -3,6 +3,23 @@
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
|
|
5
5
|
describe PWN::AI::OpenWebUI do
|
|
6
|
+
[false, true].each do |quiet|
|
|
7
|
+
it "raises an actionable exhausted 429 instead of parsing error text (quiet=#{quiet})" do
|
|
8
|
+
stub_const('PWN::Env', { ai: { openwebui: { model: 'fixture', base_uri: 'http://example.test', key: 'fixture-key' } } })
|
|
9
|
+
allow(PWN::Plugins::TransparentBrowser).to receive(:open).and_return(browser: RestClient)
|
|
10
|
+
response = instance_double(RestClient::Response, code: 429, body: 'rate limit exceeded', to_s: 'rate limit exceeded', headers: { retry_after: '2' })
|
|
11
|
+
allow(RestClient::Request).to receive(:execute).and_raise(RestClient::TooManyRequests.new(response))
|
|
12
|
+
allow(described_class).to receive(:sleep)
|
|
13
|
+
allow(described_class).to receive(:rand).and_return(0.5)
|
|
14
|
+
allow(PWN::AI::HttpRetry).to receive(:report_event)
|
|
15
|
+
expect do
|
|
16
|
+
described_class.chat_with_tools(messages: [{ role: 'user', content: 'hello' }], quiet: quiet)
|
|
17
|
+
end.to raise_error(RuntimeError, /Open WebUI.*429.*rate limit exceeded/)
|
|
18
|
+
expect(RestClient::Request).to have_received(:execute).exactly(5).times
|
|
19
|
+
expect(described_class).to have_received(:sleep).with(2.5).exactly(4).times
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
6
23
|
it 'should display information for authors' do
|
|
7
24
|
authors_response = PWN::AI::OpenWebUI
|
|
8
25
|
expect(authors_response).to respond_to :authors
|
|
@@ -80,6 +80,10 @@ describe PWN::Plugins::REPL, 'mesh packet channel routing' do
|
|
|
80
80
|
expect(win).to have_received(:addstr).with(" Reacted to: \"!aabbccdd >> Meet at noon\" with: 👍.\n")
|
|
81
81
|
expect(win).to have_received(:attron).with((local ? 23 : 21) | Curses::A_BOLD).at_least(:once)
|
|
82
82
|
expect(described_class).to have_received(:mesh_maybe_dispatch_to_pwn_ai).once
|
|
83
|
+
reply = Meshtastic::MeshPacket.new(id: 125, from: 0xb0b, to: 0xffffffff, channel: 1,
|
|
84
|
+
decoded: { portnum: :TEXT_MESSAGE_APP, payload: 'See you there!', reply_id: 123 }).to_h
|
|
85
|
+
described_class.send(:mesh_handle_rx, local: local, msg: { packet: reply })
|
|
86
|
+
expect(win).to have_received(:addstr).with(" Replied to: \"!aabbccdd >> Meet at noon\" with: See you there!\n")
|
|
83
87
|
end
|
|
84
88
|
end
|
|
85
89
|
|
|
@@ -140,6 +144,8 @@ describe PWN::Plugins::REPL, 'mesh packet channel routing' do
|
|
|
140
144
|
text = described_class.send(:mesh_reaction_text, packet: packet, text: '👍', channel: 'LongFast', index: 1)
|
|
141
145
|
expect(text).to eq('Reacted to: "original message unavailable (packet 999)" with: 👍.')
|
|
142
146
|
packet[:decoded][:emoji] = 0
|
|
147
|
+
expect(described_class.send(:mesh_reaction_text, packet: packet, text: 'Reply')).to eq('Replied to: "original message unavailable (packet 999)" with: Reply')
|
|
148
|
+
packet[:decoded].delete(:reply_id)
|
|
143
149
|
expect(described_class.send(:mesh_reaction_text, packet: packet, text: '👍')).to eq('👍')
|
|
144
150
|
end
|
|
145
151
|
|
|
@@ -1,8 +1,130 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
|
+
require 'meshtastic'
|
|
4
5
|
|
|
5
6
|
describe PWN::Plugins::REPL, 'mesh compose history' do
|
|
7
|
+
it 'does not warn when the real subscriber sees an empty protobuf Data message' do
|
|
8
|
+
stub_const('PWN::MeshTransport', :serial)
|
|
9
|
+
queue = Queue.new
|
|
10
|
+
encoded = Meshtastic::FromRadio.new(packet: { decoded: Meshtastic::Data.new }).to_proto
|
|
11
|
+
queue << Meshtastic::FromRadio.decode(encoded)
|
|
12
|
+
queue << Meshtastic::FromRadio.new(queueStatus: { free: 1 })
|
|
13
|
+
queue.close
|
|
14
|
+
received = []
|
|
15
|
+
output = StringIO.new
|
|
16
|
+
old = $stdout
|
|
17
|
+
$stdout = output
|
|
18
|
+
described_class.send(:mesh_subscribe, obj: { from_radio_queue: queue }, psks: {}, on_message: proc { |message| received << message })
|
|
19
|
+
expect(received.size).to eq(2)
|
|
20
|
+
expect(output.string).not_to include("Can't decode")
|
|
21
|
+
Meshtastic::MeshInterface.new.decode_payload(payload: 'unsupported', msg_type: :PRIVATE_APP)
|
|
22
|
+
expect(output.string).to include("Can't decode", 'unsupported')
|
|
23
|
+
ensure
|
|
24
|
+
$stdout = old
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'queues warnings for acknowledgement instead of writing through the TUI' do
|
|
28
|
+
events = Queue.new
|
|
29
|
+
stub_const('PWN::MeshEvents', events)
|
|
30
|
+
expect(described_class).not_to receive(:puts)
|
|
31
|
+
described_class.send(:mesh_ui_puts, text: 'WARNING: fixture warning')
|
|
32
|
+
expect(events.pop(true)).to eq(notice: 'WARNING: fixture warning')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'displays queued diagnostics on the UI thread' do
|
|
36
|
+
events = Queue.new
|
|
37
|
+
stub_const('PWN::MeshEvents', events)
|
|
38
|
+
events << { notice: 'RX failed: fixture' }
|
|
39
|
+
expect(described_class).to receive(:mesh_notice).with(text: 'RX failed: fixture')
|
|
40
|
+
described_class.send(:mesh_drain_events)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'captures dependency stdout and stderr and restores both streams on failure' do
|
|
44
|
+
events = Queue.new
|
|
45
|
+
stub_const('PWN::MeshEvents', events)
|
|
46
|
+
original = [$stdout, $stderr]
|
|
47
|
+
expect do
|
|
48
|
+
described_class.send(:mesh_capture_output, run: proc {
|
|
49
|
+
puts 'WARNING: decoder fixture'
|
|
50
|
+
warn 'ERROR: transport fixture'
|
|
51
|
+
raise IOError, 'fixture'
|
|
52
|
+
})
|
|
53
|
+
end.to raise_error(IOError, 'fixture')
|
|
54
|
+
expect([$stdout, $stderr]).to eq(original)
|
|
55
|
+
output = []
|
|
56
|
+
output << events.pop(true)[:notice] until events.empty?
|
|
57
|
+
expect(output.join).to include('WARNING: decoder fixture', 'ERROR: transport fixture')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'centers a reverse-colored Ok button and repaints the underlying panes' do
|
|
61
|
+
win = double('notice', maxx: 78, maxy: 6)
|
|
62
|
+
%i[keypad erase setpos addstr refresh close attron attroff].each { |method| allow(win).to receive(method) }
|
|
63
|
+
allow(Curses).to receive(:color_pair).with(20).and_return(5120)
|
|
64
|
+
allow(Curses).to receive(:cols).and_return(80)
|
|
65
|
+
allow(Curses).to receive(:lines).and_return(24)
|
|
66
|
+
allow(Curses::Window).to receive(:new).and_return(win)
|
|
67
|
+
pane = double('pane', touch: nil, refresh: nil)
|
|
68
|
+
stub_const('PWN::MeshTxWin', pane)
|
|
69
|
+
keys = ["\e", 'q', "\n"]
|
|
70
|
+
described_class.send(:mesh_notice, text: 'Decoder warning', getch: proc { keys.shift })
|
|
71
|
+
expect(keys).to be_empty
|
|
72
|
+
expect(win).to have_received(:addstr).with('Ok').exactly(3).times
|
|
73
|
+
expect(win).not_to have_received(:addstr).with(a_string_matching(/Acknowledged|Enter/))
|
|
74
|
+
expect(win).to have_received(:setpos).with(4, 38).exactly(3).times
|
|
75
|
+
expect(win).to have_received(:attron).with(5120 | Curses::A_BOLD | Curses::A_REVERSE).exactly(3).times
|
|
76
|
+
expect(win).to have_received(:attroff).with(5120 | Curses::A_BOLD | Curses::A_REVERSE).exactly(3).times
|
|
77
|
+
expect(win).to have_received(:close)
|
|
78
|
+
expect(pane).to have_received(:touch)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'keeps an idle PhoneAPI session alive and stops its heartbeat with the subscriber' do
|
|
82
|
+
events = Queue.new
|
|
83
|
+
beats = Queue.new
|
|
84
|
+
release = Queue.new
|
|
85
|
+
obj = {}
|
|
86
|
+
stub_const('PWN::MeshEvents', events)
|
|
87
|
+
stub_const('PWN::MeshObj', obj)
|
|
88
|
+
stub_const('PWN::MeshTransport', :serial)
|
|
89
|
+
allow(Meshtastic::Serial).to receive(:send_to_radio) do |args|
|
|
90
|
+
beats << [Thread.current, Meshtastic::ToRadio.decode(args[:to_radio])]
|
|
91
|
+
end
|
|
92
|
+
allow(described_class).to receive(:mesh_subscribe) do |args|
|
|
93
|
+
release.pop
|
|
94
|
+
args[:on_message].call(packet: { decoded: { portnum: 1, payload: 'after idle' } })
|
|
95
|
+
end
|
|
96
|
+
thread = described_class.send(:mesh_start_rx!, env: { channel: {} }, obj: obj, heartbeat_interval: 0.01)
|
|
97
|
+
first = beats.pop(timeout: 1)
|
|
98
|
+
second = beats.pop(timeout: 1)
|
|
99
|
+
expect(first).not_to be_nil
|
|
100
|
+
expect(second).not_to be_nil
|
|
101
|
+
expect(second.last.payload_variant).to eq(:heartbeat)
|
|
102
|
+
expect(second.last.heartbeat.nonce).to eq(0)
|
|
103
|
+
release << true
|
|
104
|
+
expect(thread.join(1)).to eq(thread)
|
|
105
|
+
expect(first.first).not_to be_alive
|
|
106
|
+
expect(events.pop[:msg].dig(:packet, :decoded, :payload)).to eq('after idle')
|
|
107
|
+
ensure
|
|
108
|
+
thread&.kill
|
|
109
|
+
thread&.join(1)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it 'drains incoming messages during idle input polls without a keypress' do
|
|
113
|
+
pi = double('pry', config: double('config', pwn_mesh: true))
|
|
114
|
+
events = Queue.new
|
|
115
|
+
stub_const('PWN::MeshEvents', events)
|
|
116
|
+
message = { packet: { decoded: { portnum: 1, payload: 'idle RX' } } }
|
|
117
|
+
polls = 0
|
|
118
|
+
allow(described_class).to receive(:mesh_draw_input)
|
|
119
|
+
expect(described_class).to receive(:mesh_handle_rx).with(msg: message)
|
|
120
|
+
reader = proc do
|
|
121
|
+
polls += 1
|
|
122
|
+
events << { msg: message } if polls == 20
|
|
123
|
+
polls > 20 ? "\u0004" : nil
|
|
124
|
+
end
|
|
125
|
+
described_class.send(:mesh_console_loop, pry: pi, getch: reader)
|
|
126
|
+
end
|
|
127
|
+
|
|
6
128
|
it 'recalls submissions with Up/Down and restores the unfinished draft and cursor' do
|
|
7
129
|
config = double('config', pwn_mesh: true)
|
|
8
130
|
pry = double('pry', config: config)
|
data/third_party/pwn_rdoc.jsonl
CHANGED
|
@@ -685,6 +685,7 @@
|
|
|
685
685
|
{"messages":[{"role":"user","content":"PWN::AI::Agent::Registry.discover Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Registry.discover`: Supported Method Parameters\n\nnames = PWN::AI::Agent::Registry.discover(\n\nforce: 'optional - re-require tool files even if already discovered (default false)'\n\n)\n"}]}
|
|
686
686
|
{"messages":[{"role":"user","content":"PWN::AI::Agent::Registry.domain_pin_names Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Registry.domain_pin_names`: "}]}
|
|
687
687
|
{"messages":[{"role":"user","content":"PWN::AI::Agent::Registry.eager_load! Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Registry.eager_load!`: "}]}
|
|
688
|
+
{"messages":[{"role":"user","content":"PWN::AI::Agent::Registry.explicit_root_types Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Registry.explicit_root_types`: "}]}
|
|
688
689
|
{"messages":[{"role":"user","content":"PWN::AI::Agent::Registry.help Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Registry.help`: "}]}
|
|
689
690
|
{"messages":[{"role":"user","content":"PWN::AI::Agent::Registry.lookup Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Registry.lookup`: Supported Method Parameters\n\nentry = PWN::AI::Agent::Registry.lookup(\n\nname: 'required - registered tool name'\n\n)\n"}]}
|
|
690
691
|
{"messages":[{"role":"user","content":"PWN::AI::Agent::Registry.mcp_routing Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Registry.mcp_routing`: "}]}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pwn
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.742
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- 0day Inc.
|
|
@@ -169,14 +169,14 @@ dependencies:
|
|
|
169
169
|
requirements:
|
|
170
170
|
- - '='
|
|
171
171
|
- !ruby/object:Gem::Version
|
|
172
|
-
version: 3.
|
|
172
|
+
version: 3.4.0
|
|
173
173
|
type: :runtime
|
|
174
174
|
prerelease: false
|
|
175
175
|
version_requirements: !ruby/object:Gem::Requirement
|
|
176
176
|
requirements:
|
|
177
177
|
- - '='
|
|
178
178
|
- !ruby/object:Gem::Version
|
|
179
|
-
version: 3.
|
|
179
|
+
version: 3.4.0
|
|
180
180
|
- !ruby/object:Gem::Dependency
|
|
181
181
|
name: colorize
|
|
182
182
|
requirement: !ruby/object:Gem::Requirement
|