pwn 0.5.741 → 0.5.743

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: 3eb74de135994d78ce8af6b31729ef0622495f0cbe6ff72ddb5ecce5662a5aa7
4
- data.tar.gz: 3f2d855056ef2cce6424ac399393e38492970850baa36fde37419d5e798ca2f8
3
+ metadata.gz: 8089c1c616fe3d19f70170b55204b0dd762a14faa40432e6acf421bd6d55197d
4
+ data.tar.gz: 6633cdc11ab8b811913d6b6e30608a79fa85c06c5bde599d4155c9c63d5a3884
5
5
  SHA512:
6
- metadata.gz: 995de4dfb6c55750ed90c2f8c282c4d6ca14e876f41ec207b71aecdc1d162ab69359a20d3d479115c4aea9b9eee7d04d9c3bd1678ff73d1e1c06dbefcbef7c46
7
- data.tar.gz: 30c2d6602caa5d1fef740508751d4457d69c7f51d861a6260e57f65251c5050d2425c21dad3e946181aa1776345f413e2a035ec3bcf411ae4eedce21441d61d4
6
+ metadata.gz: 83c1b09ae27724b7e5df7972f018f11b7bee7f77e38d295c45395bf4c828f6c57e9736306687ae243e1fc05dc4dd63e4b33407d4101b916c2e32a8b241f39958
7
+ data.tar.gz: 19ad087ec133a1194b2fd60d7348edff887b94940647b5a0e34792fa5084742dcb7a04f213ee17b404e4fffda5fc1b8db4821fd1bc6c047c0867c96a2752d8af
@@ -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
@@ -49,10 +49,12 @@ module PWN
49
49
  'PWN::Plugins::Jobs.supervise(root: ARGV[0], id: ARGV[1])', root, id,
50
50
  in: File::NULL, out: File::NULL, err: File::NULL, close_others: true)
51
51
  Process.detach(worker)
52
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
52
+ row[:worker_pid] = worker
53
+ persist(root: root, row: row)
54
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 8
53
55
  loop do
54
56
  row = load_job(id: id)
55
- break if row[:worker_pid] || row[:status] != 'RUNNING' || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
57
+ break if row[:worker_identity] || row[:status] != 'RUNNING' || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
56
58
 
57
59
  sleep 0.02
58
60
  end
@@ -372,7 +374,11 @@ module PWN
372
374
  next unless row[:status] == 'RUNNING'
373
375
 
374
376
  begin
375
- Process.setsid
377
+ begin
378
+ Process.setsid
379
+ rescue Errno::EPERM
380
+ nil
381
+ end
376
382
  row.merge!(worker_pid: Process.pid, worker_identity: SecureRandom.hex(16), started_at: Time.now.utc.iso8601(6))
377
383
  pid = spawn(row[:env].transform_keys(&:to_s), '/bin/sh', '-c', row[:command], chdir: row[:cwd],
378
384
  in: File::NULL, %i[out err] => [row[:log], 'ab'], pgroup: true, close_others: true)
@@ -181,7 +181,7 @@ module PWN
181
181
  current = nil
182
182
  dump.each_line do |line|
183
183
  current = Regexp.last_match(1) if line =~ /<([^>]+)>:/
184
- next unless current && (line =~ /call\w*\s+.*<([^>@]+)/ || line =~ /call\w*\s+([A-Za-z_]\w*)(?:@plt)?/)
184
+ next unless current && (line =~ /\b(?:call\w*|blx?r?|jalr?)\b.*<([^>@]+)/ || line =~ /\b(?:call\w*|blx?r?|jalr?)\s+([A-Za-z_]\w*)(?:@plt)?/)
185
185
 
186
186
  db.execute('INSERT INTO xrefs(src, dst) VALUES(?,?)', [current, Regexp.last_match(1)])
187
187
  end
@@ -614,31 +614,49 @@ module PWN
614
614
  end
615
615
 
616
616
  def mesh_tx_row_ready?(opts = {})
617
- return false unless opts.is_a?(Hash)
617
+ mesh_tx_outcome(opts) == :ok
618
+ end
619
+
620
+ def mesh_tx_outcome(opts = {})
621
+ return :idle unless opts.is_a?(Hash)
618
622
 
