pwn 0.5.749 → 0.5.751

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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/Blockchain.md +107 -9
  4. data/documentation/pwn-ai-Agent.md +310 -4
  5. data/documentation/pwn_silent_help_learn_demo.gif +0 -0
  6. data/etc/default_skills/pwn/banner/SKILL.md +7 -3
  7. data/etc/default_skills/pwn/blockchain/btc/SKILL.md +8 -5
  8. data/etc/default_skills/pwn/blockchain/eth/SKILL.md +12 -3
  9. data/etc/default_skills/pwn/blockchain/eth/references/urls.md +1 -1
  10. data/etc/default_skills/pwn/plugins/repl/SKILL.md +5 -0
  11. data/etc/default_skills/pwn/plugins/repl/ai/SKILL.md +4 -0
  12. data/etc/default_skills/pwn/plugins/repl/ai/references/urls.md +5 -0
  13. data/etc/default_skills/pwn/plugins/repl/ai_console/SKILL.md +48 -0
  14. data/etc/default_skills/pwn/plugins/repl/ai_console_commands/SKILL.md +47 -0
  15. data/etc/default_skills/pwn/plugins/repl/ai_console_usage/SKILL.md +48 -0
  16. data/etc/default_skills/pwn/plugins/repl/ai_swarm/SKILL.md +46 -0
  17. data/lib/pwn/ai/agent/loop.rb +287 -18
  18. data/lib/pwn/ai/agent/swarm.rb +28 -4
  19. data/lib/pwn/ai/open_ai.rb +4 -2
  20. data/lib/pwn/banner.rb +743 -2
  21. data/lib/pwn/blockchain/btc.rb +494 -196
  22. data/lib/pwn/blockchain/eth.rb +440 -81
  23. data/lib/pwn/blockchain.rb +6 -0
  24. data/lib/pwn/config.rb +22 -0
  25. data/lib/pwn/migrate.rb +9 -1
  26. data/lib/pwn/plugins/repl/ai.rb +182 -17
  27. data/lib/pwn/plugins/repl/ai_console.rb +1581 -0
  28. data/lib/pwn/plugins/repl/ai_console_commands.rb +282 -0
  29. data/lib/pwn/plugins/repl/ai_console_usage.rb +144 -0
  30. data/lib/pwn/plugins/repl/ai_swarm.rb +375 -0
  31. data/lib/pwn/plugins/repl.rb +25 -3
  32. data/lib/pwn/version.rb +1 -1
  33. data/spec/lib/pwn/ai/agent/loop_spec.rb +258 -1
  34. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +3 -2
  35. data/spec/lib/pwn/ai/open_ai_spec.rb +5 -0
  36. data/spec/lib/pwn/banner_spec.rb +643 -0
  37. data/spec/lib/pwn/blockchain/btc_spec.rb +372 -1
  38. data/spec/lib/pwn/blockchain/eth_spec.rb +205 -0
  39. data/spec/lib/pwn/blockchain_spec.rb +6 -0
  40. data/spec/lib/pwn/config_spec.rb +10 -0
  41. data/spec/lib/pwn/migrate_spec.rb +40 -3
  42. data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +65 -0
  43. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +1434 -0
  44. data/spec/lib/pwn/plugins/repl/ai_console_usage_spec.rb +29 -0
  45. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +127 -0
  46. data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +5 -3
  47. data/spec/lib/pwn/plugins/repl_spec.rb +167 -1
  48. data/third_party/pwn_rdoc.jsonl +229 -10
  49. metadata +18 -6
  50. data/etc/default_skills/pwn/blockchain/btc/references/urls.md +0 -3
@@ -5,6 +5,263 @@ require 'tmpdir'
5
5
  require 'fileutils'
6
6
 
7
7
  describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
