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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -3
  3. data/Rakefile +19 -1
  4. data/bin/pwn_setup +1 -1
  5. data/build_gem.sh +1 -1
  6. data/documentation/Backup-Retention.md +27 -0
  7. data/documentation/Cron.md +64 -35
  8. data/documentation/How-PWN-Works.md +1 -1
  9. data/documentation/Installation.md +247 -6
  10. data/documentation/Persistence.md +1 -1
  11. data/documentation/Reinforcement-Learning.md +5 -6
  12. data/documentation/diagrams/cron-scheduling.svg +130 -128
  13. data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
  14. data/etc/default_cron/session_cleanup.rb +1217 -10
  15. data/lib/pwn/ai/agent/extrospection.rb +6 -7
  16. data/lib/pwn/ai/agent/learning.rb +48 -7
  17. data/lib/pwn/ai/agent/policy.rb +5 -0
  18. data/lib/pwn/ai/agent/swarm.rb +31 -16
  19. data/lib/pwn/ai/router.rb +45 -2
  20. data/lib/pwn/cron.rb +120 -49
  21. data/lib/pwn/migrate.rb +292 -15
  22. data/lib/pwn/plugins/binary_parser.rb +5 -4
  23. data/lib/pwn/plugins/ghidra.rb +4 -3
  24. data/lib/pwn/plugins/log.rb +18 -2
  25. data/lib/pwn/plugins/repl/ai.rb +12 -1
  26. data/lib/pwn/plugins/repl/ai_console.rb +6 -2
  27. data/lib/pwn/plugins/repl/ai_swarm.rb +4 -4
  28. data/lib/pwn/sessions.rb +58 -16
  29. data/lib/pwn/setup.rb +9 -17
  30. data/lib/pwn/version.rb +1 -1
  31. data/scripts/build_gate.rb +58 -0
  32. data/spec/integration/build_gate_spec.rb +73 -0
  33. data/spec/integration/persistence_roundtrip_spec.rb +1 -7
  34. data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
  35. data/spec/lib/pwn/ai/router_spec.rb +30 -2
  36. data/spec/lib/pwn/cron_spec.rb +113 -6
  37. data/spec/lib/pwn/migrate_spec.rb +160 -5
  38. data/spec/lib/pwn/plugins/log_spec.rb +7 -0
  39. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +16 -4
  40. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +11 -0
  41. data/spec/lib/pwn/redaction_spec.rb +13 -10
  42. data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
  43. data/spec/lib/pwn/session_cleanup_default_spec.rb +165 -2
  44. data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
  45. data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
  46. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +389 -0
  47. data/spec/lib/pwn/sessions_spec.rb +112 -0
  48. data/spec/lib/pwn/setup_spec.rb +3 -1
  49. data/spec/support/gate_failure_formatter.rb +14 -0
  50. data/third_party/pwn_rdoc.jsonl +15 -1
  51. 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
@@ -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 uninstalls the OS scheduler without spawning' do
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