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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4abf337af5fb9173890e87ee2d6d8523f93f62c6d9453d1fec9431f650f04a3e
4
- data.tar.gz: 2639a24b9b3b1895e392b8168a604270b9f43118c743919c7b8348ec8f4dd929
3
+ metadata.gz: aae26246e4b23381c5f727d27100f6a10d37d955f2c9a875ad63274d3397219b
4
+ data.tar.gz: 1a0abc7df30321bd4618a8db3d356fa72e9b6cc13f6055b5f71bf4fb1b4ec90a
5
5
  SHA512:
6
- metadata.gz: e383699557934dab9374bef09204e3e37c0e4b255a3dd3c13ef1597c265fd46b7b71bb79dd8094d2fb42aea71512e01b5a7bce9eae08a3f74d8dfb7639f8ff00
7
- data.tar.gz: 6f1aea7cd79a34738923014fbc13024797c614c33e28058ab8b565402f665ccb464aa440c148291a385f4ad6b2617412d42b3c526df5d2f6cdff349149872c9d
6
+ metadata.gz: 5b680d2d2d3eb756642d68a6309a36a5d551100131b58323954bd402c73f554515304ecfb005a42e96ab15a385f16d717495cf527c2bf17c87434567401d0dd0
7
+ data.tar.gz: 7b51570ad19e664e45ca69925619fdbdda6272f985d8627f4c247815b469de19cb11bdfa942433480bbc38792127a8b39e45e56c08c8c73ffe517318ad004396
data/Gemfile CHANGED
@@ -22,7 +22,7 @@ gem 'brakeman', '8.0.6'
22
22
  gem 'bson', '5.2.0'
23
23
  gem 'bundler', '>=4.0.21'
24
24
  gem 'bundler-audit', '>=0.9.3'
25
- gem 'bunny', '3.3.0'
25
+ gem 'bunny', '3.4.0'
26
26
  gem 'colorize', '1.1.0'
27
27
  gem 'credit_card_validations', '9.0.0'
28
28
  gem 'curses', '1.7.0'
@@ -28,13 +28,20 @@ Class methods take `(opts = {})` and read `opts`.
28
28
 
29
29
  ```ruby
30
30
  PWNSandboxDriver.help
31
- PWNSandboxDriver.help(opts)
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
- return "#{e.message}: #{e.response}"
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
- puts "ERROR: #{e.message}: #{e.response}" unless opts[:quiet]
501
- "#{e.message}: #{e.response}" if opts[:quiet]
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
@@ -182,7 +182,7 @@ module PWN
182
182
  extra: '429 retries exhausted', error: e
183
183
  )
184
184
  end
185
- return "#{e.message}: #{e.response}"
185
+ raise
186
186
  end
187
187
  sleep(PWN::AI::HttpRetry.retry_after_s(response: e.response, retry_count: retry_count) + rand(0.3..5.0))
188
188
  retry
@@ -207,7 +207,7 @@ module PWN
207
207
  extra: '429 retries exhausted', error: e
208
208
  )
209
209
  end
210
- return "#{e.message}: #{e.response}"
210
+ raise
211
211
  end
212
212
  sleep(PWN::AI::HttpRetry.retry_after_s(response: e.response, retry_count: retry_count) + rand(0.3..5.0))
213
213
  retry
@@ -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(:mesh_start_rx!, env: meshtastic_env, obj: mesh_obj)
138
- PWN::Plugins::REPL.send(:mesh_refresh_ui!, env: meshtastic_env)
139
- PWN::Plugins::REPL.send(:mesh_ui_puts, text: 'Ready. /menu opens settings. Incoming channel messages appear here.')
140
- PWN::Plugins::REPL.send(:mesh_console_loop, pry: pi)
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
- event[:msg] ? mesh_handle_rx(msg: event[:msg]) : mesh_ui_puts(text: event[:text])
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
- "Reacted to: \"#{target}\" with: #{text}."
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
- warn "[pwn-mesh] persist skipped: #{e.class}: #{e.message}"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.740'
4
+ VERSION = '0.5.742'
5
5
  end
@@ -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)
@@ -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.740
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.3.0
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.3.0
179
+ version: 3.4.0
180
180
  - !ruby/object:Gem::Dependency
181
181
  name: colorize
182
182
  requirement: !ruby/object:Gem::Requirement