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.
- checksums.yaml +4 -4
- data/Gemfile +2 -2
- data/documentation/Blockchain.md +107 -9
- data/documentation/pwn-ai-Agent.md +310 -4
- data/documentation/pwn_silent_help_learn_demo.gif +0 -0
- data/etc/default_skills/pwn/banner/SKILL.md +7 -3
- data/etc/default_skills/pwn/blockchain/btc/SKILL.md +8 -5
- data/etc/default_skills/pwn/blockchain/eth/SKILL.md +12 -3
- data/etc/default_skills/pwn/blockchain/eth/references/urls.md +1 -1
- data/etc/default_skills/pwn/plugins/repl/SKILL.md +5 -0
- data/etc/default_skills/pwn/plugins/repl/ai/SKILL.md +4 -0
- data/etc/default_skills/pwn/plugins/repl/ai/references/urls.md +5 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console/SKILL.md +48 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console_commands/SKILL.md +47 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console_usage/SKILL.md +48 -0
- data/etc/default_skills/pwn/plugins/repl/ai_swarm/SKILL.md +46 -0
- data/lib/pwn/ai/agent/loop.rb +287 -18
- data/lib/pwn/ai/agent/swarm.rb +28 -4
- data/lib/pwn/ai/open_ai.rb +4 -2
- data/lib/pwn/banner.rb +743 -2
- data/lib/pwn/blockchain/btc.rb +494 -196
- data/lib/pwn/blockchain/eth.rb +440 -81
- data/lib/pwn/blockchain.rb +6 -0
- data/lib/pwn/config.rb +22 -0
- data/lib/pwn/migrate.rb +9 -1
- data/lib/pwn/plugins/repl/ai.rb +182 -17
- data/lib/pwn/plugins/repl/ai_console.rb +1581 -0
- data/lib/pwn/plugins/repl/ai_console_commands.rb +282 -0
- data/lib/pwn/plugins/repl/ai_console_usage.rb +144 -0
- data/lib/pwn/plugins/repl/ai_swarm.rb +375 -0
- data/lib/pwn/plugins/repl.rb +25 -3
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/loop_spec.rb +258 -1
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +3 -2
- data/spec/lib/pwn/ai/open_ai_spec.rb +5 -0
- data/spec/lib/pwn/banner_spec.rb +643 -0
- data/spec/lib/pwn/blockchain/btc_spec.rb +372 -1
- data/spec/lib/pwn/blockchain/eth_spec.rb +205 -0
- data/spec/lib/pwn/blockchain_spec.rb +6 -0
- data/spec/lib/pwn/config_spec.rb +10 -0
- data/spec/lib/pwn/migrate_spec.rb +40 -3
- data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +65 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +1434 -0
- data/spec/lib/pwn/plugins/repl/ai_console_usage_spec.rb +29 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +127 -0
- data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +5 -3
- data/spec/lib/pwn/plugins/repl_spec.rb +167 -1
- data/third_party/pwn_rdoc.jsonl +229 -10
- metadata +18 -6
- 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\].*?
|
|
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
|
-
|
|
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)
|