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
data/lib/pwn/sessions.rb CHANGED
@@ -88,11 +88,13 @@ module PWN
88
88
  when :trace then ['traces', File.join(id, 'trace.jsonl')]
89
89
  else raise ArgumentError, 'Invalid session support kind'
90
90
  end
91
- legacy = File.join(sessions_dir, name)
91
+ directory = opts[:read_only] ? SESSIONS_DIR : sessions_dir
92
+ legacy = File.join(directory, name)
92
93
  return legacy if File.exist?(legacy) || File.symlink?(legacy)
93
94
 
94
- path = File.join(sessions_dir, 'support', folder, name)
95
- return legacy unless File.exist?(path) || File.symlink?(path) || File.file?(File.join(sessions_dir, '.layout-v2'))
95
+ path = File.join(directory, 'support', folder, name)
96
+ return legacy unless File.exist?(path) || File.symlink?(path) || File.file?(File.join(directory, '.layout-v2'))
97
+ return path if opts[:read_only]
96
98
 
97
99
  FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
98
100
  raise IOError, 'Unsafe session support directory' unless File.realpath(File.dirname(path)) == File.expand_path(File.dirname(path))
@@ -183,11 +185,31 @@ module PWN
183
185
  files.concat(Dir.glob(File.join(root, 'artifacts', '{manifest.jsonl,*/index.jsonl,*/engagement-memory.json}')))
184
186
  databases = Dir.glob(File.join(root, 'embeddings', '*.db'))
185
187
  errors.concat(%w[artifacts embeddings].map { |name| File.join(root, name) }.select { |dir| File.symlink?(dir) || (File.exist?(dir) && !File.directory?(dir)) })
188
+ admission = Thread.current[:pwn_maintenance_admission]
186
189
  directories.each do |dir|
187
190
  next unless File.directory?(dir) && !File.symlink?(dir)
188
191
 
189
- files.concat(Dir.glob(File.join(dir, '**', '*.{json,jsonl}')).reject { |path| File.basename(path) == 'loot.json' })
190
- errors.concat(Dir.glob(File.join(dir, '**', '*')).select { |path| File.symlink?(path) })
192
+ pending = [[dir, Dir.each_child(dir)]]
193
+ until pending.empty?
194
+ begin
195
+ path = File.join(pending.last[0], pending.last[1].next)
196
+ rescue StopIteration
197
+ pending.pop
198
+ next
199
+ end
200
+ if admission && !admission.call
201
+ errors << 'incomplete_dependency_index'
202
+ break
203
+ end
204
+ if File.symlink?(path)
205
+ errors << path
206
+ elsif File.directory?(path)
207
+ pending << [path, Dir.each_child(path)]
208
+ elsif %w[.json .jsonl].include?(File.extname(path)) && File.basename(path) != 'loot.json'
209
+ files << path
210
+ end
211
+ end
212
+ break if errors.include?('incomplete_dependency_index')
191
213
  end
192
214
  dependencies = {}
193
215
  evidence_dependencies = {}
@@ -206,12 +228,20 @@ module PWN
206
228
  errors << path
207
229
  end
208
230
  files.uniq.sort.each do |path|
231
+ if admission && !admission.call
232
+ errors << 'incomplete_dependency_index'
233
+ break
234
+ end
209
235
  next unless File.exist?(path) || File.symlink?(path)
210
236
 
211
237
  begin
212
238
  raise IOError, 'Unsafe dependency parent' if errors.any? { |parent| path.start_with?("#{parent}/") }
213
239
  raise IOError, 'Unsafe dependency store' unless File.file?(path) && File.realpath(path) == File.expand_path(path)
214
240
 
241
+ if admission && !admission.call(File.size(path))
242
+ errors << 'incomplete_dependency_index'
243
+ break
244
+ end
215
245
  bytes = File.binread(path)
216
246
  units = dependency_units(path: path, bytes: bytes)
217
247
  units.each do |locator, record|
@@ -560,9 +590,12 @@ module PWN
560
590
  sid = opts[:session_id].to_s
561
591
  raise ArgumentError, 'Invalid session ID' if sid.empty? || File.basename(sid) != sid || sid == '.' || sid == '..'
562
592
 
