pwn 0.5.760 → 0.5.762

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.
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'pry'
5
+
6
+ RSpec.describe 'Swarm solve' do
7
+ include_context 'pwn tmp sandbox'
8
+
9
+ before do
10
+ @workspace = File.join(@tmp, 'work')
11
+ FileUtils.mkdir_p(@workspace)
12
+ allow(PWN::AI::Agent::Swarm).to receive(:build_persona_prompt).and_return('fixture')
13
+ allow(PWN::AI::Agent::Swarm).to receive(:child_inbox).and_return({})
14
+ allow(PWN::AI::Agent::Swarm).to receive(:child_honesty).and_return({})
15
+ allow(PWN::AI::Agent::Swarm).to receive(:bus_append).and_return({})
16
+ @turns = []
17
+ @bad = false
18
+ @reject = false
19
+ @repair = false
20
+ allow(PWN::AI::Agent::Loop).to receive(:run) do |opts|
21
+ handoff = JSON.parse(opts[:system_role_content].split("SOLVE HANDOFF\n").last, symbolize_names: true)
22
+ @turns << [opts, handoff]
23
+ revision = handoff[:revision]
24
+ answer = case handoff[:phase]
25
+ when 'assess' then { requirements: ['The file contains the requested greeting'], risks: ['Wrong bytes'] }
26
+ when 'build'
27
+ File.write(File.join(@workspace, 'greeting.txt'), @bad ? 'wrong' : 'hello')
28
+ { artifacts: ['greeting.txt'], summary: 'candidate' }
29
+ when 'critique' then { revision: revision, objections: @reject ? ['Wrong output'] : [] }
30
+ when 'verify'
31
+ { revision: revision, checks: [{ requirements: handoff[:requirements].keys, command: 'cat greeting.txt', stdout: 'hello' }] }
32
+ when 'integrate'
33
+ @bad = false if @repair
34
+ { revision: revision, decision: handoff[:evidence].all? { |row| row[:passed] } ? 'accept' : 'repair', repair: 'Write hello', summary: 'Greeting delivered' }
35
+ end
36
+ @hook&.call(handoff, answer)
37
+ JSON.generate(answer)
38
+ end
39
+ end
40
+
41
+ it 'executes the real nested Loop with a deterministic provider and host tool' do
42
+ allow(PWN::AI::Agent::Loop).to receive(:run).and_call_original
43
+ allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(false)
44
+ PWN::Env[:ai][:active] = :openai
45
+ PWN::Env[:ai][:openai] = { key: 'offline-fixture-key' }
46
+ @agent_cfg[:plan_first] = false
47
+ runtime = double('enclosing request route')
48
+ expect(runtime).not_to receive(:call)
49
+ Thread.current[:pwn_request_runtime] = runtime
50
+ allow(PWN::AI::OpenAI).to receive(:chat_with_tools) do |opts|
51
+ system = opts[:messages].find { |row| row[:role].to_s == 'system' }[:content]
52
+ handoff = JSON.parse(system.split("SOLVE HANDOFF\n").last, symbolize_names: true)
53
+ phase = handoff[:phase]
54
+ revision = handoff[:revision]
55
+ content = case phase
56
+ when 'assess' then { requirements: ['Greeting bytes'], risks: [] }
57
+ when 'build'
58
+ next({ choices: [{ message: { role: 'assistant', content: nil, tool_calls: [{ id: 'write-greeting', type: 'function', function: { name: 'shell', arguments: JSON.generate(command: "printf hello > #{Shellwords.escape(File.join(@workspace, 'greeting.txt'))}", timeout: 5) } }] } }] }) unless File.exist?(File.join(@workspace, 'greeting.txt'))
59
+
60
+ { artifacts: ['greeting.txt'], summary: 'written' }
61
+ when 'critique' then { revision: revision, objections: [] }
62
+ when 'verify' then { revision: revision, checks: [{ requirements: %w[R0 R1], command: 'cat greeting.txt', stdout: 'hello' }] }
63
+ when 'integrate' then { revision: revision, decision: 'accept', summary: 'Verified greeting' }
64
+ end
65
+ { choices: [{ message: { role: 'assistant', content: JSON.generate(content), tool_calls: [] } }] }
66
+ end
67
+ require 'timeout'
68
+ controller = PWN::Plugins::REPL::AISwarm::Controller.new(session_id: 'real-loop-fixture')
69
+ out = Timeout.timeout(15) do
70
+ controller.execute(line: '/swarm mission Write hello to greeting.txt', workspace: @workspace)
71
+ controller.send(:workers).last.value
72
+ end
73
+ expect(out).to include(status: 'accepted')
74
+ expect(out[:evidence].first).to include(stdout: 'hello', exitstatus: 0)
75
+ ensure
76
+ controller&.close
77
+ Thread.current[:pwn_request_runtime] = nil
78
+ end
79
+
80
+ it 'runs all roles with the unchanged request and accepts only executed evidence for the artifact revision' do
81
+ request = 'Write the greeting hello to greeting.txt'
82
+ states = []
83
+ out = PWN::AI::Agent::Swarm.solve(
84
+ request: request, workspace: @workspace, on_state: ->(state) { states << state },
85
+ on_tool: ->(*) { expect(states.last[:roles]['verifier']).to eq('checking') }
86
+ )
87
+ expect(out).to include(ok: true, status: 'accepted', request: request)
88
+ expect(@turns.map { |_, handoff| handoff[:phase] }).to eq(%w[assess build critique verify integrate])
89
+ expect(@turns.map { |opts, _| opts[:request] }.uniq).to eq([request])
90
+ expect(out[:evidence].first).to include(passed: true, stdout: 'hello', exitstatus: 0, revision: out[:revision])
91
+ expect(File.read(File.join(@workspace, 'greeting.txt'))).to eq('hello')
92
+ record = JSON.parse(File.read(out[:record_path]))
93
+ expect(record['revision']).to eq(out[:revision])
94
+ expect(record['evidence'].first['stdout']).to eq('hello')
95
+ expect(record['records']).to include(include('phase' => 'candidate', 'revision' => out[:revision], 'artifacts' => out[:artifacts]))
96
+ expect(out[:reply]).to include('R0', 'greeting.txt', out[:revision])
97
+ end
98
+
99
+ it 'routes the legacy slash entry to an executed solve, not a normal model request' do
100
+ pry = Pry.new
101
+ controller = nil
102
+ Dir.chdir(@workspace) do
103
+ expect(PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: '/swarm mission Write hello to greeting.txt', pry: pry)).to be(true)
104
+ controller = pry.config.pwn_ai_swarm
105
+ expect(controller).to be_a(PWN::Plugins::REPL::AISwarm::Controller)
106
+ controller.send(:workers).each(&:join)
107
+ expect(controller.snapshot[:jobs].last[:state]).to eq('accepted')
108
+ end
109
+ ensure
110
+ controller&.close
111
+ end
112
+
113
+ it 'binds identical artifact bytes to the specific objective and solve run' do
114
+ first = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
115
+ second = PWN::AI::Agent::Swarm.solve(request: 'Write hello and inspect it', workspace: @workspace)
116
+ expect(first[:artifacts]).to eq(second[:artifacts])
117
+ expect(first[:revision]).not_to eq(second[:revision])
118
+ end
119
+
120
+ it 'rejects failing checks even when integration claims accept' do
121
+ @bad = true
122
+ @hook = ->(handoff, answer) { answer[:decision] = 'accept' if handoff[:phase] == 'integrate' }
123
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)
124
+ expect(out).to include(status: 'incomplete', ok: false)
125
+ expect(out[:evidence].first).to include(stdout: 'wrong', exitstatus: 0, passed: false)
126
+ end
127
+
128
+ it 'repairs through the sole writer and reruns verification against changed bytes' do
129
+ @bad = @repair = true
130
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
131
+ expect(out[:status]).to eq('accepted')
132
+ builds = @turns.select { |_, row| row[:phase] == 'build' }
133
+ expect(builds.length).to eq(2)
134
+ expect(builds.last.last[:repair]).to include(instruction: 'Write hello')
135
+ verifications = out[:records].select { |row| row[:phase] == 'verification' }
136
+ expect(verifications.map { |row| row[:evidence].first[:passed] }).to eq([false, true])
137
+ expect(out[:records].select { |row| row[:phase] == 'verify' }.map { |row| row[:revision] }.uniq.length).to eq(2)
138
+ end
139
+
140
+ it 'does not accept unresolved objections or missing requirement coverage' do
141
+ @reject = true
142
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)
143
+ expect(out[:status]).to eq('incomplete')
144
+ expect(out[:objections]).to eq(['Wrong output'])
145
+ @reject = false
146
+ @hook = ->(handoff, answer) { answer[:checks].first[:requirements] = ['R1'] if handoff[:phase] == 'verify' }
147
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)
148
+ expect(out).to include(status: 'incomplete', uncovered: ['R0'])
149
+ end
150
+
151
+ it 'invalidates evidence if the working artifact changes during integration' do
152
+ @hook = ->(handoff, _answer) { File.write(File.join(@workspace, 'greeting.txt'), 'changed') if handoff[:phase] == 'integrate' }
153
+ expect(PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)[:status]).to eq('incomplete')
154
+ end
155
+
156
+ it 'rejects stale handoffs and copy-modifying verification' do
157
+ @hook = ->(handoff, answer) { answer[:revision] = 'old' if handoff[:phase] == 'critique' }
158
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
159
+ expect(out[:reply]).to include('stale revision')
160
+ @hook = lambda do |handoff, answer|
161
+ answer[:checks].first[:command] = 'printf wrong > greeting.txt; printf hello' if handoff[:phase] == 'verify'
162
+ end
163
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)
164
+ expect(out[:status]).to eq('incomplete')
165
+ expect(File.read(File.join(@workspace, 'greeting.txt'))).to eq('hello')
166
+ end
167
+
168
+ it 'propagates steering to every later role and invalidates the previous candidate' do
169
+ control = PWN::AI::Agent::Solve::Control.new(input: StringIO.new, output: StringIO.new)
170
+ steered = false
171
+ @hook = lambda do |handoff, _answer|
172
+ if handoff[:phase] == 'integrate' && !steered
173
+ control.submit('Also verify exact bytes')
174
+ steered = true
175
+ end
176
+ end
177
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, steering: control)
178
+ expect(out[:status]).to eq('accepted')
179
+ expect(@turns.count { |_, row| row[:phase] == 'build' }).to eq(2)
180
+ expect(out[:requirements]).to include('S1' => 'Also verify exact bytes')
181
+ expect(@turns.last(4).all? { |opts, row| opts[:request] == 'Write hello' && row[:guidance] == ['Also verify exact bytes'] }).to be(true)
182
+ end
183
+
184
+ it 'cancels at a tool boundary without claiming completion or undoing work' do
185
+ control = PWN::AI::Agent::Solve::Control.new(input: StringIO.new, output: StringIO.new)
186
+ @hook = ->(handoff, _answer) { control.stop if handoff[:phase] == 'build' }
187
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, steering: control)
188
+ expect(out[:status]).to eq('cancelled')
189
+ expect(File.read(File.join(@workspace, 'greeting.txt'))).to eq('hello')
190
+ expect(@turns.length).to eq(2)
191
+ end
192
+
193
+ it 'refuses unconfigured providers before any role or login runs' do
194
+ PWN::Env[:ai][:active] = :openai
195
+ PWN::Env[:ai][:openai] = {}
196
+ expect(PWN::AI::Agent::Loop).not_to receive(:run)
197
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
198
+ expect(out[:reply]).to include('configure openai credentials')
199
+ end
200
+
201
+ it 'enforces read-only reviewers at Dispatch even for invented tool calls' do
202
+ @hook = lambda do |handoff, _answer|
203
+ next unless handoff[:phase] == 'critique'
204
+
205
+ raw = PWN::AI::Agent::Dispatch.call(tool_call: { function: { name: 'shell', arguments: JSON.generate(command: "touch #{Shellwords.escape(File.join(@workspace, 'forbidden'))}") } })
206
+ expect(JSON.parse(raw)['success']).to be(false)
207
+ end
208
+ PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
209
+ expect(File).not_to exist(File.join(@workspace, 'forbidden'))
210
+ expect(Thread.current[:pwn_solve_tools]).to be_nil
211
+ end
212
+
213
+ it 'routes configured roles without switching defaults or modifying the global registry' do
214
+ PWN::AI::Agent::Swarm.spawn(name: 'solve_antagonist', role: 'Review', engine: :ollama, model: 'Exact/Reviewer:Tag')
215
+ registry = File.binread(PWN::AI::Agent::Swarm::AGENTS_FILE)
216
+ routes = []
217
+ @hook = ->(handoff, _answer) { routes << [handoff[:role], Thread.current[:pwn_swarm_engine], Thread.current[:pwn_swarm_model]] }
218
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
219
+ expect(out[:status]).to eq('accepted')
220
+ expect(routes.select { |row| row.first == 'antagonist' }).to all(eq(['antagonist', 'ollama', 'Exact/Reviewer:Tag']))
221
+ expect(File.binread(PWN::AI::Agent::Swarm::AGENTS_FILE)).to eq(registry)
222
+ expect(Thread.current[:pwn_swarm_model]).to be_nil
223
+ end
224
+
225
+ it 'never opens OAuth or a credential prompt during a solve provider call' do
226
+ Thread.current[:pwn_solve_tools] = []
227
+ { openai: [PWN::AI::OpenAI, :open_ai_rest_call], anthropic: [PWN::AI::Anthropic, :anthropic_rest_call], grok: [PWN::AI::Grok, :grok_rest_call] }.each do |engine, (provider, method)|
228
+ PWN::Env[:ai][engine] = {}
229
+ expect(provider).not_to receive(:obtain_oauth_bearer_token)
230
+ expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
231
+ expect(provider.send(method, rest_call: 'fixture')).to be_nil
232
+ end
233
+ ensure
234
+ Thread.current[:pwn_solve_tools] = nil
235
+ end
236
+
237
+ it 'pauses dispatch, resumes, and cancels through the coordinator command entry' do
238
+ entered = Queue.new
239
+ release = Queue.new
240
+ @hook = lambda do |handoff, _answer|
241
+ if handoff[:phase] == 'assess'
242
+ entered << true
243
+ release.pop
244
+ end
245
+ end
246
+ controller = PWN::Plugins::REPL::AISwarm::Controller.new(session_id: 'fixture')
247
+ job = controller.execute(line: '/swarm mission Write hello', workspace: @workspace)
248
+ entered.pop
249
+ expect(controller.execute(line: "/swarm pause #{job[:job_id]}")[:ok]).to be(true)
250
+ release << true
251
+ expect(controller.snapshot[:jobs].last[:state]).to eq('paused')
252
+ expect(controller.execute(line: '/swarm dm other write')[:ok]).to be(false)
253
+ expect(controller.execute(line: "/swarm steer #{job[:job_id]} Check bytes")[:ok]).to be(true)
254
+ expect(controller.execute(line: "/swarm resume #{job[:job_id]}")[:ok]).to be(true)
255
+ controller.execute(line: "/swarm cancel #{job[:job_id]}")
256
+ expect(controller.send(:workers).last.join(3)).not_to be_nil
257
+ expect(controller.snapshot[:jobs].last[:state]).to eq('cancelled')
258
+ ensure
259
+ release << true if release
260
+ controller&.close
261
+ end
262
+
263
+ it 'bounds a nonconverging role without inventing a final answer' do
264
+ control = PWN::AI::Agent::Solve::Control.new(input: StringIO.new, output: StringIO.new)
265
+ control.begin_turn
266
+ 25.times { control.model(messages: []) { :fixture } }
267
+ expect { control.model(messages: []) { :unreachable } }.to raise_error(PWN::AI::Agent::Solve::Control::Limit)
268
+ end
269
+
270
+ it 'runs a draft-preserving Mission with team through the curses confirmation entry' do
271
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
272
+ controller = PWN::Plugins::REPL::AISwarm::Controller.new(session_id: 'fixture')
273
+ console.instance_variable_set(:@swarm, controller)
274
+ editor = console.instance_variable_get(:@editor)
275
+ editor.place('Write hello to greeting.txt', 4)
276
+ Dir.chdir(@workspace) do
277
+ console.handle("\u0007")
278
+ console.handle('v')
279
+ expect(controller.snapshot[:jobs]).to be_empty
280
+ expect(console.swarm_content.join).to include('mission')
281
+ console.handle("\r")
282
+ controller.send(:workers).each(&:join)
283
+ expect(controller.snapshot[:jobs].last).to include(state: 'accepted', team: include(roles: include('verifier' => 'done')))
284
+ console.handle("\e")
285
+ expect([editor.text, editor.cursor]).to eq(['Write hello to greeting.txt', 4])
286
+ end
287
+ ensure
288
+ controller&.close
289
+ end
290
+ end