pwn 0.5.783 → 0.5.784
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 +3 -3
- data/Rakefile +19 -1
- data/bin/pwn_setup +1 -1
- data/build_gem.sh +1 -1
- data/documentation/Backup-Retention.md +27 -0
- data/documentation/Cron.md +64 -35
- data/documentation/How-PWN-Works.md +1 -1
- data/documentation/Installation.md +247 -6
- data/documentation/Persistence.md +1 -1
- data/documentation/Reinforcement-Learning.md +5 -6
- data/documentation/diagrams/cron-scheduling.svg +130 -128
- data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
- data/etc/default_cron/session_cleanup.rb +1217 -10
- data/lib/pwn/ai/agent/extrospection.rb +6 -7
- data/lib/pwn/ai/agent/learning.rb +48 -7
- data/lib/pwn/ai/agent/policy.rb +5 -0
- data/lib/pwn/ai/agent/swarm.rb +31 -16
- data/lib/pwn/ai/router.rb +45 -2
- data/lib/pwn/cron.rb +120 -49
- data/lib/pwn/migrate.rb +292 -15
- data/lib/pwn/plugins/binary_parser.rb +5 -4
- data/lib/pwn/plugins/ghidra.rb +4 -3
- data/lib/pwn/plugins/log.rb +18 -2
- data/lib/pwn/plugins/repl/ai.rb +12 -1
- data/lib/pwn/plugins/repl/ai_console.rb +6 -2
- data/lib/pwn/plugins/repl/ai_swarm.rb +4 -4
- data/lib/pwn/sessions.rb +58 -16
- data/lib/pwn/setup.rb +9 -17
- data/lib/pwn/version.rb +1 -1
- data/scripts/build_gate.rb +58 -0
- data/spec/integration/build_gate_spec.rb +73 -0
- data/spec/integration/persistence_roundtrip_spec.rb +1 -7
- data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
- data/spec/lib/pwn/ai/router_spec.rb +30 -2
- data/spec/lib/pwn/cron_spec.rb +113 -6
- data/spec/lib/pwn/migrate_spec.rb +160 -5
- data/spec/lib/pwn/plugins/log_spec.rb +7 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +16 -4
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +11 -0
- data/spec/lib/pwn/redaction_spec.rb +13 -10
- data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +165 -2
- data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +389 -0
- data/spec/lib/pwn/sessions_spec.rb +112 -0
- data/spec/lib/pwn/setup_spec.rb +3 -1
- data/spec/support/gate_failure_formatter.rb +14 -0
- data/third_party/pwn_rdoc.jsonl +15 -1
- metadata +14 -7
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require File.expand_path('../../../etc/default_cron/session_cleanup', __dir__)
|
|
5
|
+
|
|
6
|
+
RSpec.describe PWN::SessionCleanup do
|
|
7
|
+
include_context 'pwn tmp sandbox'
|
|
8
|
+
|
|
9
|
+
let(:id) { '20200101_010203_abcdef12' }
|
|
10
|
+
let(:title) { "pwn-ai session #{id}" }
|
|
11
|
+
let(:path) { File.join(PWN::Sessions.sessions_dir, "#{id}.jsonl") }
|
|
12
|
+
let(:meta_path) { path.sub('.jsonl', '.meta.json') }
|
|
13
|
+
|
|
14
|
+
before do
|
|
15
|
+
allow(Dir).to receive(:home).and_return(File.join(@tmp, 'home'))
|
|
16
|
+
FileUtils.mkdir_p(File.join(Dir.home, '.pwn'))
|
|
17
|
+
stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(Dir.home, '.pwn', 'learning.jsonl'))
|
|
18
|
+
PWN::Sessions.create(id: id)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def metadata(value)
|
|
22
|
+
File.write(meta_path, JSON.generate({ id: id, title: title, source: 'pwn-ai' }.merge(value)))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
describe 'bounded housekeeping recovery' do
|
|
26
|
+
it 'restores legacy session bytes explicitly without overwriting newer sessions' do
|
|
27
|
+
allow(described_class).to receive(:legacy_owners).and_return([])
|
|
28
|
+
original = File.binread(path)
|
|
29
|
+
result = described_class.sweep(dry_run: false, proc_root: File.join(@tmp, 'no-proc'))
|
|
30
|
+
expect(result[:deleted_ids]).to include(id)
|
|
31
|
+
ledger = File.join(result[:archive_dir], 'ledger.json')
|
|
32
|
+
expect(described_class.recovery_status(ledger: ledger)[:actions]).to include('restore')
|
|
33
|
+
expect(described_class.recover(ledger: ledger, dry_run: false)[:restored]).to include(path)
|
|
34
|
+
expect(File.binread(path)).to eq(original)
|
|
35
|
+
File.write(path, 'newer bytes')
|
|
36
|
+
expect { described_class.recover(ledger: ledger, dry_run: false) }.to raise_error(IOError, /existing/)
|
|
37
|
+
expect(File.read(path)).to eq('newer bytes')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'rolls back a continuation retirement only while the post-retirement map is unchanged' do
|
|
41
|
+
root = File.join(Dir.home, '.pwn', 'swarm')
|
|
42
|
+
swarm = '20200101_010203_abcdef'
|
|
43
|
+
map = File.join(root, swarm, 'personas.json')
|
|
44
|
+
FileUtils.mkdir_p(File.dirname(map))
|
|
45
|
+
File.write(map, JSON.generate('analyst' => id))
|
|
46
|
+
result = described_class.retire_continuation(root: root, swarm_id: swarm, name: 'analyst')
|
|
47
|
+
ledger = File.join(File.dirname(result[:backup]), 'ledger.json')
|
|
48
|
+
expect(described_class.recover(ledger: ledger, action: 'rollback', dry_run: false)[:restored]).to eq([map])
|
|
49
|
+
expect(JSON.parse(File.read(map))).to eq('analyst' => id)
|
|
50
|
+
File.write(map, JSON.generate('changed' => id))
|
|
51
|
+
expect { described_class.recover(ledger: ledger, action: 'rollback', dry_run: false) }.to raise_error(IOError, /changed/)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'rolls back targeted dependency removal only after restoring source sessions and validating current bytes' do
|
|
55
|
+
store = File.join(Dir.home, '.pwn', 'mistakes.json')
|
|
56
|
+
original = JSON.generate(bad: { sessions: [id], verified_sessions: {} })
|
|
57
|
+
File.write(store, original)
|
|
58
|
+
allow(described_class).to receive(:legacy_owners).and_return([])
|
|
59
|
+
result = described_class.sweep(dry_run: false)
|
|
60
|
+
ledger = File.join(result[:archive_dir], 'ledger.json')
|
|
61
|
+
expect { described_class.recover(ledger: ledger, action: 'rollback_dependencies', dry_run: false) }.to raise_error(IOError, /source/)
|
|
62
|
+
described_class.recover(ledger: ledger, dry_run: false)
|
|
63
|
+
expect(described_class.recover(ledger: ledger, action: 'rollback_dependencies', dry_run: false)[:restored]).to eq([store])
|
|
64
|
+
expect(File.binread(store)).to eq(original.b)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'refuses a dependency store exceeding the read admission budget' do
|
|
68
|
+
described_class.install
|
|
69
|
+
config = described_class.policy.merge('work_bytes' => 1)
|
|
70
|
+
File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
|
|
71
|
+
File.write(PWN::AI::Agent::Learning::LEARNING_FILE, JSON.generate(session_id: id, outcome: 'fixture'))
|
|
72
|
+
report = described_class.maintain(dry_run: false, proc_root: File.join(@tmp, 'no-proc'))
|
|
73
|
+
expect(report).to include(state: 'interrupted', incomplete: true)
|
|
74
|
+
expect(report[:dependency_errors]).to include('incomplete_dependency_index')
|
|
75
|
+
expect(File.file?(path)).to be(true)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'stops between phases on the time budget without interrupting an admitted write' do
|
|
79
|
+
described_class.install
|
|
80
|
+
now = 100.0
|
|
81
|
+
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC).and_return(now)
|
|
82
|
+
allow(described_class).to receive(:maintenance_transaction).and_wrap_original do |method, **options|
|
|
83
|
+
result = method.call(**options)
|
|
84
|
+
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC).and_return(now + 60)
|
|
85
|
+
result
|
|
86
|
+
end
|
|
87
|
+
allow(described_class).to receive(:legacy_owners).and_return([])
|
|
88
|
+
report = described_class.maintain(dry_run: false)
|
|
89
|
+
expect(report).to include(state: 'interrupted', incomplete: true)
|
|
90
|
+
expect(JSON.parse(File.read(report[:transaction_ledger]))['state']).to eq('interrupted')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'indexes only committed legacy backup rows and compacts duplicates without unlinking the last copy' do
|
|
94
|
+
described_class.install
|
|
95
|
+
allow(described_class).to receive(:legacy_owners).and_return([])
|
|
96
|
+
original = File.binread(path)
|
|
97
|
+
results = 2.times.map do
|
|
98
|
+
File.binwrite(path, original)
|
|
99
|
+
described_class.sweep(dry_run: false, proc_root: File.join(@tmp, 'no-proc'))
|
|
100
|
+
end
|
|
101
|
+
backups = results.map { |row| File.join(row[:archive_dir], "#{id}.jsonl") }
|
|
102
|
+
PWN::Sessions.create(id: id)
|
|
103
|
+
PWN::Sessions.append(session_id: id, role: 'user', content: 'evidence')
|
|
104
|
+
File.write(PWN::AI::Agent::Learning::LEARNING_FILE, JSON.generate(session_id: id, outcome: 'fixture'))
|
|
105
|
+
migration = PWN::AI::Agent::Learning.migrate_evidence(dry_run: false)
|
|
106
|
+
learning_backup = migration[:ledger].sub('.json', '.backup.jsonl')
|
|
107
|
+
inventory = described_class.legacy_backups.map { |row| row[:path] }
|
|
108
|
+
expect(inventory).to include(*backups, learning_backup)
|
|
109
|
+
config = described_class.policy
|
|
110
|
+
config['retention_days']['maintenance'] = 0
|
|
111
|
+
File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
|
|
112
|
+
result = described_class.prune_legacy_backups(dry_run: false)
|
|
113
|
+
expect(result[:compacted] & backups).not_to be_empty
|
|
114
|
+
expect(File.identical?(*backups)).to be(true)
|
|
115
|
+
expect(File.file?(learning_backup)).to be(true)
|
|
116
|
+
record = JSON.parse(File.read(migration[:ledger])).merge('state' => 'prepared')
|
|
117
|
+
File.write(migration[:ledger], JSON.generate(record))
|
|
118
|
+
expect(described_class.legacy_backups.map { |row| row[:path] }).not_to include(learning_backup)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'upgrades the inventory v1 without changing operator policy or unknown layouts' do
|
|
122
|
+
described_class.install
|
|
123
|
+
directory = File.join(Dir.home, '.pwn', 'maintenance')
|
|
124
|
+
config = File.binread(File.join(directory, 'policy.json'))
|
|
125
|
+
layout = File.join(directory, 'layout.json')
|
|
126
|
+
File.write(layout, JSON.generate(version: 1, categories: { learning: ['learning_evidence'] }))
|
|
127
|
+
described_class.install
|
|
128
|
+
expect(JSON.parse(File.read(layout))).to include('version' => 2, 'categories' => described_class::STORAGE)
|
|
129
|
+
expect(File.binread(File.join(directory, 'policy.json'))).to eq(config)
|
|
130
|
+
File.write(layout, JSON.generate(version: 99, categories: {}))
|
|
131
|
+
expect { described_class.install }.to raise_error(IOError, /inventory/)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'halts admission on a partial dependency index without deleting unique or active evidence' do
|
|
135
|
+
described_class.install
|
|
136
|
+
config = described_class.policy.merge('work_items' => 1, 'work_seconds' => 60)
|
|
137
|
+
File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
|
|
138
|
+
bytes = File.binread(path)
|
|
139
|
+
lease = PWN::Sessions.activate(session_id: id)
|
|
140
|
+
report = described_class.maintain(dry_run: false, proc_root: File.join(@tmp, 'no-proc'))
|
|
141
|
+
expect(report[:state]).to eq('interrupted')
|
|
142
|
+
expect(report[:incomplete]).to be(true)
|
|
143
|
+
expect(File.binread(path)).to eq(bytes)
|
|
144
|
+
expect(Thread.current[:pwn_maintenance_admission]).to be_nil
|
|
145
|
+
ensure
|
|
146
|
+
lease&.close
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'prunes redundant physical continuation backups while preserving rollback paths and unique evidence' do
|
|
150
|
+
described_class.install
|
|
151
|
+
root = File.join(Dir.home, '.pwn', 'swarm')
|
|
152
|
+
swarm = '20200101_010203_abcdef'
|
|
153
|
+
map = File.join(root, swarm, 'personas.json')
|
|
154
|
+
FileUtils.mkdir_p(File.dirname(map))
|
|
155
|
+
backups = 2.times.map do
|
|
156
|
+
File.write(map, JSON.generate('analyst' => id))
|
|
157
|
+
described_class.retire_continuation(root: root, swarm_id: swarm, name: 'analyst').fetch(:backup)
|
|
158
|
+
end
|
|
159
|
+
config = described_class.policy
|
|
160
|
+
config['retention_days']['maintenance'] = 0
|
|
161
|
+
File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
|
|
162
|
+
original = File.binread(backups.first)
|
|
163
|
+
config['retention_days']['maintenance'] = 90
|
|
164
|
+
config['budget_bytes']['maintenance'] = 0
|
|
165
|
+
File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
|
|
166
|
+
expect(File.identical?(*backups)).to be(false)
|
|
167
|
+
report = described_class.prune_legacy_backups(dry_run: false)
|
|
168
|
+
expect(report[:compacted].length).to eq(1)
|
|
169
|
+
expect(File.identical?(*backups)).to be(true)
|
|
170
|
+
expect(backups.map { |file| File.binread(file) }).to eq([original, original])
|
|
171
|
+
expect(described_class.prune_legacy_backups(dry_run: false)[:compacted]).to be_empty
|
|
172
|
+
expect(described_class.recovery_status(ledger: report[:ledger])[:state]).to eq('committed')
|
|
173
|
+
# Model interruption after the atomic replacement but before commit readback.
|
|
174
|
+
interrupted = JSON.parse(File.read(report[:ledger]))
|
|
175
|
+
interrupted['state'] = 'prepared'
|
|
176
|
+
interrupted['records'].each { |row| row['state'] = 'prepared' }
|
|
177
|
+
File.write(report[:ledger], JSON.generate(interrupted))
|
|
178
|
+
expect(described_class.recover(ledger: report[:ledger], action: 'resume', dry_run: false)[:state]).to eq('committed')
|
|
179
|
+
expect(backups.map { |file| File.binread(file) }).to eq([original, original])
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it 'owns actual festival input through use and removes it without a global tmp sweep' do
|
|
183
|
+
observed = nil
|
|
184
|
+
allow(PWN::Plugins::Voice).to receive(:text_to_speech) do |opts|
|
|
185
|
+
observed = opts[:text_path]
|
|
186
|
+
expect(observed).to start_with("#{File.join(Dir.home, '.pwn', 'tmp')}/")
|
|
187
|
+
expect(File.read(observed)).to eq('fixture speech')
|
|
188
|
+
expect(described_class.clean_disposables(dry_run: false)[:deleted]).to be_empty
|
|
189
|
+
end
|
|
190
|
+
result = PWN::AI::Agent::Extrospection.send(:voice_tts, text: 'fixture speech', engine: :festival, out: File.join(@tmp, 'speech.wav'))
|
|
191
|
+
expect(result.first).to be(true)
|
|
192
|
+
expect(File.exist?(observed)).to be(false)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it 'recovers a killed atomic cache producer using its ownership journal, never its filename' do
|
|
196
|
+
described_class.install
|
|
197
|
+
cache = File.join(Dir.home, '.pwn', 'cache', 'killed')
|
|
198
|
+
described_class.cache(path: cache, bytes: 'old')
|
|
199
|
+
child = fork do
|
|
200
|
+
allow(File).to receive(:rename).and_call_original
|
|
201
|
+
allow(File).to receive(:rename).with(anything, cache) { exit! 0 }
|
|
202
|
+
described_class.cache(path: cache, bytes: 'new')
|
|
203
|
+
exit! 1
|
|
204
|
+
end
|
|
205
|
+
_, child_status = Process.wait2(child)
|
|
206
|
+
expect(child_status.exitstatus).to eq(0)
|
|
207
|
+
unknown = "#{cache}.unknown.tmp"
|
|
208
|
+
File.write(unknown, 'unknown')
|
|
209
|
+
atomic = Dir.glob(File.join(Dir.home, '.pwn', 'maintenance', 'atomic', '*.json')).fetch(0)
|
|
210
|
+
owned = JSON.parse(File.read(atomic)).fetch('path')
|
|
211
|
+
File.write(owned, 'changed')
|
|
212
|
+
expect(described_class.recover_atomic_temps(dry_run: false)[:deleted]).to be_empty
|
|
213
|
+
File.write(owned, 'new')
|
|
214
|
+
report = described_class.recover_atomic_temps(dry_run: false)
|
|
215
|
+
expect(report[:deleted].length).to eq(1)
|
|
216
|
+
expect(File.read(cache)).to eq('old')
|
|
217
|
+
expect(File.read(unknown)).to eq('unknown')
|
|
218
|
+
expect(described_class.recover_atomic_temps(dry_run: false)[:deleted]).to be_empty
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
it 'resumes expiry_prepared after an interruption between backup unlinks' do
|
|
222
|
+
described_class.install
|
|
223
|
+
config = described_class.policy
|
|
224
|
+
config['retention_days']['maintenance'] = 0
|
|
225
|
+
File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
|
|
226
|
+
2.times do |index|
|
|
227
|
+
file = File.join(Dir.home, '.pwn', 'tmp', "expiry-#{index}")
|
|
228
|
+
File.write(file, "bytes-#{index}")
|
|
229
|
+
described_class.register_disposable(path: file, category: 'tmp', expires_at: 0)
|
|
230
|
+
end
|
|
231
|
+
result = described_class.clean_disposables(dry_run: false)
|
|
232
|
+
backups = result[:records].map { |row| row['backup'] }
|
|
233
|
+
allow(File).to receive(:unlink).and_call_original
|
|
234
|
+
allow(File).to receive(:unlink).with(backups.last).and_raise(Interrupt)
|
|
235
|
+
expect { described_class.prune_quarantine(dry_run: false) }.to raise_error(Interrupt)
|
|
236
|
+
expect(JSON.parse(File.read(result[:ledger]))['state']).to eq('expiry_prepared')
|
|
237
|
+
expect(File.exist?(backups.first)).to be(false)
|
|
238
|
+
allow(File).to receive(:unlink).with(backups.last).and_call_original
|
|
239
|
+
expect(described_class.prune_quarantine(dry_run: false)[:deleted]).to eq([backups.last])
|
|
240
|
+
expect(JSON.parse(File.read(result[:ledger]))['state']).to eq('expired')
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it 'explicitly delegates learning recovery to validated store rollback without pretending broad rollback' do
|
|
244
|
+
PWN::Sessions.append(session_id: id, role: 'user', content: '証拠')
|
|
245
|
+
file = PWN::AI::Agent::Learning::LEARNING_FILE
|
|
246
|
+
original = JSON.generate(session_id: id, outcome: 'fixture')
|
|
247
|
+
File.write(file, original)
|
|
248
|
+
migration = PWN::AI::Agent::Learning.migrate_evidence(dry_run: false)
|
|
249
|
+
expect(described_class.recovery_status(ledger: migration[:ledger])[:actions]).to include('rollback')
|
|
250
|
+
expect(described_class.recover(ledger: migration[:ledger], action: 'rollback', dry_run: true)[:dry_run]).to be(true)
|
|
251
|
+
expect(File.read(file)).not_to eq(original)
|
|
252
|
+
expect(described_class.recover(ledger: migration[:ledger], action: 'rollback', dry_run: false)[:restored]).to be(true)
|
|
253
|
+
expect(File.binread(file)).to eq(original.b)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
RSpec.describe PWN::SessionCleanup, 'disposable admission and scan progress' do
|
|
259
|
+
include_context 'pwn tmp sandbox'
|
|
260
|
+
|
|
261
|
+
before do
|
|
262
|
+
allow(Dir).to receive(:home).and_return(File.join(@tmp, 'home'))
|
|
263
|
+
FileUtils.mkdir_p(File.join(Dir.home, '.pwn'))
|
|
264
|
+
stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(Dir.home, '.pwn', 'learning.jsonl'))
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
it 'admits disposable payload IO before hashing or backing up through maintain' do
|
|
268
|
+
described_class.install
|
|
269
|
+
allow(described_class).to receive(:legacy_owners).and_return([])
|
|
270
|
+
cache = File.join(Dir.home, '.pwn', 'cache', 'large')
|
|
271
|
+
described_class.cache(path: cache, bytes: 'x' * 1_048_576)
|
|
272
|
+
described_class.register_disposable(path: cache, category: 'cache', expires_at: 0)
|
|
273
|
+
allow(Digest::SHA256).to receive(:file).and_call_original
|
|
274
|
+
allow(File).to receive(:binread).and_call_original
|
|
275
|
+
expect(Digest::SHA256).not_to receive(:file).with(cache)
|
|
276
|
+
expect(File).not_to receive(:binread).with(cache, any_args)
|
|
277
|
+
config = described_class.policy.merge('work_bytes' => 1024)
|
|
278
|
+
File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
|
|
279
|
+
report = described_class.maintain(dry_run: false, proc_root: File.join(@tmp, 'no-proc'))
|
|
280
|
+
expect(report).to include(state: 'interrupted', incomplete: true)
|
|
281
|
+
expect(File.size(cache)).to eq(1_048_576)
|
|
282
|
+
expect(report[:storage][:deleted]).to be_empty
|
|
283
|
+
expect(Dir.glob(File.join(Dir.home, '.pwn', 'maintenance', 'quarantine', '*', '*.bin'))).to be_empty
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
it 'advances bounded scans past completed and normally removed temporary manifests' do
|
|
287
|
+
described_class.install
|
|
288
|
+
allow(described_class).to receive(:legacy_owners).and_return([])
|
|
289
|
+
described_class.temporary(bytes: 'normal producer') { |_file| nil }
|
|
290
|
+
files = 2.times.map do |index|
|
|
291
|
+
file = File.join(Dir.home, '.pwn', 'cache', "expire-#{index}")
|
|
292
|
+
described_class.cache(path: file, bytes: "cache-#{index}")
|
|
293
|
+
described_class.register_disposable(path: file, category: 'cache', expires_at: 0)
|
|
294
|
+
file
|
|
295
|
+
end
|
|
296
|
+
config = described_class.policy.merge('scan_limit' => 1)
|
|
297
|
+
File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
|
|
298
|
+
reports = 6.times.map { described_class.maintain(dry_run: false, proc_root: File.join(@tmp, 'no-proc')) }
|
|
299
|
+
expect(reports.flat_map { |report| report[:storage][:deleted] }).to contain_exactly(*files)
|
|
300
|
+
expect(files.none? { |file| File.exist?(file) }).to be(true)
|
|
301
|
+
expect(Dir.glob(File.join(Dir.home, '.pwn', 'maintenance', 'disposable', '*.json')).length).to eq(3)
|
|
302
|
+
expect(reports.all? { |report| report[:storage][:deleted].length <= 1 }).to be(true)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
it 'does not hash or expire oversized quarantine backups through maintain' do
|
|
306
|
+
described_class.install
|
|
307
|
+
allow(described_class).to receive(:legacy_owners).and_return([])
|
|
308
|
+
cache = File.join(Dir.home, '.pwn', 'cache', 'quarantined')
|
|
309
|
+
described_class.cache(path: cache, bytes: 'q' * 1_048_576)
|
|
310
|
+
described_class.register_disposable(path: cache, category: 'cache', expires_at: 0)
|
|
311
|
+
removed = described_class.clean_disposables(dry_run: false)
|
|
312
|
+
backup = removed[:records].first.fetch('backup')
|
|
313
|
+
config = described_class.policy.merge('work_bytes' => 8192)
|
|
314
|
+
config['retention_days']['maintenance'] = 0
|
|
315
|
+
File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
|
|
316
|
+
report = described_class.maintain(dry_run: false, proc_root: File.join(@tmp, 'no-proc'))
|
|
317
|
+
expect(report).to include(state: 'interrupted', incomplete: true)
|
|
318
|
+
expect(File.size(backup)).to eq(1_048_576)
|
|
319
|
+
expect(JSON.parse(File.read(removed[:ledger]))['state']).to eq('committed')
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
it 'revisits active and mutated entries without losing a later re-registration' do
|
|
323
|
+
described_class.install
|
|
324
|
+
allow(described_class).to receive(:legacy_owners).and_return([])
|
|
325
|
+
files = %w[active mutated ready].map do |name|
|
|
326
|
+
file = File.join(Dir.home, '.pwn', 'cache', name)
|
|
327
|
+
described_class.cache(path: file, bytes: name)
|
|
328
|
+
described_class.register_disposable(path: file, category: 'cache', expires_at: 0)
|
|
329
|
+
file
|
|
330
|
+
end
|
|
331
|
+
File.write(files[1], 'mutated payload')
|
|
332
|
+
lease = File.open("#{files[0]}.lease", File::RDWR) # rubocop:disable Style/FileOpen -- lifetime spans repeated maintenance calls
|
|
333
|
+
lease.flock(File::LOCK_SH)
|
|
334
|
+
config = described_class.policy.merge('scan_limit' => 1)
|
|
335
|
+
File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
|
|
336
|
+
reports = 3.times.map { described_class.maintain(dry_run: false, proc_root: File.join(@tmp, 'no-proc')) }
|
|
337
|
+
expect(reports.flat_map { |report| report[:storage][:deleted] }).to eq([files[2]])
|
|
338
|
+
expect(File.read(files[0])).to eq('active')
|
|
339
|
+
expect(File.read(files[1])).to eq('mutated payload')
|
|
340
|
+
lease.close
|
|
341
|
+
described_class.cache(path: files[2], bytes: 'new registration')
|
|
342
|
+
3.times { described_class.maintain(dry_run: false, proc_root: File.join(@tmp, 'no-proc')) }
|
|
343
|
+
expect(File.exist?(files[0])).to be(false)
|
|
344
|
+
expect(File.read(files[1])).to eq('mutated payload')
|
|
345
|
+
expect(File.read(files[2])).to eq('new registration')
|
|
346
|
+
ensure
|
|
347
|
+
lease&.close unless lease&.closed?
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
it 'finishes an admitted disposable commit when the deadline expires during backup' do
|
|
351
|
+
described_class.install
|
|
352
|
+
allow(described_class).to receive(:legacy_owners).and_return([])
|
|
353
|
+
cache = File.join(Dir.home, '.pwn', 'cache', 'admitted')
|
|
354
|
+
described_class.cache(path: cache, bytes: 'exact recoverable payload')
|
|
355
|
+
described_class.register_disposable(path: cache, category: 'cache', expires_at: 0)
|
|
356
|
+
allow(described_class).to receive(:publish).and_wrap_original do |method, target, bytes|
|
|
357
|
+
result = method.call(target, bytes)
|
|
358
|
+
Thread.current[:pwn_maintenance_budget][:deadline] = 0 if target.end_with?('.bin')
|
|
359
|
+
result
|
|
360
|
+
end
|
|
361
|
+
report = described_class.maintain(dry_run: false, proc_root: File.join(@tmp, 'no-proc'))
|
|
362
|
+
expect(report).to include(state: 'interrupted', incomplete: true)
|
|
363
|
+
expect(report[:storage][:deleted]).to eq([cache])
|
|
364
|
+
record = report[:storage][:records].first
|
|
365
|
+
expect(record['state']).to eq('committed')
|
|
366
|
+
expect(File.binread(record['backup'])).to eq('exact recoverable payload')
|
|
367
|
+
expect(JSON.parse(File.read(report[:storage][:ledger]))['records'].first['state']).to eq('committed')
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
it 'retains a manifest re-registered after discovery but before its lease' do
|
|
371
|
+
described_class.install
|
|
372
|
+
allow(described_class).to receive(:legacy_owners).and_return([])
|
|
373
|
+
cache = File.join(Dir.home, '.pwn', 'cache', 'renewed')
|
|
374
|
+
described_class.cache(path: cache, bytes: 'same payload')
|
|
375
|
+
described_class.register_disposable(path: cache, category: 'cache', expires_at: 0)
|
|
376
|
+
renewed = false
|
|
377
|
+
allow(described_class).to receive(:disposable_reason).and_wrap_original do |method, row, **options|
|
|
378
|
+
reason = method.call(row, **options)
|
|
379
|
+
if !options[:verify] && !renewed
|
|
380
|
+
renewed = true
|
|
381
|
+
described_class.register_disposable(path: cache, category: 'cache', expires_at: Time.now.to_i + 3600)
|
|
382
|
+
end
|
|
383
|
+
reason
|
|
384
|
+
end
|
|
385
|
+
report = described_class.maintain(dry_run: false, proc_root: File.join(@tmp, 'no-proc'))
|
|
386
|
+
expect(report[:storage][:deleted]).to be_empty
|
|
387
|
+
expect(File.read(cache)).to eq('same payload')
|
|
388
|
+
end
|
|
389
|
+
end
|
|
@@ -7,6 +7,19 @@ require 'fileutils'
|
|
|
7
7
|
describe PWN::Sessions do
|
|
8
8
|
include_context 'pwn tmp sandbox'
|
|
9
9
|
|
|
10
|
+
it 'probes absent and compact-layout leases without filesystem writes' do
|
|
11
|
+
sid = '20261025_120000_12345678'
|
|
12
|
+
root = File.join(@tmp, 'absent-sessions')
|
|
13
|
+
stub_const('PWN::Sessions::SESSIONS_DIR', root)
|
|
14
|
+
expect(described_class.active?(session_id: sid)).to be(false)
|
|
15
|
+
expect(File.exist?(root)).to be(false)
|
|
16
|
+
FileUtils.mkdir_p(root)
|
|
17
|
+
File.write(File.join(root, '.layout-v2'), '2')
|
|
18
|
+
before = Dir.glob("#{root}/**/*", File::FNM_DOTMATCH).sort
|
|
19
|
+
expect(described_class.active?(session_id: sid)).to be(false)
|
|
20
|
+
expect(Dir.glob("#{root}/**/*", File::FNM_DOTMATCH).sort).to eq(before)
|
|
21
|
+
end
|
|
22
|
+
|
|
10
23
|
it 'reconciles page size, filtered inventory and physical support entries' do
|
|
11
24
|
allow(Dir).to receive(:home).and_return(@tmp)
|
|
12
25
|
3.times { described_class.create }
|
|
@@ -104,6 +117,105 @@ describe PWN::Sessions do
|
|
|
104
117
|
ensure
|
|
105
118
|
Thread.current[:pwn_request_runtime] = previous
|
|
106
119
|
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
describe PWN::Sessions, 'title refresh and persistence' do
|
|
123
|
+
include_context 'pwn tmp sandbox'
|
|
124
|
+
|
|
125
|
+
it 'summarizes Anthropic text blocks through the real adapter without stringifying Ruby containers' do
|
|
126
|
+
allow(Dir).to receive(:home).and_return(@tmp)
|
|
127
|
+
sid = described_class.create[:id]
|
|
128
|
+
described_class.append(session_id: sid, role: 'user', content: 'Repair login certificate')
|
|
129
|
+
config = { active: :anthropic, anthropic: { model: 'fixture-model', key: 'fixture-key' } }
|
|
130
|
+
allow(PWN::AI::Router).to receive(:summarize).and_call_original
|
|
131
|
+
allow(PWN::AI::Anthropic).to receive(:anthropic_rest_call).and_return(JSON.generate(
|
|
132
|
+
content: [{ type: 'thinking', thinking: 'private fixture reasoning' },
|
|
133
|
+
{ type: 'text', text: JSON.generate(title: 'Certificate recovery', summary: 'Repaired login certificates.') }]
|
|
134
|
+
))
|
|
135
|
+
failures = []
|
|
136
|
+
result = described_class.refresh_title(session_id: sid, ai_config: config, on_error: failures.method(:push))
|
|
137
|
+
expect(failures).to be_empty
|
|
138
|
+
expect(result).to include(title: 'Certificate recovery', title_source: 'summary', summary_turns: 1)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it 'reports the original provider failure class and preserves exact metadata bytes' do
|
|
142
|
+
allow(Dir).to receive(:home).and_return(@tmp)
|
|
143
|
+
sid = described_class.create[:id]
|
|
144
|
+
described_class.append(session_id: sid, role: 'user', content: 'Fixture request')
|
|
145
|
+
config = { active: :openai, openai: { model: 'fixture', key: 'fixture-key' } }
|
|
146
|
+
path = described_class.support_path(kind: :metadata, session_id: sid)
|
|
147
|
+
before = File.binread(path)
|
|
148
|
+
allow(PWN::AI::Router).to receive(:summarize).and_call_original
|
|
149
|
+
allow(PWN::AI::OpenAI).to receive(:chat).and_raise(Errno::EACCES, 'private fixture path')
|
|
150
|
+
failures = []
|
|
151
|
+
described_class.refresh_title(session_id: sid, ai_config: config, on_error: failures.method(:push))
|
|
152
|
+
expect(failures).to eq(['Errno::EACCES'])
|
|
153
|
+
expect(File.binread(path)).to eq(before)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'accepts whitespace around a complete fenced JSON title response' do
|
|
157
|
+
sid = described_class.create[:id]
|
|
158
|
+
described_class.append(session_id: sid, role: 'user', content: 'Fixture request')
|
|
159
|
+
allow(PWN::AI::Router).to receive(:summarize).and_return(" \n```json\n#{JSON.generate(title: 'Certificate recovery', summary: 'Repaired certificates.')}\n```\n ")
|
|
160
|
+
failures = []
|
|
161
|
+
result = described_class.refresh_title(session_id: sid, on_error: failures.method(:push))
|
|
162
|
+
expect(failures).to be_empty
|
|
163
|
+
expect(result[:title_source]).to eq('summary')
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it 'keeps the loaded session store independent of a later mismatched inaccessible home' do
|
|
167
|
+
sid = described_class.create[:id]
|
|
168
|
+
described_class.append(session_id: sid, role: 'user', content: 'Fixture request')
|
|
169
|
+
home = File.join(@tmp, 'inaccessible-home')
|
|
170
|
+
Dir.mkdir(home, 0o000)
|
|
171
|
+
allow(Dir).to receive(:home).and_return(home)
|
|
172
|
+
# Model a changed HOME after store constants loaded, without touching any
|
|
173
|
+
# real account home or relying on the runner being unprivileged.
|
|
174
|
+
allow(File).to receive(:open).and_call_original
|
|
175
|
+
allow(File).to receive(:open).with(home, any_args).and_raise(Errno::EACCES, home)
|
|
176
|
+
config = { active: :openai, openai: { model: 'fixture', key: 'fixture-key' } }
|
|
177
|
+
allow(PWN::AI::Router).to receive(:summarize).and_call_original
|
|
178
|
+
allow(PWN::AI::OpenAI).to receive(:chat).and_return(choices: [{ content: JSON.generate(title: 'Fixture recovery', summary: 'Fixture repaired.') }])
|
|
179
|
+
failures = []
|
|
180
|
+
result = described_class.refresh_title(session_id: sid, ai_config: config, on_error: failures.method(:push))
|
|
181
|
+
expect(failures).to be_empty
|
|
182
|
+
expect(result[:title_source]).to eq('summary')
|
|
183
|
+
expect(File).not_to have_received(:open).with(home, any_args)
|
|
184
|
+
ensure
|
|
185
|
+
File.chmod(0o700, home) if home && File.directory?(home)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it 'retains provenance and removes its exact temporary artifact on denied metadata replacement' do
|
|
189
|
+
sid = described_class.create[:id]
|
|
190
|
+
described_class.append(session_id: sid, role: 'user', content: 'Fixture request')
|
|
191
|
+
path = described_class.support_path(kind: :metadata, session_id: sid)
|
|
192
|
+
before = File.binread(path)
|
|
193
|
+
allow(PWN::AI::Router).to receive(:summarize).and_return(JSON.generate(title: 'Fixture repair', summary: 'Repaired fixture.'))
|
|
194
|
+
temporary = nil
|
|
195
|
+
allow(File).to receive(:rename).and_call_original
|
|
196
|
+
allow(File).to receive(:rename).with(anything, path) do |source, _target|
|
|
197
|
+
temporary = source
|
|
198
|
+
expect(File.file?(source)).to be(true)
|
|
199
|
+
raise Errno::EACCES, path
|
|
200
|
+
end
|
|
201
|
+
failures = []
|
|
202
|
+
described_class.refresh_title(session_id: sid, on_error: failures.method(:push))
|
|
203
|
+
expect(failures).to eq(['Errno::EACCES'])
|
|
204
|
+
expect(File.binread(path)).to eq(before)
|
|
205
|
+
expect(temporary).not_to be_nil
|
|
206
|
+
expect(File.exist?(temporary)).to be(false)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
it 'does not mislabel an unavailable provider response as malformed JSON' do
|
|
210
|
+
sid = described_class.create[:id]
|
|
211
|
+
described_class.append(session_id: sid, role: 'user', content: 'Fixture request')
|
|
212
|
+
original = described_class.metadata(session_id: sid)
|
|
213
|
+
allow(PWN::AI::Router).to receive(:summarize).and_return('')
|
|
214
|
+
failures = []
|
|
215
|
+
expect(described_class.refresh_title(session_id: sid, on_error: failures.method(:push))).to eq(original)
|
|
216
|
+
expect(failures).to eq(['IOError'])
|
|
217
|
+
expect(described_class.metadata(session_id: sid)).to eq(original)
|
|
218
|
+
end
|
|
107
219
|
|
|
108
220
|
it 'rejects provider echoed placeholder titles without advancing summary provenance' do
|
|
109
221
|
session = described_class.create
|
data/spec/lib/pwn/setup_spec.rb
CHANGED
|
@@ -68,7 +68,7 @@ describe PWN::Setup do
|
|
|
68
68
|
expect(res[:dry_run]).to be true
|
|
69
69
|
expect(res[:ok]).to be true
|
|
70
70
|
end
|
|
71
|
-
it 'ensure_cron enabled:false
|
|
71
|
+
it 'ensure_cron enabled:false disables only the internal worker without native changes' do
|
|
72
72
|
tmp = Dir.mktmpdir('pwn-setup-nocron-')
|
|
73
73
|
stub_const('PWN::Cron::CRON_DIR', tmp)
|
|
74
74
|
stub_const('PWN::Cron::JOBS_FILE', File.join(tmp, 'jobs.yml'))
|
|
@@ -76,6 +76,8 @@ describe PWN::Setup do
|
|
|
76
76
|
stub_const('PWN::Cron::WORKER_LOG', File.join(tmp, 'worker.log'))
|
|
77
77
|
ENV['PWN_CRON_DIR'] = tmp
|
|
78
78
|
io = StringIO.new
|
|
79
|
+
expect(PWN::Cron).not_to receive(:install_scheduler)
|
|
80
|
+
expect(PWN::Cron).not_to receive(:uninstall_scheduler)
|
|
79
81
|
res = PWN::Setup.ensure_cron(enabled: false, apply: false, io: io)
|
|
80
82
|
expect(res[:enabled]).to be false
|
|
81
83
|
expect(res[:ok]).to be true
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rspec/core/formatters/base_formatter'
|
|
4
|
+
|
|
5
|
+
# The normal final summary is still printed. Emit failures immediately too,
|
|
6
|
+
# so a later killed example cannot erase the only actionable diagnostic.
|
|
7
|
+
class GateFailureFormatter < RSpec::Core::Formatters::BaseFormatter
|
|
8
|
+
RSpec::Core::Formatters.register self, :example_failed
|
|
9
|
+
|
|
10
|
+
def example_failed(notification)
|
|
11
|
+
output.puts notification.fully_formatted(1)
|
|
12
|
+
output.flush
|
|
13
|
+
end
|
|
14
|
+
end
|