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
@@ -6,7 +6,167 @@ require 'open3'
6
6
  RSpec.describe 'Bundled session cleanup' do
7
7
  include_context 'pwn tmp sandbox'
8
8
 
9
+ it 'builds and installs the package into an isolated gem home for fresh and schema-10 upgrade paths' do
10
+ package = File.join(@tmp, 'pwn.gem')
11
+ repository = File.expand_path('../../..', __dir__)
12
+ output, error, built = Open3.capture3(RbConfig.ruby, '-S', 'gem', 'build', 'pwn.gemspec', '--output', package, chdir: repository)
13
+ expect(built.success?).to be(true), "#{output}\n#{error}"
14
+ gem_home = File.join(@tmp, 'gems')
15
+ env = { 'HOME' => File.join(@tmp, 'installed-home'), 'GEM_HOME' => gem_home,
16
+ 'GEM_PATH' => [gem_home, *Gem.path].join(File::PATH_SEPARATOR), 'RUBYOPT' => nil, 'BUNDLE_GEMFILE' => nil,
17
+ 'PWN_CRON_DIR' => File.join(@tmp, 'installed-home', '.pwn', 'cron') }
18
+ FileUtils.mkdir_p(env['HOME'])
19
+ output, error, installed = Open3.capture3(env, RbConfig.ruby, '-S', 'gem', 'install', '--local', package, '--ignore-dependencies', '--no-document', '--install-dir', gem_home)
20
+ expect(installed.success?).to be(true), "#{output}\n#{error}"
21
+ script = <<~RUBY
22
+ require 'pwn'
23
+ require 'json'
24
+ require 'stringio'
25
+ raise 'wrong gem activated' unless Gem.loaded_specs.fetch('pwn').full_gem_path.start_with?(ENV.fetch('GEM_HOME'))
26
+ PWN::Cron.install_defaults
27
+ jobs = File.binread(PWN::Cron.jobs_file)
28
+ PWN::Cron.install_defaults
29
+ raise 'non-idempotent jobs' unless File.binread(PWN::Cron.jobs_file) == jobs
30
+ policy = File.join(Dir.home, '.pwn', 'maintenance', 'policy.json')
31
+ config = JSON.parse(File.read(policy)).merge('batch_size' => 7)
32
+ File.write(policy, JSON.generate(config))
33
+ File.write(PWN::Migrate::SCHEMA_FILE, JSON.generate(schema: 10))
34
+ upgrade = PWN::Migrate.run(fix: false, backup: false, io: StringIO.new)
35
+ raise 'missing upgrade' unless upgrade[:applied_migrations] == [11]
36
+ raise 'overwritten policy' unless JSON.parse(File.read(policy))['batch_size'] == 7
37
+ raise 'non-idempotent upgrade' unless PWN::Migrate.run(fix: false, backup: false, io: StringIO.new)[:applied_migrations].empty?
38
+ raise 'wrong helper' unless File.read(File.join(PWN::Cron.cron_dir, 'session_cleanup.rb')).include?('session_cleanup v10')
39
+ report = PWN::SessionCleanup.status
40
+ raise 'wrong inventory' unless report[:categories].length == 9
41
+ # Exercise the installed startup request and original next-slot guard.
42
+ configured = PWN::Cron.list
43
+ configured.each_value { |job| job[:enabled] = false unless job[:name] == 'session_cleanup' }
44
+ File.write(PWN::Cron.jobs_file, YAML.dump(configured))
45
+ tick = PWN::Cron.tick
46
+ raise 'startup failed' unless tick.length == 1 && tick.first[:status] == 'success'
47
+ raise 'startup guard bypassed' unless Dir.glob(File.join(Dir.home, '.pwn', 'maintenance', 'runs', '*.json')).empty?
48
+ raise 'startup not coalesced' unless PWN::Cron.tick.empty?
49
+ # Actual installed producer, with only external analysis transport replaced.
50
+ binary = File.join(Dir.home, 'fixture.bin')
51
+ File.binwrite(binary, 'fixture binary')
52
+ PWN::Plugins::Ghidra.define_singleton_method(:analyze) do |_opts = {}|
53
+ { stdout: 'fixture analysis', exit: 0 }
54
+ end
55
+ PWN::Plugins::Ghidra.decompile(bin: binary)
56
+ raise 'installed cache miss' unless PWN::Plugins::Ghidra.decompile(bin: binary)[:cached]
57
+ sid = PWN::Sessions.create[:id]
58
+ release = File.join(Dir.home, 'release-job')
59
+ command = "while [ ! -f \#{release} ]; do sleep 0.05; done; printf fixture-complete"
60
+ begin
61
+ job = PWN::Plugins::Jobs.start(command: command, session_id: sid, cwd: Dir.home, max_runtime: 20)
62
+ raise 'job not running' unless PWN::Plugins::Jobs.status(id: job[:id])[:status] == 'RUNNING'
63
+ maintenance = PWN::SessionCleanup.coordinate(dry_run: false)
64
+ raise 'missing coordinator phases' unless maintenance[:phases].keys == %i[maintenance memory_consolidation learning_retention mistakes_retention policy_retention]
65
+ raise 'active job session deleted' unless File.file?(File.join(PWN::Sessions.sessions_dir, sid + '.jsonl'))
66
+ raise 'job dependency omitted' unless PWN::Sessions.dependency_index[:dependencies].fetch(sid).any? { |path| path.include?('/jobs/') }
67
+ File.write(release, 'release')
68
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 10
69
+ while PWN::Plugins::Jobs.status(id: job[:id])[:status] == 'RUNNING'
70
+ raise 'job did not finish' if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
71
+ sleep 0.02
72
+ end
73
+ raise 'job outcome changed' unless PWN::Plugins::Jobs.status(id: job[:id])[:status] == 'COMPLETED'
74
+ ensure
75
+ File.write(release, 'release')
76
+ PWN::Plugins::Jobs.stop(id: job[:id]) if job && PWN::Plugins::Jobs.status(id: job[:id])[:status] == 'RUNNING'
77
+ end
78
+ puts JSON.generate(installed: true, schema: PWN::Migrate.installed_schema, categories: report[:categories].keys)
79
+ RUBY
80
+ output, error, ran = Open3.capture3(env, RbConfig.ruby, '-e', script, chdir: @tmp)
81
+ expect(ran.success?).to be(true), "#{output}\n#{error}"
82
+ expect(JSON.parse(output)).to include('installed' => true, 'schema' => 11)
83
+ end
84
+
85
+ it 'never seeds curriculum on fresh install, upgrade or repeated migration after deletion' do
86
+ PWN::Cron.install_defaults
87
+ expect(PWN::Cron.list.values.map { |row| row[:name] }).not_to include('curriculum_practice_nightly', 'curriculum_train_weekly', 'curriculum_offline_judge')
88
+ PWN::Cron::CURRICULUM_JOBS.each do |spec|
89
+ job = PWN::Cron.create(spec.merge(sync_native: false))
90
+ PWN::Cron.remove(id: job[:id], sync_native: false)
91
+ end
92
+ custom = PWN::Cron.create(name: 'custom-curriculum', ruby: '42', enabled: false, sync_native: false)
93
+ File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 7))
94
+ 2.times { PWN::Migrate.run(fix: false, backup: false, io: StringIO.new) }
95
+ expect(PWN::Cron.list.values.map { |row| row[:name] }).to contain_exactly('session_cleanup', 'custom-curriculum')
96
+ expect(PWN::Cron.list.fetch(custom[:id])).to eq(custom)
97
+ end
98
+
99
+ it 'coordinates evidence-first maintenance, memory consolidation and retention exactly once' do
100
+ require_relative '../../../etc/default_cron/session_cleanup'
101
+ allow(Dir).to receive(:home).and_return(@tmp)
102
+ FileUtils.mkdir_p(PWN::Sessions.sessions_dir)
103
+ cleanup = PWN::SessionCleanup
104
+ expect(cleanup).to receive(:maintain).with(dry_run: false, current_session_id: 'active', proc_root: '/proc').ordered.and_return(state: 'committed')
105
+ expect(PWN::AI::Agent::Learning).to receive(:consolidate).once.ordered.and_return(removed: 0)
106
+ expect(PWN::AI::Agent::Learning).to receive(:prune_outcomes!).with(dry_run: false).once.ordered.and_return(removed: 0)
107
+ expect(PWN::AI::Agent::Mistakes).to receive(:lean!).with(dry_run: false).once.ordered.and_return(removed: 0)
108
+ expect(PWN::AI::Agent::Policy).to receive(:lean!).with(dry_run: false).once.ordered.and_return(removed: 0)
109
+ expect(PWN::AI::Agent::Learning).not_to receive(:gc_stores!)
110
+ result = cleanup.coordinate(dry_run: false, current_session_id: 'active')
111
+ expect(result[:phases].keys).to eq(%i[maintenance memory_consolidation learning_retention mistakes_retention policy_retention])
112
+ end
113
+
114
+ it 'keeps blocked coordinator phases nonapplying' do
115
+ require_relative '../../../etc/default_cron/session_cleanup'
116
+ allow(Dir).to receive(:home).and_return(@tmp)
117
+ FileUtils.mkdir_p(PWN::Sessions.sessions_dir)
118
+ allow(PWN::SessionCleanup).to receive(:maintain).and_return(apply_blocked: true, state: 'blocked')
119
+ expect(PWN::AI::Agent::Learning).not_to receive(:consolidate)
120
+ expect(PWN::AI::Agent::Learning).not_to receive(:prune_outcomes!)
121
+ expect(PWN::AI::Agent::Mistakes).not_to receive(:lean!)
122
+ expect(PWN::AI::Agent::Policy).not_to receive(:lean!)
123
+ expect(PWN::SessionCleanup.coordinate[:state]).to eq('blocked')
124
+ end
125
+
126
+ it 'never applies retention during coordinator previews and rejects duplicate maintenance owners' do
127
+ require_relative '../../../etc/default_cron/session_cleanup'
128
+ allow(Dir).to receive(:home).and_return(@tmp)
129
+ FileUtils.mkdir_p(PWN::Sessions.sessions_dir)
130
+ allow(PWN::SessionCleanup).to receive(:maintain).and_return(state: 'planned')
131
+ expect(PWN::AI::Agent::Learning).not_to receive(:consolidate)
132
+ expect(PWN::AI::Agent::Learning).to receive(:prune_outcomes!).with(dry_run: true).once.and_return({})
133
+ expect(PWN::AI::Agent::Mistakes).to receive(:lean!).with(dry_run: true).once.and_return({})
134
+ expect(PWN::AI::Agent::Policy).to receive(:lean!).with(dry_run: true).once.and_return({})
135
+ expect(PWN::SessionCleanup.coordinate(dry_run: true)[:state]).to eq('planned')
136
+ expect(Dir.exist?(File.join(@tmp, '.pwn', 'maintenance', 'runs'))).to be(false)
137
+ PWN::SessionCleanup.send(:with_maintenance_lock, dry_run: false) do
138
+ thread = Thread.new do
139
+ expect { PWN::SessionCleanup.coordinate }.to raise_error(IOError, /already running/)
140
+ end
141
+ expect(thread.join(2)).to eq(thread)
142
+ thread.value
143
+ end
144
+ end
145
+
146
+ it 'preserves legacy maintenance jobs and avoids adding duplicate retention phases' do
147
+ legacy = PWN::Cron.create(name: 'learning_consolidate_nightly', ruby: 'custom', enabled: false, sync_native: false)
148
+ PWN::Cron.install_defaults
149
+ expect(PWN::Cron.list.fetch(legacy[:id])).to eq(legacy)
150
+ job = PWN::Cron.list.values.find { |row| row[:name] == 'session_cleanup' }
151
+ expect(job[:ruby]).to include('SessionCleanup.maintain(')
152
+ expect(job[:ruby]).not_to include('SessionCleanup.coordinate(')
153
+ end
154
+
155
+ it 'installs housekeeping defaults without overwriting policy edits' do
156
+ PWN::Cron.install_defaults
157
+ path = File.join(File.dirname(PWN::Cron.cron_dir), 'maintenance', 'policy.json')
158
+ expect(File.file?(path)).to be(true)
159
+ value = JSON.parse(File.read(path))
160
+ value['batch_size'] = 3
161
+ File.write(path, JSON.generate(value))
162
+ PWN::Cron.install_defaults
163
+ expect(JSON.parse(File.read(path))['batch_size']).to eq(3)
164
+ end
165
+
9
166
  it 'seeds a portable guarded daily job and helper only once' do