619
623
  row = opts[:row]
620
- return false unless row.is_a?(Hash)
624
+ return :idle unless row.is_a?(Hash)
621
625
 
622
626
  packet = row[:packet] || row['packet']
623
- return false unless packet.is_a?(Hash)
627
+ return :idle unless packet.is_a?(Hash)
624
628
 
625
629
  decoded = packet[:decoded] || packet['decoded']
626
- return false unless decoded.is_a?(Hash)
630
+ return :idle unless decoded.is_a?(Hash)
627
631
 
628
632
  port = decoded[:portnum] || decoded['portnum']
629
- %w[5 ROUTING_APP].include?(port.to_s) || port == :ROUTING_APP
633
+ return :idle unless %w[5 ROUTING_APP].include?(port.to_s) || port == :ROUTING_APP
634
+
635
+ want = opts[:request_id]
636
+ got = decoded[:request_id] || decoded['request_id']
637
+ return :idle if want && got && want.to_i != got.to_i
638
+
639
+ routing = decoded[:payload] || decoded['payload']
640
+ routing = Meshtastic::Routing.decode(routing).to_h if routing.is_a?(String)
641
+ reason = routing[:error_reason] if routing.is_a?(Hash)
642
+ reason = Meshtastic::Routing::Error.lookup(reason) || reason if reason.is_a?(Integer)
643
+ name = reason.to_s
644
+ return :ok if reason.nil? || %w[0 NONE].include?(name)
645
+ return :rate_limited if name == 'RATE_LIMIT_EXCEEDED'
646
+
647
+ :error
630
648
  end
631
649
 
632
650
  def mesh_wait_tx_slot(opts = {})
633
- return unless opts.is_a?(Hash)
651
+ return :ok unless opts.is_a?(Hash)
634
652
 
635
653
  obj = opts[:obj]
636
- return unless obj.is_a?(Hash)
654
+ return :ok unless obj.is_a?(Hash)
637
655
 
638
656
  timeout = opts[:timeout]
639
657
  timeout = 8 if timeout.nil?
640
658
  timeout = Float(timeout)
641
- return if timeout <= 0
659
+ return :ok if timeout <= 0
642
660
 
643
661
  since = opts[:since].to_i
644
662
  deadline = Time.now + timeout
@@ -648,11 +666,11 @@ module PWN
648
666
  else
649
667
  Array(obj[:proto_data])
650
668
  end
651
- ready = rows.drop(since).any? do |row|
652
- mesh_tx_row_ready?(row: row)
669
+ rows.drop(since).each do |row|
670
+ outcome = mesh_tx_outcome(row: row, request_id: opts[:request_id])
671
+ return outcome unless outcome == :idle
653
672
  end
654
- return if ready
655
- return if Time.now >= deadline
673
+ return :timeout if Time.now >= deadline
656
674
 
657
675
  sleep 0.05
658
676
  end
@@ -682,6 +700,13 @@ module PWN
682
700
  end
683
701
  bodies << piece
684
702
  offset += piece.length
703
+ next unless offset < text.length && piece.match?(/[^[:space:]]\z/)
704
+
705
+ cut = piece.rindex(/[[:space:]]/)
706
+ next unless cut&.positive?
707
+
708
+ bodies[-1] = piece[0, cut + 1]
709
+ offset = offset - piece.length + bodies[-1].length
685
710
  end
686
711
  if bodies.size <= n
687
712
  total = bodies.size
@@ -772,69 +797,77 @@ module PWN
772
797
  dm_key = mesh_dm_key(obj: obj, kind: kind, to: dest, radio: radio, timeout: opts.fetch(:key_timeout, 15))
773
798
  end
774
799
  chunks = mesh_text_chunks(text: opts[:text])