563
- File.open(support_path(kind: :lease, session_id: sid), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
593
+ path = support_path(kind: :lease, session_id: sid, read_only: true)
594
+ File.open(path, File::RDONLY | File::NOFOLLOW) do |lock|
564
595
  !lock.flock(File::LOCK_EX | File::LOCK_NB)
565
596
  end
597
+ rescue Errno::ENOENT
598
+ false
566
599
  end
567
600
 
568
601
  # Supported Method Parameters::
@@ -752,7 +785,9 @@ module PWN
752
785
  # Character chunks keep Unicode intact, including four-byte characters.
753
786
  text.chars.each_slice(1500) do |chunk|
754
787
  output = PWN::AI::Router.summarize(text: "Previous overall summary: #{result[:summary]}\nNew conversation turns:\n#{chunk.join}", llm: true, session: true)
755
- parsed = JSON.parse(output.to_s.sub(/\A```(?:json)?\s*/, '').sub(/\s*```\z/, ''), symbolize_names: true)
788
+ raise IOError, 'Session title provider returned no text' if output.to_s.strip.empty?
789
+
790
+ parsed = JSON.parse(output.to_s.strip.sub(/\A```(?:json)?\s*/, '').sub(/\s*```\z/, ''), symbolize_names: true)
756
791
  raise ArgumentError, 'Invalid summary response' unless parsed.is_a?(Hash) && parsed[:title].is_a?(String) && parsed[:summary].is_a?(String)
757
792
  raise ArgumentError, 'Invalid summary title' if !summary_title_valid?(title: parsed[:title]) || parsed[:summary].strip.empty?
758
793
 
@@ -835,7 +870,14 @@ module PWN
835
870
  content: content,
836
871
  timestamp: Time.now.utc.iso8601
837
872
  }