167
+ allow(PWN::Cron).to receive(:systemd_user_available?).and_return(true)
168
+ expect(PWN::Cron).not_to receive(:install_native_job)
169
+ expect(PWN::Cron).not_to receive(:install_scheduler)
10
170
  PWN::Cron.install_defaults
11
171
  jobs = PWN::Cron.list.values.select { |job| job[:name] == 'session_cleanup' }
12
172
  expect(jobs.length).to eq(1)
@@ -21,10 +181,13 @@ RSpec.describe 'Bundled session cleanup' do
21
181
  end
22
182
 
23
183
  it 'upgrades schema 7 and preserves an existing job identity, schedule and payload' do
184
+ allow(PWN::Cron).to receive(:systemd_user_available?).and_return(true)
185
+ expect(PWN::Cron).not_to receive(:install_native_job)
186
+ expect(PWN::Cron).not_to receive(:install_scheduler)
24
187
  original = PWN::Cron.create(name: 'session_cleanup', schedule: '12 9 * * *', ruby: 'custom guard', enabled: false)
25
188
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 7))
26
189
  result = PWN::Migrate.run(fix: false, backup: false, io: StringIO.new)
27
- expect(result[:applied_migrations]).to eq([8, 9, 10])
190
+ expect(result[:applied_migrations]).to eq([8, 9, 10, 11])
28
191
  expect(PWN::Cron.list.fetch(original[:id])).to eq(original)
