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
|
@@ -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
|
|
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
|
-
|
|
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
|