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
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe PWN::Plugins::REPL::AIConsoleUsage do
|
|
6
|
+
it 'counts provider tokens once and estimates only configured USD rates' do
|
|
7
|
+
tracker = described_class::Tracker.new
|
|
8
|
+
tracker.record(engine: :openai, model: 'priced', usage: { input_tokens: 1_000_000, output_tokens: 1_000, input_tokens_details: { cached_tokens: 100 } }, rates: { input_per_million: 2, output_per_million: 8, cache_read_per_million: 1 })
|
|
9
|
+
priced = tracker.snapshot
|
|
10
|
+
expect(priced[:input_tokens]).to eq(1_000_000)
|
|
11
|
+
expect(priced[:cached_tokens]).to eq(100)
|
|
12
|
+
expect(priced[:total_tokens]).to eq(1_001_000)
|
|
13
|
+
expect(priced[:estimated_cost_usd]).to be_within(0.001).of((2 * 0.9999) + 0.0001 + 0.008)
|
|
14
|
+
tracker.record(engine: :openai, model: 'unknown', usage: { prompt_tokens: 10, completion_tokens: 5 })
|
|
15
|
+
mixed = tracker.snapshot
|
|
16
|
+
expect(mixed[:calls]).to eq(2)
|
|
17
|
+
expect(mixed[:cost_status]).to include('partial')
|
|
18
|
+
expect(described_class.estimate(usage: { input_tokens: 5 }, rates: nil)).to be_nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'receives one Loop completion even when provider config is frozen' do
|
|
22
|
+
tracker = described_class::Tracker.new
|
|
23
|
+
Thread.current[:pwn_usage_observer] = tracker.method(:record)
|
|
24
|
+
PWN::AI::Agent::Loop.send(:publish_usage, response: { model: 'offline', usage: { input_tokens: 3, output_tokens: 4 } }, engine: :missing)
|
|
25
|
+
expect(tracker.snapshot[:total_tokens]).to eq(7)
|
|
26
|
+
ensure
|
|
27
|
+
Thread.current[:pwn_usage_observer] = nil
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
6
|
+
include_context 'pwn tmp sandbox'
|
|
7
|
+
|
|
8
|
+
it 'returns visible errors for malformed commands and synchronous backend failures' do
|
|
9
|
+
controller = described_class.new(session_id: 'fixture')
|
|
10
|
+
expect(controller.execute(line: '/swarm ask scout "unfinished')[:ok]).to be(false)
|
|
11
|
+
allow(PWN::AI::Agent::Swarm).to receive(:create).and_raise(IOError, 'fixture storage unavailable')
|
|
12
|
+
expect(controller.execute(line: '/swarm create fixture')).to include(ok: false, error: 'IOError: fixture storage unavailable')
|
|
13
|
+
ensure
|
|
14
|
+
controller&.close
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'exposes roster metadata and retains outcomes without reviving finished jobs' do
|
|
18
|
+
allow(PWN::AI::Agent::Swarm).to receive(:personas).and_return(scout: { role: 'Inspect evidence', engine: 'ollama', model: 'fixture' })
|
|
19
|
+
allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
|
|
20
|
+
allow(PWN::AI::Agent::Swarm).to receive(:ask).and_return(reply: 'Observed fixture result')
|
|
21
|
+
controller = described_class.new(session_id: 'fixture')
|
|
22
|
+
expect(controller.roster).to include(include(name: 'scout', role: 'Inspect evidence', engine: 'ollama', model: 'fixture'))
|
|
23
|
+
queued = controller.execute(line: '/swarm ask scout inspect')
|
|
24
|
+
controller.send(:workers).each(&:join)
|
|
25
|
+
expect(controller.snapshot[:jobs].first).to include(state: 'completed', result: 'Observed fixture result')
|
|
26
|
+
expect(controller.execute(line: "/swarm steer #{queued[:job_id]} again")[:ok]).to be(false)
|
|
27
|
+
expect(controller.execute(line: "/swarm cancel #{queued[:job_id]}")[:ok]).to be(false)
|
|
28
|
+
expect(controller.busy?).to be(false)
|
|
29
|
+
ensure
|
|
30
|
+
controller&.close
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'owns model interrupts on the job thread and copies request-local routing' do
|
|
34
|
+
entered = Queue.new
|
|
35
|
+
allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
|
|
36
|
+
Thread.current[:pwn_workspace_fixture] = :copied
|
|
37
|
+
allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
|
|
38
|
+
control = opts[:steering]
|
|
39
|
+
entered << [control.instance_variable_get(:@owner), Thread.current, Thread.current[:pwn_workspace_fixture], Thread.current[:pwn_steering_input]]
|
|
40
|
+
{ reply: 'done' }
|
|
41
|
+
end
|
|
42
|
+
controller = described_class.new(session_id: 'fixture')
|
|
43
|
+
controller.execute(line: '/swarm ask scout inspect')
|
|
44
|
+
owner, worker, local, marker = entered.pop
|
|
45
|
+
expect(owner).to eq(worker)
|
|
46
|
+
expect(local).to eq(:copied)
|
|
47
|
+
expect(marker).to be_a(PWN::Plugins::REPL::AISwarm::JobControl)
|
|
48
|
+
ensure
|
|
49
|
+
controller&.close
|
|
50
|
+
Thread.current[:pwn_workspace_fixture] = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'passes prior speakers to debate, preserves literal flag-like missions, and retains failures' do
|
|
54
|
+
allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
|
|
55
|
+
calls = []
|
|
56
|
+
allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
|
|
57
|
+
calls << opts
|
|
58
|
+
raise IOError, 'fixture unavailable' if opts[:name] == 'broken'
|
|
59
|
+
|
|
60
|
+
{ reply: "reply from #{opts[:name]}" }
|
|
61
|
+
end
|
|
62
|
+
controller = described_class.new(session_id: 'fixture')
|
|
63
|
+
controller.execute(line: '/swarm debate scout,critic "--literal mission"')
|
|
64
|
+
controller.send(:workers).each(&:join)
|
|
65
|
+
expect(calls.first[:request]).to eq('--literal mission')
|
|
66
|
+
expect(calls.last[:request]).to include('reply from scout', '--literal mission')
|
|
67
|
+
controller.execute(line: '/swarm ask broken inspect')
|
|
68
|
+
controller.send(:workers).each(&:join)
|
|
69
|
+
expect(controller.snapshot[:jobs].last).to include(state: 'failed', error: 'IOError: fixture unavailable')
|
|
70
|
+
ensure
|
|
71
|
+
controller&.close
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'cancels only its owned blocked model and cannot switch swarms while it runs' do
|
|
75
|
+
entered = Queue.new
|
|
76
|
+
foreign = Thread.new { sleep }
|
|
77
|
+
allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
|
|
78
|
+
allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
|
|
79
|
+
opts[:steering].model(messages: []) do
|
|
80
|
+
entered << true
|
|
81
|
+
Queue.new.pop
|
|
82
|
+
end
|
|
83
|
+
opts[:steering].checkpoint(messages: [])
|
|
84
|
+
end
|
|
85
|
+
controller = described_class.new(session_id: 'fixture')
|
|
86
|
+
job = controller.execute(line: '/swarm ask scout inspect')
|
|
87
|
+
entered.pop
|
|
88
|
+
expect(controller.execute(line: '/swarm create other')[:ok]).to be(false)
|
|
89
|
+
expect(controller.execute(line: '/swarm use other')[:ok]).to be(false)
|
|
90
|
+
controller.execute(line: "/swarm cancel #{job[:job_id]}")
|
|
91
|
+
expect(controller.send(:workers).first.join(2)).not_to be_nil
|
|
92
|
+
expect(controller.snapshot[:jobs].first).to include(state: 'cancelled')
|
|
93
|
+
expect(foreign).to be_alive
|
|
94
|
+
controller.close
|
|
95
|
+
expect(controller.execute(line: '/swarm ask scout again')[:ok]).to be(false)
|
|
96
|
+
ensure
|
|
97
|
+
controller&.close
|
|
98
|
+
foreign&.kill&.join
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it 'runs, steers, and cancels a persona through the existing Swarm API' do
|
|
102
|
+
entered = Queue.new
|
|
103
|
+
allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
|
|
104
|
+
entered << opts[:steering]
|
|
105
|
+
opts[:on_tool].call('shell', { command: 'true' }, '{"success":true}')
|
|
106
|
+
sleep 0.01 until opts[:steering].instance_variable_get(:@stopping)
|
|
107
|
+
raise PWN::Plugins::REPL::AISwarm::JobControl::Stopped, 'cancelled'
|
|
108
|
+
end
|
|
109
|
+
events = Queue.new
|
|
110
|
+
controller = described_class.new(session_id: 'console-session')
|
|
111
|
+
allow(PWN::AI::Agent::Swarm).to receive(:personas).and_return(scout: { role: 'scout' })
|
|
112
|
+
allow(PWN::AI::Agent::Swarm).to receive(:spawn).and_return(name: 'scout')
|
|
113
|
+
controller.execute(line: '/swarm spawn scout inspect evidence')
|
|
114
|
+
queued = controller.execute(line: '/swarm ask scout inspect the fixture', on_event: ->(type, text) { events << [type, text] })
|
|
115
|
+
steering = entered.pop
|
|
116
|
+
expect(queued[:ok]).to eq(true)
|
|
117
|
+
expect(controller.snapshot[:jobs].first[:state]).to eq('running')
|
|
118
|
+
controller.execute(line: "/swarm steer #{queued[:job_id]} stop the scan")
|
|
119
|
+
expect(steering.instance_variable_get(:@pending)).to include('stop the scan')
|
|
120
|
+
controller.execute(line: "/swarm cancel #{queued[:job_id]}")
|
|
121
|
+
sleep 0.05
|
|
122
|
+
expect(controller.snapshot[:jobs].first[:state]).to eq('cancelled')
|
|
123
|
+
expect(events.pop[0]).to eq(:tool)
|
|
124
|
+
ensure
|
|
125
|
+
controller&.close
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -189,11 +189,13 @@ describe 'pwn-ai debug final answer placement' do
|
|
|
189
189
|
end
|
|
190
190
|
|
|
191
191
|
it 'prints the green final after Loop.run and does not pp the session after it' do
|
|
192
|
-
expect(hook).to
|
|
192
|
+
expect(hook).to include('PWN::Plugins::REPL.pwn_ai_run_steerable(')
|
|
193
|
+
wrapper = File.read(PWN::Plugins::REPL.method(:pwn_ai_run_steerable).source_location.first)
|
|
194
|
+
expect(wrapper).to include('control.with_reader { PWN::AI::Agent::Loop.run(')
|
|
193
195
|
expect(hook).to match(/\\e\[32m#\{final\}/)
|
|
194
196
|
expect(hook).not_to include('[0, 280]')
|
|
195
197
|
expect(hook).not_to include('[0, 700]')
|
|
196
|
-
native_start = hook.index('final = PWN::
|
|
198
|
+
native_start = hook.index('final = PWN::Plugins::REPL.pwn_ai_run_steerable')
|
|
197
199
|
native_stop = hook.index("request.replace('nil')", native_start)
|
|
198
200
|
native = hook[native_start..native_stop]
|
|
199
201
|
expect(native).not_to include('\001')
|
|
@@ -208,7 +210,7 @@ describe 'pwn-ai debug final answer placement' do
|
|
|
208
210
|
end
|
|
209
211
|
|
|
210
212
|
it 'does not fall through to regex-ReAct after a native Loop.run error' do
|
|
211
|
-
native_start = hook.index('final = PWN::
|
|
213
|
+
native_start = hook.index('final = PWN::Plugins::REPL.pwn_ai_run_steerable')
|
|
212
214
|
rescue_at = hook.index('rescue StandardError', native_start)
|
|
213
215
|
expect(rescue_at).not_to be_nil
|
|
214
216
|
chunk = hook[rescue_at, 900]
|
|
@@ -3,6 +3,162 @@
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
|
|
5
5
|
describe PWN::Plugins::REPL do # rubocop:disable Metrics/BlockLength
|
|
6
|
+
describe 'reasoning selection' do
|
|
7
|
+
include_context 'pwn tmp sandbox'
|
|
8
|
+
|
|
9
|
+
it 'persists system role before updating Env and preserves unrelated vault fields' do
|
|
10
|
+
previous_driver_opts = PWN::Env[:driver_opts]
|
|
11
|
+
path = File.join(@tmp, 'role.yaml')
|
|
12
|
+
key = Base64.strict_encode64('k' * 32)
|
|
13
|
+
iv = Base64.strict_encode64('i' * 16)
|
|
14
|
+
original = { ai: { active: 'openai', openai: { model: 'old', system_role_content: 'original', key: 'fixture' }, ollama: { model: 'keep' } }, unrelated: 'keep' }
|
|
15
|
+
PWN::Env[:ai] = Marshal.load(Marshal.dump(original[:ai]))
|
|
16
|
+
File.write(path, YAML.dump(original))
|
|
17
|
+
File.write("#{path}.decryptor", YAML.dump(key: key, iv: iv))
|
|
18
|
+
PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
|
|
19
|
+
PWN::Env[:driver_opts] = { pwn_env_path: path, pwn_dec_path: "#{path}.decryptor" }
|
|
20
|
+
expect(described_class.pwn_ai_apply_system_role(engine: 'openai', content: "new role\nsecond line")).to eq(:saved)
|
|
21
|
+
expect(PWN::Env.dig(:ai, :openai, :system_role_content)).to eq("new role\nsecond line")
|
|
22
|
+
expect(File.read(path)).not_to include('new role')
|
|
23
|
+
PWN::Plugins::Vault.decrypt(file: path, key: key, iv: iv)
|
|
24
|
+
original[:ai][:openai][:system_role_content] = "new role\nsecond line"
|
|
25
|
+
expect(YAML.load_file(path, symbolize_names: true)).to eq(original)
|
|
26
|
+
PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
|
|
27
|
+
expect(described_class.pwn_ai_apply_system_role(engine: 'openai', content: '')).to eq(:saved)
|
|
28
|
+
original[:ai][:openai][:system_role_content] = ''
|
|
29
|
+
expect(PWN::Plugins::Vault.dump(file: path, key: key, iv: iv)).to eq(original)
|
|
30
|
+
ensure
|
|
31
|
+
PWN::Env[:driver_opts] = previous_driver_opts
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'leaves ciphertext and Env untouched when role encryption fails' do
|
|
35
|
+
previous_driver_opts = PWN::Env[:driver_opts]
|
|
36
|
+
path = File.join(@tmp, 'role.yaml')
|
|
37
|
+
key = Base64.strict_encode64('k' * 32)
|
|
38
|
+
iv = Base64.strict_encode64('i' * 16)
|
|
39
|
+
PWN::Env[:ai] = { active: 'openai', openai: { model: 'old', system_role_content: 'original' } }
|
|
40
|
+
File.write(path, YAML.dump(ai: PWN::Env[:ai]))
|
|
41
|
+
File.write("#{path}.decryptor", YAML.dump(key: key, iv: iv))
|
|
42
|
+
PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
|
|
43
|
+
ciphertext = File.binread(path)
|
|
44
|
+
PWN::Env[:driver_opts] = { pwn_env_path: path, pwn_dec_path: "#{path}.decryptor" }
|
|
45
|
+
allow(PWN::Plugins::Vault).to receive(:encrypt).and_raise(IOError, 'fixture encryption failure')
|
|
46
|
+
expect { described_class.pwn_ai_apply_system_role(engine: 'openai', content: 'unsaved') }.to raise_error(/not saved/)
|
|
47
|
+
expect(PWN::Env.dig(:ai, :openai, :system_role_content)).to eq('original')
|
|
48
|
+
expect(File.binread(path)).to eq(ciphertext)
|
|
49
|
+
expect(Dir.glob(File.join(@tmp, '.pwn-ai-selection*'))).to be_empty
|
|
50
|
+
ensure
|
|
51
|
+
PWN::Env[:driver_opts] = previous_driver_opts
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'rejects role saves without a decryptor or after switching the active engine' do
|
|
55
|
+
previous_driver_opts = PWN::Env[:driver_opts]
|
|
56
|
+
PWN::Env[:driver_opts] = { pwn_env_path: File.join(@tmp, 'missing.yaml'), pwn_dec_path: File.join(@tmp, 'missing.decryptor') }
|
|
57
|
+
PWN::Env[:ai] = { active: 'openai', openai: { system_role_content: 'original' } }
|
|
58
|
+
expect { described_class.pwn_ai_apply_system_role(engine: 'openai', content: 'unsaved') }.to raise_error(/not saved/)
|
|
59
|
+
expect(PWN::Env.dig(:ai, :openai, :system_role_content)).to eq('original')
|
|
60
|
+
expect(described_class).not_to receive(:persist_ai_selection)
|
|
61
|
+
expect { described_class.pwn_ai_apply_system_role(engine: 'ollama', content: 'unsaved') }.to raise_error(/Active engine changed/)
|
|
62
|
+
ensure
|
|
63
|
+
PWN::Env[:driver_opts] = previous_driver_opts
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'forwards explicit none through the agent rather than silently using provider defaults' do
|
|
67
|
+
PWN::Env[:ai] = { active: 'openai', openai: { model: 'gpt-5.5', reasoning_effort: 'none' } }
|
|
68
|
+
expect(PWN::AI::OpenAI).to receive(:chat_with_tools).with(hash_including(reasoning_effort: 'none', think: false)).and_return(choices: [{ message: { role: 'assistant', content: 'fixture' } }])
|
|
69
|
+
PWN::AI::Agent::Loop.send(:invoke_provider_chat, engine: :openai, messages: [], tools: [])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'forwards OpenAI effort through the agent into Chat Completions and Responses payloads' do
|
|
73
|
+
PWN::Env[:ai] = { active: 'openai', openai: { model: 'gpt-5', reasoning_effort: 'high' } }
|
|
74
|
+
expect(PWN::AI::OpenAI).to receive(:open_ai_rest_call).with(hash_including(rest_call: 'chat/completions', http_body: hash_including(reasoning_effort: 'high'))).and_return({ choices: [{ message: { role: 'assistant', content: 'offline fixture' } }] }.to_json)
|
|
75
|
+
PWN::AI::Agent::Loop.send(:invoke_provider_chat, engine: :openai, messages: [{ role: 'user', content: 'fixture' }], tools: [])
|
|
76
|
+
PWN::Env[:ai][:openai][:model] = 'gpt-6-astra'
|
|
77
|
+
expect(PWN::AI::OpenAI).to receive(:open_ai_rest_call).with(hash_including(rest_call: 'responses', http_body: hash_including(reasoning: { effort: 'high', summary: 'auto' }))).and_return({ output: [{ type: 'message', content: [{ type: 'output_text', text: 'offline fixture' }] }] }.to_json)
|
|
78
|
+
PWN::AI::Agent::Loop.send(:invoke_provider_chat, engine: :openai, messages: [{ role: 'user', content: 'fixture' }], tools: [])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'forwards the Grok effort through the agent into the HTTP payload' do
|
|
82
|
+
PWN::Env[:ai] = { active: 'grok', grok: { model: 'grok-4.6', reasoning_effort: 'high' } }
|
|
83
|
+
expect(PWN::AI::Grok).to receive(:grok_rest_call).with(hash_including(http_body: hash_including(model: 'grok-4.6', reasoning_effort: 'high'))).and_return({ choices: [{ message: { role: 'assistant', content: 'offline fixture' } }] }.to_json)
|
|
84
|
+
PWN::AI::Agent::Loop.send(:invoke_provider_chat, engine: :grok, messages: [{ role: 'user', content: 'fixture' }], tools: [])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'uses Enter for the current effort and permits cancellation in the legacy prompt' do
|
|
88
|
+
input = StringIO.new("\nq\n")
|
|
89
|
+
allow(input).to receive(:tty?).and_return(true)
|
|
90
|
+
previous = $stdin
|
|
91
|
+
$stdin = input
|
|
92
|
+
selection = { engine: 'grok', model: 'grok-4.6', efforts: %w[low medium high], default: 'high' }
|
|
93
|
+
expect(described_class.pwn_ai_prompt_reasoning(selection: selection)).to eq('high')
|
|
94
|
+
expect(described_class.pwn_ai_prompt_reasoning(selection: selection)).to be_nil
|
|
95
|
+
ensure
|
|
96
|
+
$stdin = previous
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'does not offer effort controls for unsupported models' do
|
|
100
|
+
expect(described_class.pwn_ai_reasoning_selection(engine: 'grok', model: 'grok-4')[:efforts]).to eq([])
|
|
101
|
+
expect(described_class.pwn_ai_reasoning_selection(engine: 'openai', model: 'gpt-4o')[:efforts]).to eq([])
|
|
102
|
+
%w[anthropic gemini ollama openwebui].each do |engine|
|
|
103
|
+
expect(described_class.pwn_ai_reasoning_selection(engine: engine, model: 'fixture')[:efforts]).to eq([])
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'cancels without changing any configuration or persisting' do
|
|
108
|
+
PWN::Env[:ai] = { active: 'ollama', ollama: { model: 'old' }, grok: { model: 'old', reasoning_effort: 'high' } }
|
|
109
|
+
before = Marshal.dump(PWN::Env[:ai])
|
|
110
|
+
expect(described_class).not_to receive(:persist_ai_selection)
|
|
111
|
+
described_class.pwn_ai_run_model(args: %w[grok grok-4.6], prompt: ->(_selection) {})
|
|
112
|
+
expect(Marshal.dump(PWN::Env[:ai])).to eq(before)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'skips effort prompts and catalogs for engines without effort support' do
|
|
116
|
+
PWN::Env[:ai] = { active: 'ollama', ollama: { model: 'old', think: false } }
|
|
117
|
+
allow(described_class).to receive(:persist_ai_selection).and_return(false)
|
|
118
|
+
expect(PWN::AI::OpenAI).not_to receive(:get_models)
|
|
119
|
+
prompt = double('prompt')
|
|
120
|
+
expect(prompt).not_to receive(:call)
|
|
121
|
+
described_class.pwn_ai_run_model(args: %w[ollama fixture], prompt: prompt)
|
|
122
|
+
expect(PWN::Env[:ai][:ollama]).to eq(model: 'fixture', think: false)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'merges reasoning and model into an encrypted sandbox vault without replacing other values' do
|
|
126
|
+
require 'yaml'
|
|
127
|
+
previous_driver_opts = PWN::Env[:driver_opts]
|
|
128
|
+
PWN::Env[:driver_opts] = previous_driver_opts.to_h.dup
|
|
129
|
+
path = File.join(@tmp, 'selection.yaml')
|
|
130
|
+
key = Base64.strict_encode64('k' * 32)
|
|
131
|
+
iv = Base64.strict_encode64('i' * 16)
|
|
132
|
+
original = { ai: { active: 'ollama', openai: { model: 'old', key: 'fixture', reasoning_effort: 'low', oauth: { account_id: 'fixture' } }, ollama: { model: 'keep' } }, unrelated: 'keep' }
|
|
133
|
+
File.write(path, YAML.dump(original))
|
|
134
|
+
File.write("#{path}.decryptor", YAML.dump(key: key, iv: iv))
|
|
135
|
+
PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
|
|
136
|
+
PWN::Env[:driver_opts][:pwn_env_path] = path
|
|
137
|
+
PWN::Env[:driver_opts][:pwn_dec_path] = "#{path}.decryptor"
|
|
138
|
+
expect(described_class.persist_ai_selection(engine: 'openai', model: 'gpt-6-astra', reasoning_effort: 'high')).to be(true)
|
|
139
|
+
expect(File.read(path)).not_to include('reasoning_effort')
|
|
140
|
+
PWN::Plugins::Vault.decrypt(file: path, key: key, iv: iv)
|
|
141
|
+
original[:ai][:active] = 'openai'
|
|
142
|
+
original[:ai][:openai].merge!(model: 'gpt-6-astra', reasoning_effort: 'high')
|
|
143
|
+
expect(YAML.load_file(path, symbolize_names: true)).to eq(original)
|
|
144
|
+
ensure
|
|
145
|
+
PWN::Env[:driver_opts] = previous_driver_opts
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'offers catalog efforts before applying either model or effort' do
|
|
149
|
+
PWN::Env[:ai] = { active: 'ollama', ollama: { model: 'old' }, openai: { model: 'old', reasoning_effort: 'high' } }
|
|
150
|
+
allow(PWN::AI::OpenAI).to receive(:get_models).and_return(data: [{ id: 'gpt-6-astra', supported_reasoning_levels: [{ effort: 'low' }, { effort: 'high' }], default_reasoning_level: 'low' }])
|
|
151
|
+
allow(described_class).to receive(:persist_ai_selection).and_return(false)
|
|
152
|
+
described_class.pwn_ai_run_model(args: %w[openai gpt-6-astra], prompt: lambda { |selection|
|
|
153
|
+
expect(PWN::Env[:ai][:active]).to eq('ollama')
|
|
154
|
+
expect(selection).to include(efforts: %w[low high], default: 'high')
|
|
155
|
+
'low'
|
|
156
|
+
})
|
|
157
|
+
expect(PWN::Env[:ai]).to include(active: 'openai', openai: { model: 'gpt-6-astra', reasoning_effort: 'low' })
|
|
158
|
+
expect(described_class).to have_received(:persist_ai_selection).with(engine: 'openai', model: 'gpt-6-astra', reasoning_effort: 'low')
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
6
162
|
def command_src
|
|
7
163
|
[
|
|
8
164
|
described_class,
|
|
@@ -193,6 +349,16 @@ describe PWN::Plugins::REPL do # rubocop:disable Metrics/BlockLength
|
|
|
193
349
|
end
|
|
194
350
|
|
|
195
351
|
describe 'pwn-ai completion menus' do
|
|
352
|
+
it 'handles idle steering and empty steering locally without eval or a model call' do
|
|
353
|
+
pry = double('local pry')
|
|
354
|
+
expect(pry).not_to receive(:eval)
|
|
355
|
+
expect(PWN::AI::Agent::Loop).not_to receive(:run)
|
|
356
|
+
expect { expect(described_class.pwn_ai_dispatch_slash!(request: '/steer', pry: pry)).to be(true) }.to output(%r{Usage: /steer <instruction>}).to_stdout
|
|
357
|
+
expect { expect(described_class.pwn_ai_dispatch_slash!(request: '/steer stop', pry: pry)).to be(true) }.to output(/no active request/).to_stdout
|
|
358
|
+
expect(described_class.pwn_ai_complete(target: '/st', line: '/st')).to include('/steer')
|
|
359
|
+
expect { described_class.pwn_ai_dispatch_slash!(request: '/help', pry: pry) }.to output(%r{/steer <instruction>}).to_stdout
|
|
360
|
+
end
|
|
361
|
+
|
|
196
362
|
it 'classifies leading slash as command, other slash as path, else ruby' do
|
|
197
363
|
expect(described_class).to respond_to(:pwn_ai_complete_kind)
|
|
198
364
|
expect(described_class.pwn_ai_complete_kind(line: '/cron')).to eq(:command)
|
|
@@ -207,7 +373,7 @@ describe PWN::Plugins::REPL do # rubocop:disable Metrics/BlockLength
|
|
|
207
373
|
hits = described_class.pwn_ai_complete(target: '/sk', line: '/sk')
|
|
208
374
|
expect(hits).to include('/skills')
|
|
209
375
|
hits = described_class.pwn_ai_complete(target: '/', line: '/')
|
|
210
|
-
%w[/cron /skills /sessions /memory /debug /trace /back /help /model /learning /mcp].each do |cmd|
|
|
376
|
+
%w[/cron /skills /sessions /memory /debug /trace /back /help /model /learning /mcp /system-role].each do |cmd|
|
|
211
377
|
expect(hits).to include(cmd)
|
|
212
378
|
end
|
|
213
379
|
hits = described_class.pwn_ai_complete(target: 'li', line: '/cron li')
|