29
192
  expect(PWN::Cron.list.values.count { |job| job[:name] == 'session_cleanup' }).to eq(1)
30
193
  expect(File).to exist(File.join(PWN::Cron.cron_dir, 'session_cleanup.rb'))
@@ -40,7 +203,7 @@ RSpec.describe 'Bundled session cleanup' do
40
203
  bytes = File.read(PWN::Cron.jobs_file)
41
204
  expect(PWN::Cron).not_to receive(:install_native_job)
42
205
  PWN::Cron.install_defaults
43
- expect(File.read(helper)).to include('# PWN managed default: session_cleanup v9')
206
+ expect(File.read(helper)).to include('# PWN managed default: session_cleanup v10')
44
207
  expect(File.read(PWN::Cron.jobs_file)).to eq(bytes)
45
208
  end
46
209
 
@@ -0,0 +1,101 @@
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
+ before do
10
+ allow(Dir).to receive(:home).and_return(File.join(@tmp, 'home'))
11
+ @root = File.join(Dir.home, '.pwn')
12
+ FileUtils.mkdir_p(File.join(@root, 'logs'))
13
+ FileUtils.mkdir_p(File.join(@root, 'sessions'))
14
+ File.write(File.join(@root, 'sessions', '.maintenance.lock'), '')
15
+ @path = File.join(@root, 'logs', 'pwn-ai-DEBUG-abc123-R1.log')
16
+ File.write(@path, 'debug session stop')
17
+ File.utime(Time.at(1), Time.at(1), @path)
18
+ allow(described_class).to receive(:legacy_open_files).and_return([])
19
+ @manifest = File.join(@tmp, 'expiry.json.gz')
20
+ end
21
+
22
+ it 'retains open descriptors, referenced paths, recent and unrecognized logs' do
23
+ File.write(File.join(@root, 'memory.json'), JSON.generate(evidence: @path))
24
+ expect(described_class.expire_legacy[:retained].first[:reason]).to eq('evidence_reference')
25
+ File.unlink(File.join(@root, 'memory.json'))
26
+ stat = File.stat(@path)
27
+ allow(described_class).to receive(:legacy_open_files).and_return([[stat.dev, stat.ino]])
28
+ expect(described_class.expire_legacy[:retained].first[:reason]).to eq('open_descriptor')
29
+ allow(described_class).to receive(:legacy_open_files).and_return([])
30
+ File.utime(Time.now, Time.now, @path)
31
+ expect(described_class.expire_legacy[:candidates]).to be_empty
32
+ File.utime(Time.at(1), Time.at(1), @path)
33
+ File.rename(@path, File.join(@root, 'logs', 'unknown.log'))
34
+ expect(described_class.expire_legacy[:candidates]).to be_empty
35
+ end
36
+
37
+ it 'fails closed on symlinks, changed inodes, unreadable proc and held maintenance locks' do
38
+ original = @path
39
+ File.rename(original, "#{original}.save")
40
+ File.symlink("#{original}.save", original)
41
+ expect { described_class.expire_legacy }.to raise_error(IOError, /Unsafe/)
42
+ File.unlink(original)
43
+ File.rename("#{original}.save", original)
44
+ allow(described_class).to receive(:legacy_open_files).and_raise(Errno::EACCES)
45
+ expect { described_class.expire_legacy }.to raise_error(Errno::EACCES)
46
+ allow(described_class).to receive(:legacy_open_files).and_return([])
47
+ File.open(File.join(@root, 'sessions', '.maintenance.lock'), 'r+') do |lock|
48
+ lock.flock(File::LOCK_EX)
49
+ expect { described_class.expire_legacy }.to raise_error(IOError, /Maintenance active/)
50
+ end
51
+ allow(described_class).to receive(:legacy_manifest).and_wrap_original do |method, *args|
52
+ method.call(*args)
53
+ File.write(original, 'replacement')
54
+ end
55
+ expect { described_class.expire_legacy(dry_run: false, manifest: @manifest) }.to raise_error(IOError, /changed/)
56
+ expect(File.read(original)).to eq('replacement')
57
+ end
58
+
59
+ it 'requires external metadata and preserves unclosed diagnostics' do
60
+ expect { described_class.expire_legacy(dry_run: false, manifest: File.join(@root, 'manifest.gz')) }.to raise_error(ArgumentError)
61
+ File.write(@path, 'still running')
62
+ File.utime(Time.at(1), Time.at(1), @path)
63
+ expect(described_class.expire_legacy[:candidates]).to be_empty
64
+ end
65
+
66
+ it 'expires triage cache only when byte-identical evidence survives outside the cache' do
67
+ directory = File.join(@root, 'cache', 'triage')
68
+ FileUtils.mkdir_p(directory)
69
+ cache = File.join(directory, "#{'a' * 64}.json")
70
+ File.write(cache, JSON.generate(sha256: 'a' * 64, path: '/missing/input'))
71
+ File.utime(Time.at(1), Time.at(1), cache)
72
+ FileUtils.mkdir_p(File.join(@root, 'artifacts', 'test'))
73
+ evidence = File.join(@root, 'artifacts', 'test', 'copy.bin')
74
+ FileUtils.cp(cache, evidence)
75
+ result = described_class.expire_legacy(dry_run: false, manifest: @manifest)
76
+ expect(result[:removed].map { |row| row[:path] }).to include(cache)
77
+ expect(File.exist?(evidence)).to be(true)
78
+ end
79
+
80
+ it 'keeps three valid recovery copies per known family and preserves exact evidence pins' do
81
+ File.write(File.join(@root, 'learning.jsonl'), "{}\n")
82
+ backups = (1..5).map do |day|
83
+ path = File.join(@root, format('learning.jsonl.bak-lean-202001%02d', day))
84
+ File.write(path, "{}\n")
85
+ File.utime(Time.at(day), Time.at(day), path)
86
+ path
87
+ end
88
+ File.write(File.join(@root, 'memory.json'), JSON.generate(evidence: backups.first))
89
+ result = described_class.expire_legacy(dry_run: false, manifest: @manifest)
90
+ expect(result[:removed].map { |row| row[:path] }).to include(backups[1])
91
+ expect(backups.select { |path| File.exist?(path) }).to eq(backups.values_at(0, 2, 3, 4))
92
+ end
93
+
94
+ it 'expires only closed recognized diagnostics with an external metadata manifest' do
95
+ result = described_class.expire_legacy(dry_run: false, manifest: @manifest)
96
+ expect(result[:removed].map { |row| row[:path] }).to eq([@path])
97
+ expect(File.exist?(@path)).to be(false)
98
+ expect(JSON.parse(Zlib::GzipReader.open(@manifest, &:read))['removed'].first['sha256']).to eq(Digest::SHA256.hexdigest('debug session stop'))
99
+ expect(Dir.children(File.join(@root, 'logs'))).to be_empty
100
+ end
101
+ end
@@ -3,6 +3,337 @@
3
3
  require 'spec_helper'