8
+ describe 'operator steering' do
9
+ include_context 'pwn tmp sandbox'
10
+
11
+ around do |example|
12
+ require 'timeout'
13
+ Timeout.timeout(15, Class.new(Exception)) { example.run } # rubocop:disable Lint/InheritException -- must escape provider retry rescues
14
+ end
15
+
16
+ it 'isolates simultaneous model windows and input readers across sessions' do
17
+ inputs = Array.new(2) { IO.pipe }
18
+ entered = Queue.new
19
+ completed = Queue.new
20
+ releases = Array.new(2) { Queue.new }
21
+ threads = inputs.each_with_index.map do |(input, _writer), index|
22
+ Thread.new do
23
+ control = described_class::Steering.new(input: input, output: StringIO.new)
24
+ result = control.with_reader do
25
+ control.model(messages: []) do
26
+ entered << index
27
+ releases[index].pop
28
+ end
29
+ control.checkpoint(messages: [])
30
+ :original
31
+ rescue described_class::Steering::Restart => e
32
+ e.instructions
33
+ end
34
+ completed << [index, result, control.reader.alive?]
35
+ end
36
+ end
37
+ expect([entered.pop, entered.pop].sort).to eq([0, 1])
38
+ inputs[0][1].puts('/steer only session zero')
39
+ expect(completed.pop).to eq([0, ['only session zero'], false])
40
+ expect(threads[1]).to be_alive
41
+ releases[1] << true
42
+ expect(completed.pop).to eq([1, :original, false])
43
+ ensure
44
+ threads&.each do |thread|
45
+ thread.kill if thread.alive?
46
+ thread.join
47
+ end
48
+ inputs&.flatten&.each { |io| io.close unless io.closed? }
49
+ end
50
+
51
+ it 'disables competing trace step reads while the input broker owns the terminal' do
52
+ input, writer = IO.pipe
53
+ expect(PWN::AI::Agent::Loop).to receive(:run) do
54
+ expect(PWN::Plugins::Log).not_to receive(:wait_trace_step!)
55
+ described_class.send(:wait_trace_step!, label: 'engine')
56
+ 'done'
57
+ end
58
+ expect(PWN::Plugins::REPL.pwn_ai_run_steerable(input: input, output: StringIO.new, request: 'test')).to eq('done')
59
+ expect(Thread.current[:pwn_steering_input]).to be_nil
60
+ ensure
61
+ input&.close
62
+ writer&.close
63
+ end
64
+
65
+ it 'does not accept or preserve a final answer superseded during finalization' do
66
+ input, writer = IO.pipe
67
+ control = described_class::Steering.new(input: input, output: StringIO.new)
68
+ sid = PWN::Sessions.create(title: 'steered final')[:id]
69
+ allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(false)
70
+ allow(described_class).to receive(:should_auto_introspect?).and_return(false)
71
+ calls = 0
72
+ allow(described_class).to receive(:call_engine) do |opts|
73
+ calls += 1
74
+ expect(opts[:messages].none? { |row| row[:content] == 'Stale final.' }).to be(true)
75
+ { role: 'assistant', content: calls == 1 ? 'Stale final.' : 'Yellow.', tool_calls: [] }
76
+ end
77
+ allow(described_class).to receive(:may_finalize?) do
78
+ control.submit('what color is a lemon?') if calls == 1
79
+ true
80
+ end
81
+ result = described_class.run(steering: control, request: 'what color is a passion fruit?', session_id: sid,
82
+ system_role_content: 'test', enabled_toolsets: [])
83
+ expect(result).to eq('Yellow.')
84
+ expect(PWN::Sessions.load(session_id: sid).none? { |row| row[:content] == 'Stale final.' }).to be(true)
85
+ ensure
86
+ input&.close
87
+ writer&.close
88
+ end
89
+
90
+ it 'rejects a second reader for the same input without disturbing the first' do
91
+ input, writer = IO.pipe
92
+ control = described_class::Steering.new(input: input, output: StringIO.new)
93
+ control.with_reader do
94
+ other = described_class::Steering.new(input: input, output: StringIO.new)
95
+ expect { other.with_reader { nil } }.to raise_error(ArgumentError, /already owned/)
96
+ expect(control.reader).to be_alive
97
+ end
98
+ expect(control.reader).not_to be_alive
99
+ ensure
100
+ input&.close
101
+ writer&.close
102
+ end
103
+
104
+ it 'queues multiple directives FIFO and ends the reader on EOF without losing a final unterminated line' do
105
+ input, writer = IO.pipe
106
+ control = described_class::Steering.new(input: input, output: StringIO.new)
107
+ control.with_reader do
108
+ writer.write("/steer first\n/steer second")
109
+ writer.close
110
+ expect(control.reader.join(2)).to eq(control.reader)
111
+ expect { control.checkpoint(messages: []) }.to raise_error(described_class::Steering::Restart) do |error|
112
+ expect(error.instructions).to eq(%w[first second])
113
+ end
114
+ end
115
+ expect(input.gets).to be_nil
116
+ ensure
117
+ input&.close
118
+ writer&.close unless writer&.closed?
119
+ end
120
+
121
+ it 'restores a real PTY and joins its reader on CTRL+C' do
122
+ require 'pty'
123
+ require 'io/console'
124
+ master, slave = PTY.open
125
+ original = slave.echo?
126
+ control = described_class::Steering.new(input: slave, output: StringIO.new)
127
+ expect { control.with_reader { raise Interrupt } }.to raise_error(Interrupt)
128
+ expect(control.reader).not_to be_alive
129
+ expect(slave).to be_tty
130
+ expect(slave.echo?).to eq(original)
131
+ ensure
132
+ master&.close
133
+ slave&.close
134
+ end
135
+
136
+ it 'uses the active steered request rather than the first historical user to select required tools' do
137
+ # The sandbox restores :ai on the original Env; replacing the constant
138
+ # would leave that original object configured for Ollama after teardown.
139
+ PWN::Env[:ai] = { active: 'openai', openai: {} }
140
+ Thread.current[:pwn_active_steering_request] = 'what color is a lemon?'
141
+ expect(PWN::AI::OpenAI).to receive(:chat_with_tools).with(hash_including(tool_choice: 'auto')).and_return(choices: [{ message: { role: 'assistant', content: 'Yellow.' } }])
142
+ described_class.send(
143
+ :invoke_provider_chat,
144
+ engine: :openai,
145
+ messages: [{ role: 'user', content: 'Write a file to /tmp/stale.txt' }, { role: 'user', content: 'what color is a lemon?' }],
146
+ tools: [{ type: 'function', function: { name: 'shell' } }]
147
+ )
148
+ ensure
149
+ Thread.current[:pwn_active_steering_request] = nil
150
+ end
151
+
152
+ it 'lets an active tool finish, forwards prompt input, preserves evidence, and suppresses the rest of its batch' do
153
+ input, writer = IO.pipe
154
+ output = StringIO.new
155
+ entered = Queue.new
156
+ queued = Queue.new
157
+ sid = PWN::Sessions.create(title: 'steer tool')[:id]
158
+ old_request = "Write a report to #{File.join(@tmp, 'old-report.txt')}"
159
+ latest = 'what color is a lemon?'
160
+ observed = []
161
+ on_tool = ->(name, _args, result) { observed << [name, result] }
162
+ allow(output).to receive(:puts).and_wrap_original do |method, text|
163
+ method.call(text)
164
+ queued << true if text.include?('steering queued')
165
+ end
166
+ allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(false)
167
+ allow(described_class).to receive(:should_auto_introspect?).and_return(false)
168
+ tool_calls = %w[first stale].map { |id| { id: id, type: 'function', function: { name: 'shell', arguments: '{}' } } }
169
+ count = 0
170
+ allow(described_class).to receive(:call_engine) do |opts|
171
+ count += 1
172
+ if count == 1
173
+ { role: 'assistant', tool_calls: tool_calls }
174
+ else
175
+ expect(count).to eq(2)
176
+ messages = opts[:messages]
177
+ expect(messages.select { |row| row[:role] == 'user' }.map { |row| row[:content] }).to include(old_request, latest)
178
+ results = messages.select { |row| row[:role] == 'tool' }
179
+ expect(results.map { |row| row[:tool_call_id] }).to eq(%w[first stale])
180
+ expect(results.first[:content]).to include('tool answer')
181
+ expect(results.last[:content]).to include('Not executed')
182
+ expect(PWN::AI::Agent::TurnFinalizer.required_artifacts(request: latest)).to be_empty
183
+ expect(Thread.current[:pwn_artifact_contract][:request]).to eq(latest)
184
+ expect(PWN::AI::Agent::OpenGoal.current[:request]).to eq(latest)
185
+ { role: 'assistant', content: 'Yellow.', tool_calls: [] }
186
+ end
187
+ end
188
+ expect(PWN::AI::Agent::Dispatch).to receive(:call).once do |opts|
189
+ expect(opts[:tool_call][:id]).to eq('first')
190
+ entered << true
191
+ answer = input.gets
192
+ File.write(File.join(@tmp, 'finished-work'), answer)
193
+ JSON.generate(success: true, result: { stdout: answer, exitstatus: 0 })
194
+ end
195
+ producer = Thread.new do
196
+ entered.pop
197
+ writer.puts("/steer #{latest}")
198
+ queued.pop
199
+ writer.puts('tool answer')
200
+ end
201
+ result = PWN::Plugins::REPL.pwn_ai_run_steerable(
202
+ input: input, output: output, request: old_request,
203
+ session_id: sid, system_role_content: 'test system', enabled_toolsets: [], on_tool: on_tool
204
+ )
205
+ expect(result).to eq('Yellow.')
206
+ expect(File.read(File.join(@tmp, 'finished-work'))).to eq("tool answer\n")
207
+ expect(output.string).to include('wait until the current tool finishes', 'not undone')
208
+ expect(observed.filter_map { |name, result| result if name == 'shell' }).to contain_exactly(include('tool answer'))
209
+ ensure
210
+ producer&.join(2)
211
+ producer&.kill if producer&.alive?
212
+ input&.close unless input&.closed?
213
+ writer&.close unless writer&.closed?
214
+ end
215
+
216
+ it 'reads a real PTY while the foreground model is blocked and restarts with the latest user instruction' do
217
+ require 'fcntl'
218
+ require 'pty'
219
+ require 'io/console'
220
+ writer, input = PTY.open
221
+ original_flags = input.fcntl(Fcntl::F_GETFL)
222
+ original_echo = input.echo?
223
+ output = StringIO.new
224
+ sid = PWN::Sessions.create(title: 'steering')[:id]
225
+ entered = Queue.new
226
+ allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(false)
227
+ allow(described_class).to receive(:should_auto_introspect?).and_return(false)
228
+ calls = []
229
+ owner = Thread.current
230
+ allow(described_class).to receive(:call_engine) do |opts|
231
+ expect(Thread.current).to eq(owner)
232
+ calls << Marshal.load(Marshal.dump(opts[:messages]))
233
+ if calls.length == 1
234
+ entered << true
235
+ Queue.new.pop
236
+ end
237
+ { role: 'assistant', content: 'Yellow.', tool_calls: [] }
238
+ end
239
+ producer = Thread.new do
240
+ entered.pop
241
+ writer.puts('/steer what color is a lemon?')
242
+ end
243
+ result = PWN::Plugins::REPL.pwn_ai_run_steerable(
244
+ input: input, output: output, request: 'what color is a passion fruit?',
245
+ session_id: sid, system_role_content: 'test system', enabled_toolsets: []
246
+ )
247
+ expect(result).to eq('Yellow.')
248
+ expect(calls.length).to eq(2)
249
+ users = calls.last.select { |row| row[:role] == 'user' }.map { |row| row[:content] }
250
+ expect(users).to include('what color is a passion fruit?', 'what color is a lemon?')
251
+ persisted = PWN::Sessions.load(session_id: sid).select { |row| row[:role] == 'user' }.map { |row| row[:content] }
252
+ expect(persisted).to eq(['what color is a passion fruit?', 'what color is a lemon?'])
253
+ expect(output.string).to include('steering queued')
254
+ expect(input).not_to be_closed
255
+ expect(input.fcntl(Fcntl::F_GETFL)).to eq(original_flags)
256
+ expect(input.echo?).to eq(original_echo)
257
+ ensure
258
+ producer&.join(2)
259
+ producer&.kill if producer&.alive?
260
+ input&.close unless input&.closed?
261
+ writer&.close unless writer&.closed?
262
+ end
263
+ end
264
+
8
265
  it 'should display information for authors' do