775
- chunks.each_with_index do |piece, idx|
776
- packet_id = Random.rand(2...0xffffffff)
777
- since = obj.is_a?(Hash) ? Array(obj[:proto_data]).size : 0
778
- if dm_key
779
- bytes = Meshtastic::MeshInterface.new.send_text(from: obj[:my_node_num] || 0, to: dest, channel: radio || 0, text: piece, want_ack: true, psks: nil, last_packet_id: packet_id - 1)
780
- packet = Meshtastic::ToRadio.decode(bytes)
781
- packet.packet.pki_encrypted = true
782
- packet.packet.public_key = dm_key
783
- transport = { serial: Meshtastic::Serial, bluetooth: Meshtastic::Bluetooth, tcp: Meshtastic::TCP }.fetch(kind)
784
- transport.send_to_radio({ "#{kind}_obj": obj, to_radio: packet.to_proto })
785
- else
786
- case kind
787
- when :serial
788
- tx = { serial_obj: obj, to: dest, text: piece, want_ack: true, last_packet_id: packet_id - 1 }
789
- tx[:channel] = radio unless radio.nil?
790
- Meshtastic::Serial.send_text(tx)
791
- when :bluetooth
792
- tx = { bluetooth_obj: obj, to: dest, text: piece, want_ack: true, last_packet_id: packet_id - 1 }
793
- tx[:channel] = radio unless radio.nil?
794
- Meshtastic::Bluetooth.send_text(tx)
795
- when :tcp
796
- tx = { tcp_obj: obj, to: dest, text: piece, last_packet_id: packet_id - 1 }
797
- tx[:channel] = radio unless radio.nil?
798
- Meshtastic::TCP.send_text(tx)
800
+ chunks.each_with_index do |piece, _idx|
801
+ echoed = false
802
+ attempts = 0
803
+ loop do
804
+ attempts += 1
805
+ packet_id = Random.rand(2...0xffffffff)
806
+ since = obj.is_a?(Hash) ? Array(obj[:proto_data]).size : 0
807
+ if dm_key
808
+ bytes = Meshtastic::MeshInterface.new.send_text(from: obj[:my_node_num] || 0, to: dest, channel: radio || 0, text: piece, want_ack: true, psks: nil, last_packet_id: packet_id - 1)
809
+ packet = Meshtastic::ToRadio.decode(bytes)
810
+ packet.packet.pki_encrypted = true
811
+ packet.packet.public_key = dm_key
812
+ transport = { serial: Meshtastic::Serial, bluetooth: Meshtastic::Bluetooth, tcp: Meshtastic::TCP }.fetch(kind)
813
+ transport.send_to_radio({ "#{kind}_obj": obj, to_radio: packet.to_proto })
799
814
  else