4
4
  require File.expand_path('../../../etc/default_cron/session_cleanup', __dir__)
5
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 'housekeeping inventory' do
26
+ it 'keeps filesystem metadata and bytes unchanged during status and maintenance dry-run' do
27
+ File.unlink(PWN::Sessions.support_path(kind: :lease, session_id: id))
28
+ snapshot = lambda do
29
+ Dir.glob(File.join(@tmp, '**', '*'), File::FNM_DOTMATCH).sort.to_h do |entry|
30
+ stat = File.lstat(entry)
31
+ [entry, [stat.ino, stat.mode, stat.size, stat.mtime, stat.ctime, stat.file? ? Digest::SHA256.file(entry).hexdigest : nil]]
32
+ end
33
+ end
34
+ before = snapshot.call
35
+ described_class.status
36
+ result = described_class.maintain(dry_run: true)
37
+ expect(result[:dry_run]).to be(true)
38
+ expect(snapshot.call).to eq(before)
39
+ end
40
+
41
+ it 'includes legacy migration backups as protected bounded inventory' do
42
+ directory = File.join(Dir.home, '.pwn', 'backup', '20261025_120000')
43
+ FileUtils.mkdir_p(directory)
44
+ File.write(File.join(directory, 'custom.json'), 'only-copy')
45
+ before = File.stat(directory)
46
+ row = described_class.status[:categories].fetch('backups')
47
+ expect(row).to include(files: 1, bytes: 9, unique_bytes: 9, incomplete: false,
48
+ policy_mode: 'protected_evidence_no_automatic_deletion')
49
+ expect(row[:retention_days]).to be_nil
50
+ expect(File.stat(directory).mtime).to eq(before.mtime)
51
+ expect(described_class.prune_legacy_backups[:compacted]).to be_empty
52
+ end
53
+
54
+ it 'evicts real producer cache under budget pressure but not an active reader or unknown bytes' do
55
+ described_class.install
56
+ config = described_class.policy
57
+ config['budget_bytes']['cache'] = 0
58
+ File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
59
+ binary = File.join(@tmp, 'binary')
60
+ File.binwrite(binary, "binary\xFF".b)
61
+ allow(PWN::Plugins::Ghidra).to receive(:analyze).and_return(stdout: '分析', exit: 0)
62
+ expect(PWN::Plugins::Ghidra.decompile(bin: binary)[:stdout]).to eq('分析')
63
+ expect(PWN::Plugins::Ghidra.decompile(bin: binary)[:cached]).to be(true)
64
+ cache = File.join(Dir.home, '.pwn', 'cache', 'decompile', "#{Digest::SHA256.file(binary).hexdigest}.json")
65
+ unknown = File.join(File.dirname(cache), 'unknown')
66
+ File.write(unknown, 'keep')
67
+ File.open("#{cache}.lease", 'r') do |lease|
68
+ lease.flock(File::LOCK_SH)
69
+ expect(described_class.clean_disposables(dry_run: false)[:deleted]).to be_empty
70
+ end
71
+ expect(described_class.clean_disposables(dry_run: false)[:deleted]).to eq([cache])
72
+ expect(File.read(unknown)).to eq('keep')
73
+ end
74
+
75
+ it 'keeps the old cache intact when atomic replacement runs out of space' do
76
+ cache = File.join(Dir.home, '.pwn', 'cache', 'failure')
77
+ described_class.cache(path: cache, bytes: 'before')
78
+ allow(File).to receive(:rename).and_call_original
79
+ allow(File).to receive(:rename).with(anything, cache).and_raise(Errno::ENOSPC)
80
+ expect { described_class.cache(path: cache, bytes: 'after') }.to raise_error(Errno::ENOSPC)
81
+ expect(described_class.cache(path: cache)).to eq('before')
82
+ expect(Dir.glob("#{cache}.*").reject { |name| name.end_with?('.lease') }).to be_empty
83
+ end
84
+
85
+ it 'rotates real ordinary logs and protects debug writers through request closure' do
86
+ described_class.install
87
+ log = File.join(Dir.home, '.pwn', 'logs', 'pwn.log')
88
+ PWN::Plugins::Log.append(level: :info, msg: 'first', which_self: self)
89
+ expect(File.read(log)).to include('first')
90
+ File.open(log, 'a') { |file| file.write('x' * 1_024_000) }
91
+ PWN::Plugins::Log.append(level: :info, msg: 'second', which_self: self)
92
+ expect(File.read(log)).to include('second')
93
+ expect(File.size(log)).to be < 1_024_000
94
+ expect(Dir.glob("#{log}.*").reject { |name| name.end_with?('.lease') }.length).to eq(1)
95
+ debug = File.join(Dir.home, '.pwn', 'logs', 'request.log')
96
+ PWN::Plugins::Log.send(:open_debug_file!, path: debug)
97
+ expect { described_class.register_disposable(path: debug, category: 'logs') }.to raise_error(IOError, /active/)
98
+ PWN::Plugins::Log.send(:close_debug_file!)
99
+ manifest = File.join(Dir.home, '.pwn', 'maintenance', 'disposable', "#{Digest::SHA256.hexdigest(debug)}.json")
100
+ expect(File.file?(manifest)).to be(true)
101
+ ensure
102
+ PWN::Plugins::Log.send(:close_debug_file!)
103
+ end
104
+
105
+ it 'coalesces startup work without scanning and executes it once through the existing cron tick' do
106
+ cron = File.join(Dir.home, '.pwn', 'cron')
107
+ allow(PWN::Cron).to receive(:cron_dir).and_return(cron)
108
+ FileUtils.mkdir_p(cron)
109
+ expect(described_class).not_to receive(:status)
110
+ expect(described_class).not_to receive(:maintain)
111
+ PWN::Cron.install_defaults
112
+ request = File.join(Dir.home, '.pwn', 'maintenance', 'background.json')
113
+ bytes = File.binread(request)
114
+ PWN::Cron.install_defaults
115
+ expect(File.binread(request)).to eq(bytes)
116
+ allow(PWN::Cron).to receive(:due_jobs).and_return({})
117
+ job = PWN::Cron.list.values.find { |entry| entry[:name] == 'session_cleanup' }
118
+ expect(PWN::Cron).to receive(:run).with(id: job[:id]).once.and_return(status: 'success', duration: 0)
119
+ expect(PWN::Cron.tick.length).to eq(1)
120
+ expect(PWN::Cron.tick).to be_empty
121
+ expect(JSON.parse(File.read(request))['state']).to eq('completed')
122
+ end
123
+
124
+ it 'reports an interrupted broad transaction without losing the prepared ledger' do
125
+ described_class.install
126
+ allow(described_class).to receive(:maintenance_transaction).and_raise(Interrupt)
127
+ expect { described_class.maintain(dry_run: false) }.to raise_error(Interrupt)
128
+ ledger = Dir.glob(File.join(Dir.home, '.pwn', 'maintenance', 'runs', '*.json')).fetch(0)
129
+ expect(JSON.parse(File.read(ledger))).to include('state' => 'interrupted', 'error' => 'Interrupt')
130
+ end
131
+
132
+ it 'serializes cache publication across real processes and threads' do
133
+ cache = File.join(Dir.home, '.pwn', 'cache', 'concurrent')
134
+ described_class.cache(path: cache, bytes: 'initial')
135
+ workers = 2.times.map do |worker|
136
+ fork do
137
+ threads = 3.times.map do |thread|
138
+ Thread.new { 8.times { |index| described_class.cache(path: cache, bytes: JSON.generate(worker: worker, thread: thread, index: index, text: '日本語')) } }
139
+ end
140
+ threads.each(&:value)
141
+ exit! 0
142
+ rescue StandardError
143
+ exit! 1
144
+ end
145
+ end
146
+ workers.each { |pid| expect(Process.wait2(pid).last.success?).to be(true) }
147
+ body = described_class.cache(path: cache)
148
+ expect(JSON.parse(body)['text']).to eq('日本語')
149
+ manifest = File.join(Dir.home, '.pwn', 'maintenance', 'disposable', "#{Digest::SHA256.hexdigest(cache)}.json")
150
+ expect(JSON.parse(File.read(manifest))['sha256']).to eq(Digest::SHA256.hexdigest(body))
151
+ end
152
+
153
+ it 'bounds evidence migration without dropping unmigrated rows or breaking readers' do
154
+ other = '20200101_010204_abcdef12'
155
+ PWN::Sessions.create(id: other)
156
+ [id, other].each { |sid| PWN::Sessions.append(session_id: sid, role: 'user', content: '証拠') }
157
+ file = PWN::AI::Agent::Learning::LEARNING_FILE
158
+ File.write(file, [id, other].map { |sid| JSON.generate(session_id: sid, outcome: 'fixture') }.join("\n"))
159
+ result = PWN::AI::Agent::Learning.migrate_evidence(dry_run: false, max_sessions: 1)
160
+ expect(result[:migrated_rows]).to eq(1)
161
+ rows = File.readlines(file).map { |line| JSON.parse(line) }
162
+ expect(rows.length).to eq(2)
163
+ expect(rows.count { |row| row['evidence_ref'] }).to eq(1)
164
+ expect(PWN::Sessions.load(session_id: other).last[:content]).to eq('証拠')
165
+ expect(PWN::AI::Agent::Learning.migrate_evidence(dry_run: false, max_sessions: 1)[:migrated_rows]).to eq(1)
166
+ end
167
+
168
+ it 'honors session retention floors and cooperative cancellation before deletion' do
169
+ allow(described_class).to receive(:legacy_owners).and_return([])
170
+ described_class.install
171
+ config = File.join(Dir.home, '.pwn', 'maintenance', 'policy.json')
172
+ value = described_class.policy
173
+ value['retention_days']['sessions'] = 1
174
+ File.write(config, JSON.generate(value))
175
+ expect(described_class.run(dry_run: false)[:deleted_ids]).to be_empty
176
+ value['retention_days']['sessions'] = 0
177
+ File.write(config, JSON.generate(value))
178
+ control = double(stopping?: true)
179
+ Thread.current[:pwn_steering_input] = control
180
+ expect(described_class.run(dry_run: false)[:deleted_ids]).to be_empty
181
+ expect(File.file?(path)).to be(true)
182
+ ensure
183
+ Thread.current[:pwn_steering_input] = nil
184
+ end
185
+
186
+ it 'expires only committed verified disposable backups and retains prepared recovery evidence' do
187
+ file = File.join(Dir.home, '.pwn', 'tmp', 'expired-backup')
188
+ FileUtils.mkdir_p(File.dirname(file))
189
+ File.write(file, 'disposable')
190
+ described_class.register_disposable(path: file, category: 'tmp', expires_at: 0)
191
+ result = described_class.clean_disposables(dry_run: false)
192
+ backup = result[:records].first['backup']
193
+ old = Time.now - (100 * 86_400)
194
+ File.utime(old, old, result[:ledger])
195
+ expect(described_class.prune_quarantine(dry_run: true)[:eligible]).to eq([backup])
196
+ expect(File.file?(backup)).to be(true)
197
+ expect(described_class.prune_quarantine(dry_run: false)[:deleted]).to eq([backup])
198
+ expect(File.exist?(backup)).to be(false)
199
+ expect(JSON.parse(File.read(result[:ledger]))['state']).to eq('expired')
200
+ end
201
+
202
+ it 'requires explicit continuation retirement and preserves an active association' do
203
+ swarm = PWN::AI::Agent::Swarm
204
+ root = File.join(Dir.home, '.pwn', 'swarm')
205
+ stub_const('PWN::AI::Agent::Swarm::SWARM_ROOT', root)
206
+ sid = swarm.create[:swarm_id]
207
+ swarm.spawn(name: 'fixture', role: 'fixture', swarm_id: sid)
208
+ map = File.join(root, sid, 'personas.json')
209
+ File.write(map, JSON.generate('fixture' => id))
210
+ lease = PWN::Sessions.activate(session_id: id)
211
+ expect { swarm.retire(name: 'fixture', swarm_id: sid, local: true, continuation: true) }.to raise_error(IOError, /active/)
212
+ expect(JSON.parse(File.read(map))['fixture']).to eq(id)
213
+ PWN::Sessions.deactivate(lease: lease)
214
+ lease = nil
215
+ result = swarm.retire(name: 'fixture', swarm_id: sid, local: true, continuation: true)
216
+ expect(result[:continuation]).to include(session_id: id, state: 'committed')
217
+ expect(JSON.parse(File.read(map))).not_to have_key('fixture')
218
+ expect(File.exist?(path)).to be(true)
219
+ expect(swarm.send(:persona_session, swarm_id: sid, name: 'fixture')).not_to eq(id)
220
+ ensure
221
+ PWN::Sessions.deactivate(lease: lease) if lease
222
+ end
223
+
224
+ it 'bounds applying session batches without hiding remaining candidates' do
225
+ allow(described_class).to receive(:legacy_owners).and_return([])
226
+ PWN::Sessions.create(id: '20200101_010203_abcdef13')
227
+ described_class.install
228
+ config = File.join(Dir.home, '.pwn', 'maintenance', 'policy.json')
229
+ File.write(config, JSON.generate(described_class.policy.merge('batch_size' => 1)))
230
+ result = described_class.run(dry_run: false)
231
+ expect(result[:deleted_ids].length).to eq(1)
232
+ expect(result[:candidate_ids].length).to eq(2)
233
+ expect(result[:skipped][:batch_limit]).to eq(1)
234
+ expect(Dir.glob(File.join(PWN::Sessions.sessions_dir, '*.jsonl')).length).to eq(1)
235
+ end
236
+
237
+ it 'preserves failed writes and exposes interrupted prepared records instead of declaring completion' do
238
+ file = File.join(Dir.home, '.pwn', 'tmp', 'failure')
239
+ FileUtils.mkdir_p(File.dirname(file))
240
+ File.binwrite(file, "binary\xFF".b)
241
+ described_class.register_disposable(path: file, category: 'tmp', expires_at: 0)
242
+ allow(File).to receive(:unlink).and_call_original
243
+ allow(File).to receive(:unlink).with(file).and_raise(Errno::EACCES)
244
+ result = described_class.clean_disposables(dry_run: false)
245
+ expect(result[:state]).to eq('interrupted')
246
+ expect(File.binread(file)).to eq("binary\xFF".b)
247
+ ledger = JSON.parse(File.binread(result[:ledger]))
248
+ expect(ledger['records'].first['state']).to eq('prepared')
249
+ expect(File.binread(ledger['records'].first['backup'])).to eq(File.binread(file))
250
+ ensure
251
+ allow(File).to receive(:unlink).and_call_original
252
+ end
253
+
254
+ it 'recovers exact disposable backups without overwriting changed destinations' do
255
+ file = File.join(Dir.home, '.pwn', 'tmp', 'binary')
256
+ FileUtils.mkdir_p(File.dirname(file))
257
+ bytes = "café\xFF\x00".b
258
+ File.binwrite(file, bytes)
259
+ described_class.register_disposable(path: file, category: 'tmp', expires_at: 0)
260
+ result = described_class.clean_disposables(dry_run: false)
261
+ expect(described_class.recover(ledger: result[:ledger])[:restored]).to be_empty
262
+ expect(File.exist?(file)).to be(false)
263
+ expect(described_class.recover(ledger: result[:ledger], dry_run: false)[:restored]).to eq([file])
264
+ expect(File.binread(file)).to eq(bytes)
265
+ File.binwrite(file, 'later user edit')
266
+ expect { described_class.recover(ledger: result[:ledger], dry_run: false) }.to raise_error(IOError, /existing/)
267
+ expect(File.binread(file)).to eq('later user edit')
268
+ end
269
+
270
+ it 'serializes transcript compaction and append on a permanent writer inode' do
271
+ lock_path = "#{path}.write.lock"
272
+ writer = nil
273
+ File.open(lock_path, 'a') do |lock|
274
+ lock.flock(File::LOCK_EX)
275
+ entered = Queue.new
276
+ writer = Thread.new do
277
+ entered << true
278
+ PWN::Sessions.append(session_id: id, role: 'assistant', content: 'retained append')
279
+ end
280
+ entered.pop
281
+ expect(writer.join(0.05)).to be_nil
282
+ end
283
+ writer.join(2)
284
+ expect(writer).not_to be_alive
285
+ expect(PWN::Sessions.load(session_id: id).last[:content]).to eq('retained append')
286
+ inode = File.stat(lock_path).ino
287
+ PWN::Sessions.send(:compact_transcript!, path: path, assistant_content_max: 2)
288
+ expect(File.stat(lock_path).ino).to eq(inode)
289
+ expect(Dir.glob("#{path}.*.tmp")).to be_empty
290
+ ensure
291
+ writer&.join(2)
292
+ end
293
+
294
+ it 'dispatches maintenance status locally and shares routine cleanup with the cron entry point' do
295
+ expect(PWN::Plugins::REPL).to receive(:pwn_ai_print_result).with(value: hash_including(operation: 'status'))
296
+ expect(PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: '/maintenance status', raise_errors: true)).to be_truthy
297
+ allow(described_class).to receive(:legacy_owners).and_return([])
298
+ expect(described_class).to receive(:clean_disposables).with(dry_run: true).and_call_original
299
+ expect(described_class.maintain(dry_run: true)[:storage]).to include(operation: 'routine')
300
+ end
301
+
302
+ it 'cleans only verified expired disposable bytes and preserves active, changed and unknown files' do
303
+ root = File.join(Dir.home, '.pwn', 'cache')
304
+ FileUtils.mkdir_p(root)
305
+ paths = %w[expired active changed unknown].to_h { |name| [name, File.join(root, name)] }
306
+ paths.each_value { |file| File.binwrite(file, "café\x00".b) }
307
+ %w[expired active changed].each { |name| described_class.register_disposable(path: paths[name], category: 'cache', expires_at: Time.now.to_i - 1) }
308
+ File.binwrite(paths['changed'], 'new data')
309
+ File.open("#{paths['active']}.lease", 'a') do |lease|
310
+ lease.flock(File::LOCK_SH)
311
+ preview = described_class.clean_disposables(dry_run: true)
312
+ expect(preview[:eligible]).to eq([paths['expired']])
313
+ expect(File.exist?(paths['expired'])).to be(true)
314
+ result = described_class.clean_disposables(dry_run: false)
315
+ expect(result[:deleted]).to eq([paths['expired']])
316
+ expect(File.exist?(paths['expired'])).to be(false)
317
+ expect(JSON.parse(File.binread(result[:ledger]))['state']).to eq('committed')
318
+ end
319
+ %w[active changed unknown].each { |name| expect(File.file?(paths[name])).to be(true) }
320
+ end
321
+
322
+ it 'reports all storage categories read-only and preserves unknown files' do
323
+ root = File.join(Dir.home, '.pwn')
324
+ FileUtils.mkdir_p(File.join(root, 'tmp'))
325
+ unknown = File.join(root, 'tmp', 'unknown.bin')
326
+ File.binwrite(unknown, "\xFF\x00".b)
327
+ before = Dir.glob(File.join(root, '**', '*'), File::FNM_DOTMATCH).sort
328
+ status = described_class.status
329
+ expect(status[:categories].keys).to match_array(%w[sessions learning artifacts state logs cache tmp maintenance backups])
330
+ expect(status[:categories]['tmp']).to include(files: 1, bytes: 2)
331
+ expect(Dir.glob(File.join(root, '**', '*'), File::FNM_DOTMATCH).sort).to eq(before)
332
+ expect(File.binread(unknown)).to eq("\xFF\x00".b)
333
+ end
334
+ end
335
+ end
336
+
6
337
  RSpec.describe PWN::SessionCleanup do