9
266
  authors_response = PWN::AI::Agent::Loop
10
267
  expect(authors_response).to respond_to :authors
@@ -680,7 +937,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
680
937
 
681
938
  it 'does not Dispatch.call an identical payload once the checkpoint fires' do
682
939
  src = File.read(described_class.method(:run).source_location.first)
683
- chunk = src[/if Thread\.current\[:pwn_extinguished\].*?tools_called \+= 1/m]
940
+ chunk = src[/if Thread\.current\[:pwn_extinguished\].*?note_payload_result!/m]
684
941
  expect(chunk).to include('same_n = note_same_payload!')
685
942
  expect(chunk.index('note_same_payload!')).to be < chunk.index('Dispatch.call')
686
943
  expect(chunk.index('Dispatch.call')).to be < chunk.index('note_payload_result!')
@@ -194,9 +194,10 @@ describe PWN::AI::Agent::TurnFinalizer do
194
194
 
195
195
  it 'precommits before cheap returns and uses the committed list for dispatch observation' do
196
196
  source = File.read(PWN::AI::Agent::Loop.method(:run).source_location.first)
197
- run = source[source.index('public_class_method def self.run(opts = {})')..]
197
+ expect(source).to include('run_turn(options)')
198
+ run = source[source.index('private_class_method def self.run_turn(opts = {})')..]
198
199
  expect(run.index('commit_artifacts!')).to be < run.index('cheap =')
199
- expect(run).to include('allow_text_only = required_artifacts.empty?', 'cheap = allow_text_only', 'declared_paths = required_artifacts')
200
+ expect(run).to include('allow_text_only = steering.nil? && required_artifacts.empty?', 'cheap = allow_text_only', 'declared_paths = required_artifacts')
200
201
  expect(run).to include('Thread.current[:pwn_artifact_contract] = prior_artifact_contract')
201
202
  end
202
203
 
@@ -127,6 +127,11 @@ describe PWN::AI::OpenAI do
127
127
  expect(src).to include('enabled?(engine: :openai)')
128
128
  end
129
129
 
130
+ it 'forwards an explicit none effort on Responses models that support it' do
131
+ body = described_class.send(:responses_http_body, model: 'gpt-5.5', messages: [], reasoning_effort: 'none')
132
+ expect(body[:reasoning]).to include(effort: 'none')
133
+ end
134
+
130
135
  it 'treats gpt-5 family as reasoning models' do
131
136
  expect(described_class.send(:reasoning_model?, model: 'gpt-5.5')).to eq(true)
132
137
  expect(described_class.send(:reasoning_model?, model: 'gpt-5-mini')).to eq(true)