838
- File.open(path, 'a') { |f| f.puts(JSON.dump(entry)) }
873
+ File.open("#{path}.write.lock", File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |writer|
874
+ writer.flock(File::LOCK_EX)
875
+ File.open(path, File::WRONLY | File::APPEND | File::NOFOLLOW) do |file|
876
+ file.puts(JSON.dump(entry))
877
+ file.flush
878
+ file.fsync
879
+ end
880
+ end
839
881
  provisional_title(session_id: sid, request: content) if role == 'user'
840
882
  entry
841
883
  ensure
@@ -1236,12 +1278,10 @@ module PWN
1236
1278
  return false unless File.exist?(path)
1237
1279
 
1238
1280
  lease = activate(session_id: File.basename(path, '.jsonl'))
1281
+ writer = File.open("#{path}.write.lock", File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen -- held through atomic replace; ensure closes it
1282
+ writer.flock(File::LOCK_EX)
1239
1283
  lines = File.readlines(path)
1240
- rows = lines.map do |l|
1241
- JSON.parse(l, symbolize_names: true)
1242
- rescue StandardError
1243
- nil
1244
- end.compact
1284
+ rows = lines.map { |line| JSON.parse(line, symbolize_names: true) }
1245
1285
  return false if rows.empty?
1246
1286
 
1247
1287
  # Preserve last assistant fully-ish; compact tools and older assistants
@@ -1266,17 +1306,19 @@ module PWN
1266
1306
  end
1267
1307
  return changed if dry || !changed
1268
1308
 
1269
- tmp = "#{path}.#{Process.pid}.tmp"
1270
- File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f|
1309
+ tmp = "#{path}.#{SecureRandom.hex(12)}.tmp"
1310
+ File.open(tmp, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |f|
1271
1311
  f.flock(File::LOCK_EX)
1272
1312
  rows.each { |e| f.puts(JSON.generate(e)) }
1273
1313
  f.flush
1274
1314
  f.fsync
1275
1315
  end
1276
1316
  File.rename(tmp, path)
1317
+ File.open(File.dirname(path), File::RDONLY, &:fsync)
1277
1318
  true
1278
1319
  ensure
1279
1320
  FileUtils.rm_f(tmp) if defined?(tmp) && tmp && File.exist?(tmp)
1321
+ writer&.close
1280
1322
  deactivate(lease: lease)
1281
1323
  end
1282
1324
 
@@ -1358,7 +1400,7 @@ module PWN
1358
1400
  #{self}.list(include_hidden: 'optional - include unretained sessions for diagnostics and explicit resume', current_session_id: 'optional - include this active session regardless of naming classification', references: 'optional - precomputed structural dependency index')
1359
1401
 
1360
1402
  # Resolve housekeeping paths with legacy compatibility.
1361
- #{self}.support_path(kind: 'required - metadata, lease, owner or trace', session_id: 'required - support record identifier')
1403
+ #{self}.support_path(kind: 'required - metadata, lease, owner or trace', session_id: 'required - support record identifier', read_only: 'optional - resolve without creating session or support directories')
1362
1404
 
1363
1405
  # Page retained sessions separately from global store diagnostics.
1364
1406
  #{self}.catalog(offset: 'optional - nonnegative retained session offset, omitted selects newest page', limit: 'optional - page size from one to 200, defaults 25 plus active row if outside page', current_session_id: 'optional - active session id always included without granting retention')
data/lib/pwn/setup.rb CHANGED
@@ -1047,22 +1047,20 @@ module PWN
1047
1047
  end
1048
1048
 
1049
1049
  if dry_run
1050
- st = PWN::Cron.scheduler_status(apply: false)
1050
+ st = { worker: PWN::Cron.worker_status, backend: :worker }
1051
1051
  io.puts "#{'cron worker'.ljust(26)} #{st.dig(:worker, :running) ? OK : MISS} (dry-run, backend=#{st[:backend]}, enabled=#{enabled})"
1052
1052
  return { ok: true, dry_run: true, worker: st[:worker], backend: st[:backend], enabled: enabled }
1053
1053
  end
1054
1054
 
1055
1055
  unless enabled
1056
- native = begin
1057
- PWN::Cron.uninstall_scheduler(apply: opts.fetch(:apply, PWN::Cron.apply_native?))
1058
- rescue StandardError => e
1059
- io.puts "#{'cron scheduler'.ljust(26)} #{MISS} #{e.class}: #{e.message}"
1060
- { ok: false, error: "#{e.class}: #{e.message}" }
1061
- end
1056
+ PWN::Cron.persist_scheduler_config(enabled: false)
1057
+ worker = PWN::Cron.stop_worker
1058
+ native = { backend: :worker, worker: worker }
1062
1059
  io.puts "#{'cron scheduler'.ljust(26)} #{OK} disabled (#{native[:backend] || 'none'})"
1063
1060
  return { ok: true, enabled: false, native: native, seeded: [] }
1064
1061
  end
1065
1062
 
1063
+ PWN::Cron.persist_scheduler_config(enabled: true) if opts.key?(:enabled)
1066
1064
  seeded = begin
1067
1065
  PWN::Cron.install_defaults
1068
1066
  rescue StandardError => e
@@ -1078,6 +1076,7 @@ module PWN
1078
1076
  seeded: seeded,
1079
1077
  worker: live.merge(already_running: true),
1080
1078
  skipped_spawn: true,
1079
+ backend: :worker,
1081
1080
  enabled: true
1082
1081
  }
1083
1082
  end
@@ -1097,16 +1096,9 @@ module PWN
1097
1096
  mark = st[:running] ? OK : MISS
1098
1097
  io.puts "#{'cron worker'.ljust(26)} #{mark} #{label} pid=#{st[:pid] || worker[:pid] || 'none'}"
1099
1098
 
1100
- native = begin
1101
- PWN::Cron.install_scheduler(
1102
- enabled: true,
1103
- apply: opts.fetch(:apply, PWN::Cron.apply_native?)
1104
- )
1105
- rescue StandardError => e
1106
- io.puts "#{'cron scheduler'.ljust(26)} #{MISS} #{e.class}: #{e.message}"
1107
- { ok: false, error: "#{e.class}: #{e.message}" }
1108
- end
1109
- backend = native.is_a?(Hash) ? native[:backend] : nil
1099
+ # Setup never installs, starts, removes or repairs native schedulers.
1100
+ native = { ok: true, backend: :worker, persisted: false }
1101
+ backend = :worker
1110
1102
  io.puts "#{'cron scheduler'.ljust(26)} #{native[:ok] == false ? MISS : OK} backend=#{backend || 'none'}"
1111
1103
 
1112
1104
  {
data/lib/pwn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.783'
4
+ VERSION = '0.5.784'
5
5
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Run only the validation gate, never installation. Keep one bounded, private
4
+ # log even when a terminal disconnects or the child exits without a summary.
5
+ require 'fileutils'
6
+ require 'open3'
7
+
8
+ abort 'Usage: ruby scripts/build_gate.rb command [args...]' if ARGV.empty?
9
+
10
+ limit = 1_048_576
11
+ path = File.join(Dir.home, '.pwn', 'logs', 'build-gate.log')
12
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
13
+ File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |log|
14
+ abort "Build gate already running; log: #{path}" unless log.flock(File::LOCK_EX | File::LOCK_NB)
15
+
16
+ log.chmod(0o600)
17
+ log.truncate(0)
18
+ log.sync = true
19
+ $stdout.sync = true
20
+ append = lambda do |bytes|
21
+ if log.size + bytes.bytesize > limit
22
+ log.rewind
23
+ retained = (log.read + bytes).byteslice(-limit, limit)
24
+ log.rewind
25
+ log.truncate(0)
26
+ log.write(retained)
27
+ else
28
+ log.seek(0, IO::SEEK_END)
29
+ log.write(bytes)
30
+ end
31
+ end
32
+ emit = lambda do |bytes|
33
+ append.call(bytes)
34
+ begin
35
+ $stdout.write(bytes)
36
+ rescue Errno::EPIPE, IOError
37
+ # Continue draining the child into the durable log after stream loss.
38
+ nil
39
+ end
40
+ end
41
+ emit.call("Build gate log: #{path} (last #{limit} bytes; replaced on next run)\n")
42
+ emit.call("Gate launcher: uid=#{Process.uid} ruby=#{RUBY_VERSION} cwd=#{Dir.pwd} HOME=#{Dir.home}\n")
43
+ status = nil
44
+ Open3.popen2e(*ARGV) do |input, output, waiter|
45
+ input.close
46
+ loop { emit.call(output.readpartial(16_384)) }
47
+ rescue EOFError
48
+ status = waiter.value
49
+ end
50
+ result = status.signaled? ? "signal=#{Signal.signame(status.termsig)}" : "exit=#{status.exitstatus}"
51
+ emit.call("\nBuild gate #{result}\n")
52
+ unless status.success?
53
+ log.rewind
54
+ tail = log.read.byteslice(-16_384, 16_384) || log.tap(&:rewind).read
55
+ warn "\nBuild gate failed (#{result}). Build gate log: #{path}\n#{tail}"
56
+ end
57
+ exit(status.exitstatus || (128 + status.termsig))
58
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'open3'
5
+ require 'tmpdir'
6
+
7
+ RSpec.describe 'build gate diagnostics' do
8
+ it 'reports the RSpec child signal rather than only a generic command failure' do
9
+ Dir.mktmpdir('pwn-rake-signal-') do |home|
10
+ spec = File.join(home, 'terminated_spec.rb')
11
+ File.write(spec, "RSpec.describe('fixture') { it('terminates') { Process.kill('KILL', Process.pid) } }\n")
12
+ output, status = Open3.capture2e({ 'HOME' => home, 'SPEC' => spec, 'SPEC_OPTS' => nil }, 'bundle', 'exec', 'rake', 'spec')
13
+ expect(status.success?).to be(false)
14
+ expect(output).to match(/RSpec child failed: (signal=KILL|exit=137)/)
15
+ end
16
+ end
17
+
18
+ it 'retains the first failure details even if a later example terminates RSpec' do
19
+ Dir.mktmpdir('pwn-rake-failure-') do |home|
20
+ spec = File.join(home, 'terminated_spec.rb')
21
+ File.write(spec, "RSpec.describe('fixture') { it('fails first') { expect(1).to eq(2) }; it('terminates') { Process.kill('KILL', Process.pid) } }\n")
22
+ output, status = Open3.capture2e({ 'HOME' => home, 'SPEC' => spec, 'SPEC_OPTS' => nil }, 'bundle', 'exec', 'rake', 'spec')
23
+ expect(status.success?).to be(false)
24
+ expect(output).to include('fixture fails first', 'expected: 2', 'got: 1')
25
+ end
26
+ end
27
+
28
+ it 'keeps a bounded log and preserves successful exit after large output' do
29
+ Dir.mktmpdir('pwn-build-bound-') do |home|
30
+ script = File.expand_path('../../scripts/build_gate.rb', __dir__)
31
+ output, status = Open3.capture2e({ 'HOME' => home }, RbConfig.ruby, script, RbConfig.ruby, '-e', 'puts "x" * 2_000_000; puts "finished fixture"')
32
+ expect(status.success?).to be(true)
33
+ expect(output).to include('finished fixture')
34
+ log = File.join(home, '.pwn', 'logs', 'build-gate.log')
35
+ expect(File.size(log)).to be <= 1_048_576
36
+ expect(File.read(log)).to include('finished fixture', 'exit=0')
37
+ end
38
+ end
39
+
40
+ it 'records a directly signalled gate without inventing a summary' do
41
+ Dir.mktmpdir('pwn-build-signal-') do |home|
42
+ script = File.expand_path('../../scripts/build_gate.rb', __dir__)
43
+ output, status = Open3.capture2e({ 'HOME' => home }, RbConfig.ruby, script, RbConfig.ruby, '-e', 'Process.kill("TERM", Process.pid)')
44
+ expect(status.exitstatus).to eq(143)
45
+ expect(output).to include('signal=TERM')
46
+ expect(File.read(File.join(home, '.pwn', 'logs', 'build-gate.log'))).to include('signal=TERM')
47
+ end
48
+ end
49
+
50
+ it 'finishes recording when both terminal streams disconnect' do
51
+ Dir.mktmpdir('pwn-build-disconnect-') do |home|
52
+ script = File.expand_path('../../scripts/build_gate.rb', __dir__)
53
+ reader, writer = IO.pipe
54
+ reader.close
55
+ pid = Process.spawn({ 'HOME' => home }, RbConfig.ruby, script, RbConfig.ruby, '-e', 'warn "Failures: disconnected fixture"; exit 23', out: writer, err: writer)
56
+ writer.close
57
+ expect(Process.wait2(pid).last.exitstatus).to eq(23)
58
+ expect(File.read(File.join(home, '.pwn', 'logs', 'build-gate.log'))).to include('Failures: disconnected fixture', 'exit=23')
59
+ end
60
+ end
61
+
62
+ it 'preserves merged failure output and the gate exit status outside the terminal stream' do
63
+ Dir.mktmpdir('pwn-build-gate-') do |home|
64
+ script = File.expand_path('../../scripts/build_gate.rb', __dir__)
65
+ output, status = Open3.capture2e({ 'HOME' => home }, RbConfig.ruby, script, RbConfig.ruby, '-e', '$stdout.sync = true; puts "F.F"; warn "Failures: fixture assertion"; exit 23')
66
+ expect(status.exitstatus).to eq(23)
67
+ expect(output).to include('Failures: fixture assertion', 'exit=23', 'Build gate log:')
68
+ log = File.join(home, '.pwn', 'logs', 'build-gate.log')
69
+ expect(File.read(log)).to include('F.F', 'Failures: fixture assertion', 'exit=23')
70
+ expect(File.stat(log).mode & 0o777).to eq(0o600)
71
+ end
72
+ end
73
+ end
@@ -58,13 +58,7 @@ RSpec.describe 'PWN persistence stores — write → reload → delete round-tri
58
58
  expect(PWN::Cron.list.keys).not_to include(job[:id])
59
59
 
60
60
  seeded = PWN::Cron.install_defaults
61
- expect(seeded.map { |j| j[:name] }).to include(
62
- 'curriculum_practice_nightly',
63
- 'curriculum_train_weekly',
64
- 'curriculum_offline_judge',
65
- 'learning_consolidate_nightly',
66
- 'pwn_stores_lean_nightly'
67
- )
61
+ expect(seeded.map { |j| j[:name] }).to eq(['session_cleanup'])
68
62
  expect(PWN::Cron.install_defaults).to eq([]) # idempotent
69
63
  end
70
64
  end
@@ -12,6 +12,33 @@ describe PWN::AI::Agent::Policy do
12
12
  expect(described_class).to respond_to :help
13
13
  end
14
14
 
15
+ it 'serializes trajectory retention and episode append on the same permanent writer lock' do
16
+ Dir.mktmpdir do |tmp|
17
+ stub_const('PWN::AI::Agent::Policy::POLICY_FILE', File.join(tmp, 'policy.json'))
18
+ stub_const('PWN::AI::Agent::Policy::TRAJECTORY_FILE', File.join(tmp, 'policy_traj.jsonl'))
19
+ File.write(described_class::TRAJECTORY_FILE, '')
20
+ [-> { described_class.lean! }, -> { described_class.send(:persist_episode!, episode: { session_id: 'fixture', score: nil, steps: [] }) }].each do |operation|
21
+ entered = Queue.new
22
+ thread = nil
23
+ begin
24
+ PWN::Memory.transaction(path: described_class::TRAJECTORY_FILE) do
25
+ thread = Thread.new do
26
+ entered << true
27
+ operation.call
28
+ end
29
+ entered.pop
30
+ expect(thread.join(0.1)).to be_nil
31
+ end
32
+ expect(thread.join(2)).to eq(thread)
33
+ thread.value
34
+ ensure
35
+ thread&.join(2)
36
+ end
37
+ end
38
+ expect(described_class.trajectories.length).to eq(1)
39
+ end
40
+ end
41
+
15
42
  it 'learns a better Q for a rewarded action than a punished one' do
16
43
  tmp = Dir.mktmpdir
17
44
  stub_const('PWN::AI::Agent::Policy::POLICY_FILE', File.join(tmp, 'policy.json'))
@@ -37,7 +37,7 @@ describe PWN::AI::Router do
37
37
  expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
38
38
  expect(PWN::AI::OpenAI).not_to receive(:obtain_oauth_bearer_token)
39
39
  allow(PWN::AI::OpenAI).to receive(:chat).and_raise(IOError, 'offline provider')
40
- expect(described_class.summarize(text: 'Conversation', llm: true, session: true)).to eq('')
40
+ expect { described_class.summarize(text: 'Conversation', llm: true, session: true) }.to raise_error(IOError, 'offline provider')
41
41
  expect(Thread.current[:pwn_session_title]).to be_nil
42
42
  PWN::Env[:ai][:openai].delete(:key)
43
43
  expect(PWN::AI::OpenAI).not_to receive(:chat)
@@ -52,11 +52,39 @@ describe PWN::AI::Router do
52
52
  expect(mod).not_to receive(:obtain_oauth_bearer_token)
53
53
  expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
54
54
  expect(PWN::Plugins::TransparentBrowser).not_to receive(:open)
55
- expect(described_class.summarize(text: 'Conversation', llm: true, session: true)).to eq('')
55
+ if engine == 'anthropic'
56
+ # This adapter parses a nil transport result; preserve that failure
57
+ # rather than disguising it as a downstream title JSON parse error.
58
+ expect { described_class.summarize(text: 'Conversation', llm: true, session: true) }.to raise_error(TypeError)
59
+ else
60
+ expect(described_class.summarize(text: 'Conversation', llm: true, session: true)).to eq('')
61
+ end
56
62
  expect(Thread.current[:pwn_session_title]).to be_nil
57
63
  end
58
64
  end
59
65
 
66
+ it 'extracts supported symbol and string keyed wrapper content without reasoning or tool data' do
67
+ stub_const('PWN::Env', { ai: { active: :openai, openai: { model: 'fixture', key: 'fixture-key' } } })
68
+ body = JSON.generate(title: 'Certificate repair', summary: 'Repaired login certificates.')
69
+ payloads = [
70
+ { choices: [{ message: { role: 'assistant', content: body } }] },
71
+ { 'choices' => [{ 'role' => 'assistant', 'content' => body }] },
72
+ { 'content' => [{ 'type' => 'thinking', 'thinking' => 'not output' }, { 'type' => 'text', 'text' => body }] },
73
+ { choices: [{ content: [{ type: 'output_text', text: body }] }] },
74
+ { message: { role: 'assistant', content: body } },
75
+ { title: 'Certificate repair', summary: 'Repaired login certificates.' }
76
+ ]
77
+ payloads.each do |payload|
78
+ allow(PWN::AI::OpenAI).to receive(:chat).and_return(payload)
79
+ expect(described_class.summarize(text: 'Fixture', llm: true, session: true)).to eq(body)
80
+ end
81
+ [{ content: [{ type: 'tool_use', input: { title: 'Wrong', summary: 'Wrong' } }] },
82
+ { choices: [{ role: 'user', content: body }] }, { content: { title: 'Wrong' } }].each do |payload|
83
+ allow(PWN::AI::OpenAI).to receive(:chat).and_return(payload)
84
+ expect(described_class.summarize(text: 'Fixture', llm: true, session: true)).to eq('')
85
+ end
86
+ end
87
+
60
88
  it 'should display information for authors' do
61
89
  expect(described_class).to respond_to :authors
62
90
  end
@@ -34,6 +34,48 @@ describe PWN::Cron do
34
34
  FileUtils.remove_entry(@tmp) if @tmp && Dir.exist?(@tmp)
35
35
  end
36
36
 
37
+ it 'removes only a schedule without native effects when explicitly requested' do
38
+ target = described_class.create(name: 'curriculum_practice_nightly', enabled: false, sync_native: false)
39
+ other = described_class.create(name: 'custom', enabled: false, sync_native: false)
40
+ log = File.join(@tmp, "#{target[:id]}.log")
41
+ File.write(log, 'evidence')
42
+ expect(described_class).not_to receive(:remove_native_job)
43
+ described_class.remove(id: target[:id], sync_native: false)
44
+ expect(described_class.list).to eq(other[:id] => other)
45
+ expect(File.read(log)).to eq('evidence')
46
+ end
47
+
48
+ it 'does not resurrect a schedule removed during execution or erase concurrent creations' do
49
+ target = described_class.create(name: 'running', ruby: '42', sync_native: false)
50
+ allow(described_class).to receive(:eval) do
51
+ described_class.remove(id: target[:id], sync_native: false)
52
+ described_class.create(name: 'concurrent', ruby: '43', sync_native: false)
53
+ 42
54
+ end
55
+ described_class.run(id: target[:id])
56
+ expect(described_class.list.values.map { |row| row[:name] }).to eq(['concurrent'])
57
+ end
58
+
59
+ it 'refuses destructive registry updates when existing YAML is corrupt' do
60
+ File.write(described_class.jobs_file, '--- [broken')
61
+ before = File.binread(described_class.jobs_file)
62
+ expect { described_class.remove(id: 'absent', sync_native: false) }.to raise_error(IOError)
63
+ expect(File.binread(described_class.jobs_file)).to eq(before)
64
+ end
65
+
66
+ it 'preserves concurrent process creations with atomic registry readback' do
67
+ count = 4
68
+ pids = Array.new(count) do |index|
69
+ fork do
70
+ 5.times { |n| described_class.create(name: "process-#{index}-#{n}", ruby: '42', enabled: false, sync_native: false) }
71
+ exit! 0
72
+ end
73
+ end
74
+ pids.each { |pid| expect(Process.wait2(pid).last.success?).to be(true) }
75
+ expect(described_class.list.length).to eq(count * 5)
76
+ expect(Dir.glob("#{described_class.jobs_file}.*.tmp")).to be_empty
77
+ end
78
+
37
79
  it 'exposes worker control methods' do
38
80
  %i[
39
81
  due? due_jobs run_due tick worker_loop
@@ -155,9 +197,16 @@ describe PWN::Cron do
155
197
  expect(res[:started]).to be true
156
198
  end
157
199
  end
200
+ end
201
+
202
+ describe PWN::Cron do
158
203
  describe 'OS-agnostic scheduler' do
159
204
  before do
160
- @tmp = Dir.mktmpdir('pwn-cron-os-spec-')
205
+ # install_defaults owns siblings of cron_dir (maintenance/cache/tmp).
206
+ # Give it a complete private root, never the shared Dir.tmpdir parent.
207
+ @root = Dir.mktmpdir('pwn-cron-os-spec-')
208
+ @tmp = File.join(@root, 'cron')
209
+ FileUtils.mkdir_p(@tmp)
161
210
  stub_const('PWN::Cron::CRON_DIR', @tmp)
162
211
  stub_const('PWN::Cron::JOBS_FILE', File.join(@tmp, 'jobs.yml'))
163
212
  stub_const('PWN::Cron::PID_FILE', File.join(@tmp, 'worker.pid'))
@@ -167,7 +216,53 @@ describe PWN::Cron do
167
216
 
168
217
  after do
169
218
  ENV.delete('PWN_CRON_DIR')
170
- FileUtils.remove_entry(@tmp) if @tmp && Dir.exist?(@tmp)
219
+ FileUtils.remove_entry(@root) if @root && Dir.exist?(@root)
220
+ end
221
+
222
+ it 'keeps default housekeeping off every native installer' do
223
+ allow(described_class).to receive(:scheduler_backend).and_return(:systemd_user)
224
+ expect(described_class).not_to receive(:systemd_user)
225
+ described_class.install_defaults
226
+ expect(File.file?(File.join(@root, 'maintenance', 'policy.json'))).to be(true)
227
+ job = described_class.list.values.find { |row| row[:name] == 'session_cleanup' }
228
+ %i[install_native_job install_systemd_user_job install_crontab_job install_launchd_job install_schtasks_job].each do |method|
229
+ expect(described_class.public_send(method, job: job, apply: true)).to include(skipped: true, reason: :housekeeping_internal_only)
230
+ end
231
+ expect(Dir.glob(File.join(@tmp, 'native', "*#{job[:id]}*"))).to be_empty
232
+ end
233
+
234
+ it 'setup uses only the existing internal worker even with systemd available' do
235
+ allow(described_class).to receive(:systemd_user_available?).and_return(true)
236
+ expect(described_class).not_to receive(:install_scheduler)
237
+ expect(described_class).not_to receive(:uninstall_scheduler)
238
+ expect(described_class).not_to receive(:install_native_job)
239
+ allow(described_class).to receive(:worker_status).and_return({ running: false }, { running: true, pid: 123 })
240
+ expect(described_class).to receive(:ensure_worker).with(restart: false, interval: nil).once.and_return(started: true, pid: 123)
241
+ expect(PWN::Setup.ensure_cron(io: StringIO.new)).to include(ok: true, backend: :worker)
242
+ end
243
+
244
+ it 'preserves custom cron bytes and runs housekeeping identically by tick and manual run' do
245
+ custom = described_class.create(name: 'operator-custom', schedule: '12 9 * * *', ruby: '41', enabled: false)
246
+ described_class.install_defaults
247
+ expect(described_class.list[custom[:id]]).to eq(custom)
248
+ jobs = described_class.list
249
+ jobs.each_value { |job| job[:enabled] = false }
250
+ job = jobs.values.find { |row| row[:name] == 'session_cleanup' }
251
+ job.merge!(enabled: true, schedule: 'every 1s', ruby: 'PWN::SessionCleanup.maintain(dry_run: false)')
252
+ File.write(described_class.jobs_file, YAML.dump(jobs))
253
+ expect(PWN::SessionCleanup).to receive(:maintain).with(dry_run: false).twice.and_return(deleted: 0)
254
+ expect(described_class.run(id: job[:id])).to include(status: 'success', result: { deleted: 0 })
255
+ expect(described_class.tick(now: Time.now + 2)).to contain_exactly(include(id: job[:id], status: 'success'))
256
+ expect(described_class.list[custom[:id]]).to eq(custom)
257
+ end
258
+
259
+ it 'native worker markers exclude housekeeping while leaving custom jobs runnable' do
260
+ allow(ENV).to receive(:[]).and_call_original
261
+ allow(ENV).to receive(:[]).with('PWN_CRON_NATIVE').and_return('1')
262
+ house = described_class.create(name: 'session_cleanup', ruby: 'raise "must not run"')
263
+ custom = described_class.create(name: 'operator-custom', schedule: 'every 1s', ruby: '41')
264
+ expect(described_class.tick).to contain_exactly(include(id: custom[:id], status: 'success'))
265
+ expect(described_class.list[house[:id]][:last_run]).to be_nil
171
266
  end
172
267
 
173
268
  it 'exposes scheduler control methods' do
@@ -178,12 +273,12 @@ describe PWN::Cron do
178
273
  ].each do |m|
179
274
  expect(PWN::Cron).to respond_to(m)
180
275
  end
181
- expect(PWN::Cron::DEFAULT_JOBS.length).to eq(6)
276
+ expect(PWN::Cron::DEFAULT_JOBS.length).to eq(1)
182
277
  names = PWN::Cron::DEFAULT_JOBS.map { |j| j[:name] }
183
- expect(names).to include('pwn_stores_lean_nightly')
184
- expect(names).to include('learning_consolidate_nightly')
278
+ expect(names).to eq(['session_cleanup'])
185
279
  enabled = PWN::Cron::DEFAULT_JOBS.reject { |j| j[:enabled] == false }.map { |j| j[:name] }
186
- expect(enabled).to contain_exactly('pwn_stores_lean_nightly', 'learning_consolidate_nightly', 'session_cleanup')
280
+ expect(enabled).to contain_exactly('session_cleanup')
281
+ expect(PWN::Cron::CURRICULUM_JOBS.length).to eq(3)
187
282
  end
188
283
 
189
284
  it 'picks a native backend per OS without consulting live daemons' do
@@ -237,6 +332,7 @@ describe PWN::Cron do
237
332
  expect(body).to include('<key>KeepAlive</key>')
238
333
  PWN::Cron.list.each_value do |job|
239
334
  next unless job[:enabled]
335
+ next if described_class.send(:housekeeping_job?, job: job)
240
336
 
241
337
  plist = File.join(@tmp, 'native', "com.0dayinc.pwn.cron.job.#{job[:id]}.plist")
242
338
  expect(File).to exist(plist)
@@ -247,6 +343,15 @@ describe PWN::Cron do
247
343
  end
248
344
  end
249
345
 
346
+ it 'rejects housekeeping execution from a systemd invocation without evaluating the payload' do
347
+ job = described_class.create(name: 'session_cleanup', ruby: 'raise "must not run"')
348
+ allow(File).to receive(:read).and_call_original
349
+ allow(File).to receive(:read).with('/proc/self/cgroup').and_return('0::/user.slice/pwn-cron-worker.service')
350
+ expect(described_class.run(id: job[:id])).to include(skipped: true, reason: :housekeeping_internal_only)
351
+ expect(described_class.list[job[:id]][:last_run]).to be_nil
352
+ expect(described_class.tick).to eq([])
353
+ end
354
+
250
355
  it 'systemd backend emits OnCalendar that matches each default schedule' do
251
356
  PWN::Cron.install_defaults
252
357
  res = PWN::Cron.install_scheduler(backend: :systemd_user, apply: false)
@@ -256,6 +361,7 @@ describe PWN::Cron do
256
361
  expect(unit).to include('Restart=always')
257
362
  PWN::Cron.list.each_value do |job|
258
363
  next unless job[:enabled]
364
+ next if described_class.send(:housekeeping_job?, job: job)
259
365
 
260
366
  tmr = File.join(@tmp, 'native', "pwn-cron-j#{job[:id]}.timer")
261
367
  expect(File).to exist(tmr)
@@ -273,6 +379,7 @@ describe PWN::Cron do
273
379
  expect(worker).to include('PWN::Cron.ensure_worker')
274
380
  PWN::Cron.list.each_value do |job|
275
381
  next unless job[:enabled]
382
+ next if described_class.send(:housekeeping_job?, job: job)
276
383
 
277
384
  preview = File.read(File.join(@tmp, 'native', "job-#{job[:id]}.crontab"))
278
385
  expect(preview).to include(job[:schedule])