7
338
  include_context 'pwn tmp sandbox'
8
339
 
@@ -289,14 +620,16 @@ RSpec.describe PWN::SessionCleanup do
289
620
  cron_id = PWN::Sessions.create[:id]
290
621
  payload = PWN::Cron::DEFAULT_JOBS.find { |job| job[:name] == 'session_cleanup' }[:ruby]
291
622
  allow(PWN::Cron).to receive(:cron_dir).and_return(File.expand_path('../../../etc/default_cron', __dir__))
292
- expect(eval(payload)[:deleted_ids]).to eq([cron_id]) # rubocop:disable Security/Eval -- execute the exact bundled local job
623
+ coordinated = eval(payload) # rubocop:disable Security/Eval -- execute the exact bundled local job
624
+ expect(coordinated.dig(:phases, :maintenance, :deleted_ids)).to eq([cron_id])
625
+ expect(File).not_to exist(File.join(PWN::Sessions.sessions_dir, "#{cron_id}.jsonl"))
293
626
  legacy_id = PWN::Sessions.create[:id]
294
627
  expect(described_class.run(dry_run: false)[:deleted_ids]).to eq([legacy_id])
295
628
  end
296
629
 
297
630
  it 'does not migrate evidence or delete while legacy ownership is unresolved' do
298
631
  allow(described_class).to receive(:legacy_owners).and_return([{ pid: 123, reason: 'legacy_session_ownership_unknown' }])
299
- expect(PWN::AI::Agent::Learning).to receive(:migrate_evidence).with(dry_run: true).and_call_original
632
+ expect(PWN::AI::Agent::Learning).to receive(:migrate_evidence).with(hash_including(dry_run: true, max_sessions: 100)).and_call_original
300
633
  expect(described_class.maintain[:apply_blocked]).to be(true)
301
634
  expect(File).to exist(path)
302
635
  end