800
- send_psks = psks
801
- send_psks = mesh_channel_psks(env: env) if send_psks.nil? || send_psks.empty?
802
- Meshtastic::MQTT.send_text(
803
- mqtt_obj: obj,
804
- last_packet_id: packet_id - 1,
805
- from: from,
806
- to: dest,
807
- region: mesh_mqtt_region(region: opts[:region], env: env),
808
- topic: mesh_mqtt_topic(env: env, topic: opts[:topic]),
809
- channel: channel,
810
- text: piece,
811
- psks: send_psks
815
+ case kind
816
+ when :serial
817
+ tx = { serial_obj: obj, to: dest, text: piece, want_ack: true, last_packet_id: packet_id - 1 }
818
+ tx[:channel] = radio unless radio.nil?
819
+ Meshtastic::Serial.send_text(tx)
820
+ when :bluetooth
821
+ tx = { bluetooth_obj: obj, to: dest, text: piece, want_ack: true, last_packet_id: packet_id - 1 }
822
+ tx[:channel] = radio unless radio.nil?
823
+ Meshtastic::Bluetooth.send_text(tx)
824
+ when :tcp
825
+ tx = { tcp_obj: obj, to: dest, text: piece, want_ack: true, last_packet_id: packet_id - 1 }
826
+ tx[:channel] = radio unless radio.nil?
827
+ Meshtastic::TCP.send_text(tx)
828
+ else
829
+ send_psks = psks
830
+ send_psks = mesh_channel_psks(env: env) if send_psks.nil? || send_psks.empty?
831
+ Meshtastic::MQTT.send_text(
832
+ mqtt_obj: obj,
833
+ last_packet_id: packet_id - 1,
834
+ from: from,
835
+ to: dest,
836
+ region: mesh_mqtt_region(region: opts[:region], env: env),
837
+ topic: mesh_mqtt_topic(env: env, topic: opts[:topic]),
838
+ channel: channel,
839
+ text: piece,
840
+ psks: send_psks
841
+ )
842
+ end
843
+ end
844
+ unless echoed
845
+ from_id = from.to_s
846
+ from_id = mesh_self_node_id(env: env, obj: obj) if from_id.empty?
847
+ from_id = mesh_format_node_id(id: from_id)
848
+ PWN.send(:remove_const, :MeshLastTx) if PWN.const_defined?(:MeshLastTx)
849
+ PWN.const_set(:MeshLastTx, { from: from_id, to: dest, text: piece.to_s, at: Time.now })
850
+ mesh_handle_rx(
851
+ local: true,
852
+ channel_name: channel_name,
853
+ msg: {
854
+ packet: {
855
+ id: packet_id,
856
+ channel: radio,
857
+ pki_encrypted: !dm_key.nil?,
858
+ node_id_from: from_id,
859
+ node_id_to: dest,
860
+ decoded: { portnum: :TEXT_MESSAGE_APP, payload: piece.to_s }
861
+ }
862
+ }
812
863
  )
864
+ echoed = true
813
865
  end
814
- end
815
- from_id = from.to_s
816
- from_id = mesh_self_node_id(env: env, obj: obj) if from_id.empty?
817
- from_id = mesh_format_node_id(id: from_id)
818
- PWN.send(:remove_const, :MeshLastTx) if PWN.const_defined?(:MeshLastTx)
819
- PWN.const_set(:MeshLastTx, { from: from_id, to: dest, text: piece.to_s, at: Time.now })
820
- mesh_handle_rx(
821
- local: true,
822
- channel_name: channel_name,
823
- msg: {
824
- packet: {
825
- id: packet_id,
826
- channel: radio,
827
- pki_encrypted: !dm_key.nil?,
828
- node_id_from: from_id,
829
- node_id_to: dest,
830
- decoded: { portnum: :TEXT_MESSAGE_APP, payload: piece.to_s }
831
- }
832
- }
833
- )
834
- next unless idx + 1 < chunks.size
835
- next unless %i[serial bluetooth tcp].include?(kind)
866
+ break unless %i[serial bluetooth tcp].include?(kind)
836
867
 
837
- mesh_wait_tx_slot(obj: obj, since: since)
868
+ slot = mesh_wait_tx_slot(obj: obj, since: since, request_id: packet_id)
869
+ break if slot == :ok || slot == :timeout || slot == :error || attempts >= 8
870
+ end
838
871
  end
839
872
  return unless dm_key && PWN.const_defined?(:MeshPendingDm)
840
873
 
@@ -1016,7 +1049,8 @@ module PWN
1016
1049
  next if event[:obj] && (!PWN.const_defined?(:MeshObj) || !event[:obj].equal?(PWN::MeshObj))
1017
1050
 
1018
1051
  if event[:notice]
1019
- mesh_notice(text: event[:notice])
1052
+ notice = mesh_notice_text(text: event[:notice])
1053
+ mesh_notice(text: notice) if notice
1020
1054
  elsif event[:msg]
1021
1055
  mesh_handle_rx(msg: event[:msg])
1022
1056
  else
@@ -1102,6 +1136,14 @@ module PWN
1102
1136
  text
1103
1137
  end
1104
1138
 
1139
+ def mesh_notice_text(opts = {})
1140
+ text = opts[:text].to_s.dup.force_encoding(Encoding::UTF_8).scrub
1141
+ text.gsub!(/\e\[[0-9;?]*[A-Za-z]/, '')
1142
+ text.gsub!(/[\x00-\x08\x0b\x0c\x0e-\x1f]/, '')
1143
+ text.strip!
1144
+ text.empty? ? nil : text
1145
+ end
1146
+
1105
1147
  def mesh_capture_output(opts = {})
1106
1148
  original_out = $stdout
1107
1149
  original_err = $stderr
@@ -1116,7 +1158,8 @@ module PWN
1116
1158
  rescue EOFError
1117
1159
  # Flush the last diagnostic even when the TUI is closing.
1118
1160
  end
1119
- events << { notice: text.force_encoding(Encoding::UTF_8).scrub }
1161
+ notice = mesh_notice_text(text: text)
1162
+ events << { notice: notice } if notice
1120
1163
  end
1121
1164
  rescue IOError
1122
1165
  nil
@@ -1173,7 +1216,7 @@ module PWN
1173
1216
  end
1174
1217
  end
1175
1218
 
1176
- private :mesh_capture_output, :mesh_notice
1219
+ private :mesh_capture_output, :mesh_notice, :mesh_notice_text
1177
1220
 
1178
1221
  # Drop the submitted TX buffer so the next prompt is empty (Reline keeps the old line).
1179
1222
  def mesh_reset_input!(opts = {})
@@ -1551,7 +1594,7 @@ module PWN
1551
1594
  routing = Meshtastic::Routing.decode(routing).to_h if routing.is_a?(String)
1552
1595
  reason = routing[:error_reason] if routing.is_a?(Hash)
1553
1596
  reason = Meshtastic::Routing::Error.lookup(reason) || reason if reason.is_a?(Integer)
1554
- mesh_ui_puts(text: "TX failed: packet #{decoded[:request_id]}: #{reason}") if reason && !%w[0 NONE].include?(reason.to_s)
1597
+ mesh_ui_puts(text: "TX failed: packet #{decoded[:request_id]}: #{reason}") if reason && !%w[0 NONE RATE_LIMIT_EXCEEDED].include?(reason.to_s)
1555
1598
  return
1556
1599
  end
1557
1600
  return unless mesh_text_app?(portnum: decoded[:portnum])
@@ -2185,7 +2228,7 @@ module PWN
2185
2228
 
2186
2229
  private :mesh_transport, :mesh_bound_transport, :mesh_layout, :mesh_box!, :mesh_mqtt_region, :mesh_mqtt_topic, :mesh_active_psks, :mesh_device_channel_meta, :mesh_device_channels
2187
2230
  private :mesh_radio_channel, :mesh_radio_index_for_name, :mesh_channel_name_for_index, :mesh_unassigned_slot_map, :mesh_psk_b64, :mesh_psk_same?, :mesh_env_channel_name_for_psk, :mesh_whitelist_name_for_index, :mesh_channel_name_from_topic, :mesh_link_label
2188
- private :mesh_connect_one, :mesh_connect, :mesh_mqtt_tls?, :mesh_subscribe, :mesh_text_payload_max, :mesh_payload_fits?, :mesh_text_chunks, :mesh_tx_row_ready?, :mesh_wait_tx_slot, :mesh_send_text, :mesh_disconnect, :mesh_compose_send, :mesh_channel_names, :mesh_env_hash
2231
+ private :mesh_connect_one, :mesh_connect, :mesh_mqtt_tls?, :mesh_subscribe, :mesh_text_payload_max, :mesh_payload_fits?, :mesh_text_chunks, :mesh_tx_row_ready?, :mesh_tx_outcome, :mesh_wait_tx_slot, :mesh_send_text, :mesh_disconnect, :mesh_compose_send, :mesh_channel_names, :mesh_env_hash
2189
2232
  private :mesh_submit, :mesh_console_loop, :mesh_drain_events, :mesh_draw_input, :mesh_wrap_text, :mesh_ui_puts, :mesh_menu_root, :mesh_list_devices
2190
2233
  private :mesh_start_rx!, :mesh_channel_psks, :mesh_text_app?, :mesh_rx_text, :mesh_self_node_id, :mesh_decorate_local_id, :mesh_public_psk?, :mesh_channel_securely_encrypted?
2191
2234
  private :mesh_ai_whitelisted?, :mesh_ai_prompt, :mesh_broadcast?, :mesh_reply_target_label, :mesh_handle_rx, :mesh_maybe_dispatch_to_pwn_ai, :mesh_refresh_ui!, :mesh_reconnect!
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.741'
4
+ VERSION = '0.5.743'
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'
@@ -15,7 +15,7 @@ describe 'PWN::AI::Agent::Tools exploitdev' do
15
15
  expect(PWN::AI::Agent::Registry.lookup(name: 'exploitdev')).not_to be_nil
16
16
  end
17
17
 
18
- it 'builds a ret2libc chain end-to-end through Dispatch' do
18
+ it 'builds a ret2libc chain end-to-end through Dispatch', :x86_64_binary do
19
19
  Dir.mktmpdir('pwn-ret2libc-tool-') do |dir|
20
20
  src = File.join(dir, 'vuln.c')
21
21
  bin = File.join(dir, 'vuln')
@@ -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
@@ -60,7 +60,7 @@ describe PWN::Plugins::ExploitDev do
60
60
  expect(row[:gadgets].map { |g| g[:gadget] }).to include(a_string_matching(/pop rdi/i))
61
61
  end
62
62
 
63
- it 'ret2libc builds a packed chain from ELF resolve + pop rdi' do
63
+ it 'ret2libc builds a packed chain from ELF resolve + pop rdi', :x86_64_binary do
64
64
  Dir.mktmpdir('pwn-ret2libc-') do |dir|
65
65
  src = File.join(dir, 'vuln.c')
66
66
  bin = File.join(dir, 'vuln')
@@ -64,7 +64,16 @@ describe PWN::Plugins::Jobs do
64
64
 
65
65
  it 'pages binary logs with byte offsets and distinguishes EOF from completion' do
66
66
  row = described_class.start(command: "printf 'abc\\377def'; sleep 1", max_runtime: 5)
67
- first = described_class.job_tail(handle: row[:id], offset: 0, length: 4)
67
+ first = Timeout.timeout(8) do
68
+ loop do
69
+ page = described_class.job_tail(handle: row[:id], offset: 0, length: 4)
70
+ break page if page[:bytes].to_i.positive?
71
+
72
+ raise 'job ended before log bytes' unless page[:status] == 'RUNNING'
73
+
74
+ sleep 0.05
75
+ end
76
+ end
68
77
  expect(first).to include(bytes: 4, next_offset: 4, encoding: 'base64', eof: false, status: 'RUNNING')
69
78
  expect(Base64.strict_decode64(first[:data])).to eq("abc\xFF".b)
70
79
  last = described_class.tail(id: row[:id], offset: 4, length: 100)
@@ -13,13 +13,31 @@ describe PWN::Plugins::REDB do
13
13
  expect(described_class).to respond_to :help
14
14
  end
15
15
 
16
+ it 'records ARM bl strcpy xrefs from objdump' do
17
+ Dir.mktmpdir('redb-arm') do |dir|
18
+ stub_const('PWN::Plugins::REDB::ROOT', File.join(dir, 'redb'))
19
+ bin = File.join(dir, 'x')
20
+ File.write(bin, 'fake')
21
+ dump = <<~ASM
22
+ 0000000000000000 <caller>:
23
+ 0: d10043ff sub sp, sp, #0x10
24
+ 4: 94000000 bl 8 <strcpy@plt>
25
+ ASM
26
+ allow(Open3).to receive(:capture2).with('nm', '-C', bin).and_return(['', 1])
27
+ allow(Open3).to receive(:capture2).with('objdump', '-d', bin).and_return([dump, 1])
28
+ allow(Open3).to receive(:capture2).with('strings', '-a', bin).and_return(['', 1])
29
+ described_class.open(bin: bin)
30
+ expect(described_class.xrefs_to(bin: bin, sym: 'strcpy').map { |row| row['dst'] || row[:dst] }).to include('strcpy')
31
+ end
32
+ end
33
+
16
34
  it 'answers who calls strcpy from cache on a second open' do
17
35
  Dir.mktmpdir('redb') do |dir|
18
36
  stub_const('PWN::Plugins::REDB::ROOT', File.join(dir, 'redb'))
19
37
  src = File.join(dir, 'x.c')
20
38
  bin = File.join(dir, 'x')
21
39
  File.write(src, "#include <string.h>\nvoid caller(void){ char b[8]; strcpy(b, \"x\"); }\nint main(void){ caller(); return 0; }\n")
22
- system('cc', '-O0', '-g', '-fno-builtin', '-fno-inline', '-o', bin, src)
40
+ expect(system('cc', '-O0', '-g', '-fno-builtin', '-fno-inline', '-o', bin, src)).to be(true)
23
41
  described_class.open(bin: bin)
24
42
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
43
  described_class.open(bin: bin)
@@ -113,6 +113,7 @@ describe PWN::Plugins::REPL, 'mesh packet channel routing' do
113
113
  sent = Meshtastic::ToRadio.decode(args[:to_radio]).packet
114
114
  end
115
115
  allow(described_class).to receive(:mesh_maybe_dispatch_to_pwn_ai)
116
+ allow(described_class).to receive(:mesh_wait_tx_slot).and_return(:ok)
116
117
  described_class.send(:mesh_send_text, env: mesh_env, obj: radio, text: 'Local message', channel_name: 'LongFast')
117
118
  expect(sent.decoded.payload).to eq('Local message')
118
119
  packet = { decoded: { emoji: 1, reply_id: sent.id } }
@@ -40,6 +40,47 @@ describe PWN::Plugins::REPL, 'mesh payload chunking' do
40
40
  expect(reconstructed).to eq(body)
41
41
  end
42
42
 
43
+ it 'does not split a word across chunk boundaries' do
44
+ words = (1..80).map { |i| "token#{i}" }
45
+ body = words.join(' ')
46
+ chunks = described_class.send(:mesh_text_chunks, text: body)
47
+ bodies = chunks.map { |piece| piece.sub(%r{\A\(\d+/\d+\) }, '') }
48
+ expect(bodies.join).to eq(body)
49
+ expect(chunks.size).to be > 1
50
+ bodies[0..-2].each do |piece|
51
+ expect(piece).to match(/[[:space:]]\z/)
52
+ end
53
+ expect(bodies.join.split).to eq(words)
54
+ end
55
+
56
+ it 'still splits a single word that cannot fit in one payload' do
57
+ body = 'A' * (max + 10)
58
+ chunks = described_class.send(:mesh_text_chunks, text: body)
59
+ expect(chunks.size).to be > 1
60
+ expect(chunks.map { |piece| piece.sub(%r{\A\(\d+/\d+\) }, '') }.join).to eq(body)
61
+ end
62
+
63
+ it 'does not treat RATE_LIMIT_EXCEEDED as a free TX slot' do
64
+ obj = {
65
+ proto_data: [{ packet: { decoded: { portnum: :ROUTING_APP, request_id: 7, payload: { error_reason: :RATE_LIMIT_EXCEEDED } } } }],
66
+ rx_mutex: Mutex.new
67
+ }
68
+ t = Time.now
69
+ expect(described_class.send(:mesh_wait_tx_slot, obj: obj, since: 0, timeout: 0.25, request_id: 7)).to eq(:rate_limited)
70
+ expect(Time.now - t).to be < 0.2
71
+ end
72
+
73
+ it 'retries a rate-limited chunk instead of sending the next one' do
74
+ body = 'A' * (max + 10)
75
+ sent = []
76
+ allow(Meshtastic::Serial).to receive(:send_text) { |h| sent << h[:text] }
77
+ allow(described_class).to receive(:mesh_wait_tx_slot).and_return(:rate_limited, :ok, :ok)
78
+ described_class.send(:mesh_send_text, env: env, obj: { proto_data: [] }, text: body, to: '!ffffffff')
79
+ expect(sent.size).to be > 2
80
+ expect(sent[0]).to eq(sent[1])
81
+ expect(sent[2]).not_to eq(sent[0])
82
+ end
83
+
43
84
  it 'keeps each chunk small enough that the encoded ToRadio fits a 256-byte LoRa frame' do
44
85
  body = 'A' * max
45
86
  chunks = described_class.send(:mesh_text_chunks, text: body)
@@ -76,17 +117,17 @@ describe PWN::Plugins::REPL, 'mesh payload chunking' do
76
117
  body = 'A' * (max + 10)
77
118
  obj = { proto_data: [], rx_mutex: Mutex.new }
78
119
  allow(Meshtastic::Serial).to receive(:send_text)
79
- allow(described_class).to receive(:mesh_wait_tx_slot)
120
+ allow(described_class).to receive(:mesh_wait_tx_slot).and_return(:ok)
80
121
  described_class.send(:mesh_send_text, env: env, obj: obj, text: body, to: '!ffffffff')
81
122
  n = described_class.send(:mesh_text_chunks, text: body).size
82
- expect(described_class).to have_received(:mesh_wait_tx_slot).exactly(n - 1).times
123
+ expect(described_class).to have_received(:mesh_wait_tx_slot).exactly(n).times
83
124
  end
84
125
 
85
- it 'does not wait for a TX slot after a single payload' do
126
+ it 'waits for a TX slot after a single radio payload' do
86
127
  allow(Meshtastic::Serial).to receive(:send_text)
87
- allow(described_class).to receive(:mesh_wait_tx_slot)
128
+ allow(described_class).to receive(:mesh_wait_tx_slot).and_return(:ok)
88
129
  described_class.send(:mesh_send_text, env: env, obj: { proto_data: [] }, text: 'hi', to: '!ffffffff')
89
- expect(described_class).not_to have_received(:mesh_wait_tx_slot)
130
+ expect(described_class).to have_received(:mesh_wait_tx_slot).once
90
131
  end
91
132
 
92
133
  it 'does not treat QueueStatus free as the chunk being on the air' do
@@ -40,6 +40,35 @@ describe PWN::Plugins::REPL, 'mesh compose history' do
40
40
  described_class.send(:mesh_drain_events)
41
41
  end
42
42
 
43
+ it 'does not turn spinner cursor restores into a notice overlay' do
44
+ events = Queue.new
45
+ stub_const('PWN::MeshEvents', events)
46
+ described_class.send(:mesh_capture_output, run: proc {
47
+ 3.times { $stdout.write("\e[?25h") }
48
+ $stderr.write("\e[?25h")
49
+ })
50
+ expect(events).to be_empty
51
+ end
52
+
53
+ it 'does not notice TTYSpinner cursor restore during capture' do
54
+ events = Queue.new
55
+ stub_const('PWN::MeshEvents', events)
56
+ described_class.send(:mesh_capture_output, run: proc {
57
+ PWN::Plugins::TTYSpinner.send(:show_cursor, spin: nil)
58
+ })
59
+ expect(events).to be_empty
60
+ end
61
+
62
+ it 'keeps real warnings after stripping cursor sequences' do
63
+ events = Queue.new
64
+ stub_const('PWN::MeshEvents', events)
65
+ described_class.send(:mesh_capture_output, run: proc {
66
+ $stderr.write("\e[?25hWARNING: decoder fixture\e[?25h\n")
67
+ })
68
+ expect(events.pop(true)[:notice]).to eq('WARNING: decoder fixture')
69
+ expect(events).to be_empty
70
+ end
71
+
43
72
  it 'captures dependency stdout and stderr and restores both streams on failure' do
44
73
  events = Queue.new
45
74
  stub_const('PWN::MeshEvents', events)
data/spec/spec_helper.rb CHANGED
@@ -31,6 +31,7 @@ RSpec.configure do |config|
31
31
  end
32
32
  config.filter_run_excluding :rtl_sdr_hardware unless ENV['PWN_TEST_RTL_SDR_HARDWARE'] == '1'
33
33
  config.filter_run_excluding :combo_nation_mcp unless ENV['PWN_TEST_COMBO_NATION_MCP'] == '1'
34
+ config.filter_run_excluding :x86_64_binary unless RbConfig::CONFIG['host_cpu'].to_s.match?(/amd64|x86_64/)
34
35
 
35
36
  next if ENV['PWN_SPEC_VERBOSE']
36
37
 
@@ -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.741
4
+ version: 0.5.743
5
5
  platform: ruby
6
6
  authors:
7
7
  - 0day Inc.