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/migrate.rb CHANGED
@@ -4,6 +4,9 @@ require 'json'
4
4
  require 'yaml'
5
5
  require 'fileutils'
6
6
  require 'time'
7
+ require 'digest'
8
+ require 'find'
9
+ require 'securerandom'
7
10
 
8
11
  module PWN
9
12
  # PWN::Migrate — the ~/.pwn state **doctor** and **auto-migrator**.
@@ -49,7 +52,7 @@ module PWN
49
52
  # Bump this whenever the shape of any file under ~/.pwn changes in a
50
53
  # way that requires a one-time transform. Add the transform as an
51
54
  # entry in MIGRATIONS keyed by the NEW schema number.
52
- SCHEMA_VERSION = 10
55
+ SCHEMA_VERSION = 11
53
56
 
54
57
  OK = "\e[32mok\e[0m"
55
58
  BAD = "\e[31mFAIL\e[0m"
@@ -269,6 +272,10 @@ module PWN
269
272
  10 => lambda { |_root, io|
270
273
  io.puts ' · pwn.yaml ai reasoning_effort for anthropic, gemini, ollama, openwebui'
271
274
  PWN::Migrate.backfill_vault(io: io)
275
+ },
276
+ 11 => lambda { |_root, io|
277
+ io.puts ' · versioned housekeeping policy and managed session_cleanup helper'
278
+ PWN::Cron.install_defaults
272
279
  }
273
280
  }.freeze
274
281
 
@@ -383,14 +390,17 @@ module PWN
383
390
  backup = opts.fetch(:backup, true)
384
391
  io = opts[:io] || $stdout
385
392
 
386
- FileUtils.mkdir_p(PWN_ROOT)
393
+ FileUtils.mkdir_p(PWN_ROOT) unless dry_run
387
394
  before = status
388
395
  io.puts "PWN::Migrate — pwn #{before[:pwn_version]} · ~/.pwn schema " \
389
396
  "#{before[:schema_installed]} → #{before[:schema_current]}" \
390
397
  "#{' (dry-run)' if dry_run}"
391
398
 
399
+ drift = fix ? vault_drift(key: opts[:key], iv: opts[:iv]) : nil
400
+ changes = before[:schema_installed] < SCHEMA_VERSION ||
401
+ (fix && (!before[:incompatible].empty? || (drift && !drift[:missing].empty?)))
392
402
  bpath = nil
393
- if backup && !dry_run && File.directory?(PWN_ROOT)
403
+ if backup && changes && !dry_run && File.directory?(PWN_ROOT)
394
404
  bpath = backup!
395
405
  io.puts " backup : #{bpath}"
396
406
  end
@@ -422,7 +432,7 @@ module PWN
422
432
  fixed << vd if vd && vd[:added].to_i.positive?
423
433
  end
424
434
 
425
- stamp! unless dry_run
435
+ stamp! if !dry_run && changes
426
436
 
427
437
  after = status
428
438
  io.puts
@@ -612,19 +622,277 @@ module PWN
612
622
  "dropped #{bad} bad line(s), kept #{good.length}"
613
623
  end
614
624
 
615
- private_class_method def self.backup!
616
- ts = Time.now.utc.strftime('%Y%m%d_%H%M%S')
617
- dst = File.join(BACKUP_ROOT, ts)
618
- FileUtils.mkdir_p(dst)
619
- Dir.children(PWN_ROOT).each do |c|
620
- next if %w[backup quarantine sessions swarm].include?(c) # large / regenerable
625
+ # Supported Method Parameters::
626
+ # PWN::Migrate.prune_backups(dry_run: true, keep: 3, limit: 10, admission: nil)
627
+ # Default duplicate-only pruning is nondestructive to unique history.
628
+ # Explicit expire_history policy bounds recovery generations instead.
629
+ public_class_method def self.prune_backups(opts = {})
630
+ return expire_backup_generations(opts) if opts[:expire_history] || backup_retention_policy['expire_history']
631
+
632
+ dry_run = opts.fetch(:dry_run, true)
633
+ keep = [Integer(opts.fetch(:keep, 3)), 3].max
634
+ limit = Integer(opts.fetch(:limit, 10))
635
+ admission = opts[:admission]
636
+ result = { eligible: [], deleted: [], retained: [], incomplete: false }
637
+ return result unless File.directory?(BACKUP_ROOT)
638
+ raise IOError, 'Unsafe backup root' unless File.realpath(BACKUP_ROOT) == BACKUP_ROOT && File.stat(BACKUP_ROOT).uid == Process.uid
639
+
640
+ work = lambda do
641
+ names = Dir.children(BACKUP_ROOT).grep(/\A\d{8}_\d{6}(?:_[a-f0-9]{8})?\z/).sort
642
+ retained = names.last(keep)
643
+ manifests = retained.to_h { |name| [name, backup_inventory(path: File.join(BACKUP_ROOT, name), admission: admission)] }
644
+ names[0...-keep].to_a.last(limit).reverse_each do |name|
645
+ path = File.join(BACKUP_ROOT, name)
646
+ inventory = backup_inventory(path: path, admission: admission)
647
+ match = manifests.find { |_other, rows| rows == inventory }
648
+ unless match
649
+ result[:retained] << name
650
+ next
651
+ end
652
+ result[:eligible] << name
653
+ next if dry_run
621
654
 
622
- src = File.join(PWN_ROOT, c)
623
- FileUtils.cp_r(src, dst)
624
- rescue StandardError
625
- next
655
+ canonical = File.join(BACKUP_ROOT, match.first)
656
+ raise IOError, 'Backup changed before pruning' unless backup_inventory(path: canonical, admission: admission) == inventory && backup_inventory(path: path, admission: admission) == inventory
657
+
658
+ FileUtils.remove_entry_secure(path)
659
+ raise IOError, 'Backup pruning failed' if File.exist?(path)
660
+
661
+ result[:deleted] << name
662
+ end
663
+ result
664
+ end
665
+ return work.call if dry_run
666
+
667
+ File.open(File.join(BACKUP_ROOT, '.retention.lock'), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
668
+ raise IOError, 'Backup maintenance active' unless lock.flock(File::LOCK_EX | File::LOCK_NB)
669
+
670
+ work.call
671
+ end
672
+ rescue IOError => e
673
+ result[:incomplete] = true
674
+ result[:error] = e.message
675
+ result
676
+ end
677
+
678
+ # Explicit opt-in expires historical versions, not merely duplicate bytes.
679
+ private_class_method def self.backup_retention_policy
680
+ path = File.join(BACKUP_ROOT, '.retention-policy.json')
681
+ return { 'keep' => 3, 'pins' => [], 'expire_history' => false } unless File.exist?(path) || File.symlink?(path)
682
+ raise IOError, 'Unsafe retention policy' unless File.realpath(path) == path && File.stat(path).uid == Process.uid
683
+
684
+ value = JSON.parse(File.binread(path))
685
+ valid = value['version'] == 1 && [true, false].include?(value['expire_history']) && value['keep'].is_a?(Integer) && value['keep'] >= 3 && value['pins'].is_a?(Array)
686
+ valid &&= value['pins'].all? { |name| name.is_a?(String) && name.match?(/\A\d{8}_\d{6}(?:_[a-f0-9]{8})?\z/) }
687
+ raise IOError, 'Invalid retention policy' unless valid
688
+
689
+ value
690
+ end
691
+
692
+ private_class_method def self.backup_input_names
693
+ (STATE_FILES.keys.map { |rel| rel.delete_suffix('/') } + %w[.schema pwn.yaml.decryptor]).uniq.reject { |rel| %w[sessions swarm].include?(rel.split('/').first) }
694
+ end
695
+
696
+ # Conservative literal-path protection in current evidence/continuation stores.
697
+ # Historical log/history/metrics copies are not operational dependencies.
698
+ private_class_method def self.backup_reference_pins(opts = {})
699
+ admission = opts[:admission]
700
+ pins = []
701
+ roots = %w[learning learning.jsonl memory.json mistakes.json policy.json policy_traj.jsonl preferences.jsonl engagements artifacts sessions swarm jobs runs open_goals open_goal.json curriculum finetune extrospection skill_review.jsonl maintenance]
702
+ roots.each do |name|
703
+ path = File.join(PWN_ROOT, name)
704
+ next unless File.exist?(path) || File.symlink?(path)
705
+
706
+ Find.find(path) do |file|
707
+ stat = File.lstat(file)
708
+ raise IOError, 'Unsafe current evidence entry' unless stat.file? || stat.directory?
709
+ next unless stat.file? && file.match?(/\.jsonl?\z/)
710
+ raise IOError, 'Backup work budget exhausted' if admission && !admission.call(stat.size + 1)
711
+
712
+ documents = file.end_with?('.jsonl') ? File.foreach(file).reject { |line| line.strip.empty? }.map { |line| JSON.parse(line) } : [JSON.parse(File.binread(file))]
713
+ documents.select! { |row| row.is_a?(Hash) && (%w[prepared interrupted].include?(row['state']) || row['pinned'] == true) } if name == 'maintenance'
714
+ until documents.empty?
715
+ value = documents.pop
716
+ case value
717
+ when Hash then documents.concat(value.values)
718
+ when Array then documents.concat(value)
719
+ when String
720
+ match = value.match(%r{\A(?:#{Regexp.escape(BACKUP_ROOT)}/|backup/)(\d{8}_\d{6}(?:_[a-f0-9]{8})?)(?:/|\z)})
721
+ pins << match[1] if match
722
+ end
723
+ end
724
+ end
725
+ end
726
+ pins.uniq
727
+ end
728
+
729
+ private_class_method def self.expire_backup_generations(opts = {})
730
+ require 'zlib'
731
+ result = { eligible: [], deleted: [], retained: [], incomplete: false, permanent_history_expiry: true }
732
+ return result unless File.directory?(BACKUP_ROOT)
733
+ raise IOError, 'Unsafe backup root' unless File.realpath(PWN_ROOT) == PWN_ROOT && File.realpath(BACKUP_ROOT) == BACKUP_ROOT && File.stat(BACKUP_ROOT).uid == Process.uid
734
+
735
+ dry_run = opts.fetch(:dry_run, true)
736
+ admission = opts[:admission]
737
+ policy = backup_retention_policy
738
+ keep = [Integer(opts.fetch(:keep, policy['keep'])), 3].max
739
+ limit = Integer(opts.fetch(:limit, 10))
740
+ raise ArgumentError, 'Invalid retention limit' unless limit.positive?
741
+
742
+ operation = lambda do
743
+ names = Dir.children(BACKUP_ROOT).grep(/\A\d{8}_\d{6}(?:_[a-f0-9]{8})?\z/).sort
744
+ raise IOError, 'Pinned backup missing' unless (policy['pins'] - names).empty?
745
+
746
+ if (names - (names.last(keep) + policy['pins'])).empty?
747
+ result[:retained] = names
748
+ result[:pins] = policy['pins']
749
+ result[:evidence_scan] = 'not_needed_no_expiry'
750
+ return result
751
+ end
752
+
753
+ pins = (policy['pins'] + backup_reference_pins(admission: admission)).uniq
754
+ raise IOError, 'Pinned backup missing' unless (pins - names).empty?
755
+
756
+ retained = (names.last(keep) + pins).uniq.sort
757
+ result[:retained] = retained
758
+ result[:pins] = pins
759
+ return result if (names - retained).empty?
760
+ raise IOError, 'Insufficient recovery generations' if retained.length < keep
761
+
762
+ current = backup_inventory(path: PWN_ROOT, names: backup_input_names, admission: admission)
763
+ inventories = retained.to_h { |name| [name, backup_inventory(path: File.join(BACKUP_ROOT, name), admission: admission)] }
764
+ newest = inventories.fetch(names.last)
765
+ raise IOError, 'Current migration input coverage missing' unless current.all? { |path, row| newest[path] && newest[path].first == row.first }
766
+
767
+ %w[pwn.yaml pwn.yaml.decryptor].each do |name|
768
+ raise IOError, 'Current encrypted configuration recovery missing' if current.key?(name) && newest[name] != current[name]
769
+ end
770
+ result[:coverage] = { current_paths: current.length, newest: names.last, legacy_completeness_certified: false, restore: 'copy migration-owned paths only; never overlay entire legacy trees or edit shared inodes' }
771
+ candidates = (names - retained).first(limit)
772
+ expired = candidates.to_h { |name| [name, backup_inventory(path: File.join(BACKUP_ROOT, name), admission: admission)] }
773
+ result[:eligible] = candidates
774
+ return result if dry_run
775
+
776
+ # Recheck everything before publishing the single private metadata ledger.
777
+ raise IOError, 'Current migration input changed' unless current == backup_inventory(path: PWN_ROOT, names: backup_input_names, admission: admission)
778
+ raise IOError, 'Backup references changed' unless pins == (policy['pins'] + backup_reference_pins(admission: admission)).uniq
779
+
780
+ inventories.merge(expired).each do |name, rows|
781
+ raise IOError, 'Backup changed before expiry' unless rows == backup_inventory(path: File.join(BACKUP_ROOT, name), admission: admission)
782
+ end
783
+ ledger = File.join(BACKUP_ROOT, '.retention-manifest.json.gz')
784
+ raise IOError, 'Unsafe retention manifest' if File.symlink?(ledger)
785
+
786
+ record = { version: 1, state: 'prepared', policy: policy, coverage: result[:coverage], retained: inventories, expiring: expired, deleted: [] }
787
+ verification_bytes = inventories.values.sum { |rows| rows.values.sum { |row| row.first == 'file' ? row[4] + 1 : 0 } }
788
+ reserved_bytes = (JSON.generate(record).bytesize * 2) + verification_bytes
789
+ raise IOError, 'Backup commit budget exhausted' if admission && !admission.call(reserved_bytes)
790
+
791
+ publish_backup_retention(path: ledger, record: record)
792
+ candidates.each do |name|
793
+ FileUtils.remove_entry_secure(File.join(BACKUP_ROOT, name))
794
+ raise IOError, 'Backup expiry failed' if File.exist?(File.join(BACKUP_ROOT, name))
795
+
796
+ result[:deleted] << name
797
+ record[:deleted] << name
798
+ end
799
+ inventories.each do |name, rows|
800
+ raise IOError, 'Retained backup changed' unless rows == backup_inventory(path: File.join(BACKUP_ROOT, name))
801
+ end
802
+ record[:state] = 'committed'
803
+ publish_backup_retention(path: ledger, record: record)
804
+ result
805
+ end
806
+ return operation.call if dry_run
807
+
808
+ File.open(File.join(BACKUP_ROOT, '.retention.lock'), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
809
+ raise IOError, 'Backup maintenance active' unless lock.flock(File::LOCK_EX | File::LOCK_NB)
810
+
811
+ operation.call
812
+ end
813
+ rescue IOError => e
814
+ result[:incomplete] = true
815
+ result[:error] = e.message
816
+ result
817
+ end
818
+
819
+ private_class_method def self.publish_backup_retention(opts = {})
820
+ path = opts[:path]
821
+ temporary = "#{path}.#{SecureRandom.hex(8)}"
822
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
823
+ writer = Zlib::GzipWriter.new(file)
824
+ writer.write(JSON.generate(opts[:record]))
825
+ writer.finish
826
+ file.flush
827
+ file.fsync
828
+ end
829
+ File.rename(temporary, path)
830
+ File.open(BACKUP_ROOT, File::RDONLY, &:fsync)
831
+ end
832
+
833
+ private_class_method def self.backup_inventory(opts = {})
834
+ path = opts[:path]
835
+ admission = opts[:admission]
836
+ names = opts[:names]
837
+ raise IOError, 'Unsafe backup directory' unless File.realpath(path) == path && File.directory?(path)
838
+
839
+ rows = {}
840
+ Find.find(path) do |file|
841
+ next if file == path
842
+
843
+ rel = file.delete_prefix("#{path}/")
844
+ Find.prune if names && names.none? { |name| rel == name || rel.start_with?("#{name}/") || name.start_with?("#{rel}/") }
845
+ stat = File.lstat(file)
846
+ raise IOError, 'Unsafe backup entry' unless (stat.file? || stat.directory?) && stat.uid == Process.uid
847
+ next if names && names.none? { |name| rel == name || rel.start_with?("#{name}/") }
848
+ raise IOError, 'Backup work budget exhausted' if admission && !admission.call(stat.file? ? stat.size + 1 : 0)
849
+
850
+ row = [stat.ftype, stat.mode, stat.uid, stat.gid]
851
+ if stat.file?
852
+ row += [stat.size, Digest::SHA256.file(file).hexdigest]
853
+ after = File.lstat(file)
854
+ raise IOError, 'Backup changed while hashing' unless [stat.ino, stat.size, stat.mtime, stat.ctime] == [after.ino, after.size, after.mtime, after.ctime]
855
+ end
856
+ rows[rel] = row
857
+ end
858
+ rows
859
+ end
860
+
861
+ private_class_method def self.backup!
862
+ FileUtils.mkdir_p(BACKUP_ROOT, mode: 0o700)
863
+ raise IOError, 'Unsafe backup root' unless File.realpath(BACKUP_ROOT) == BACKUP_ROOT
864
+
865
+ File.open(File.join(BACKUP_ROOT, '.retention.lock'), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
866
+ lock.flock(File::LOCK_EX)
867
+ ts = "#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}_#{SecureRandom.hex(4)}"
868
+ dst = File.join(BACKUP_ROOT, ts)
869
+ partial = File.join(BACKUP_ROOT, ".partial-#{ts}")
870
+ Dir.mkdir(partial, 0o700)
871
+ # Only state owned by the migration registry. Logs, caches, artifacts,
872
+ # jobs and prior maintenance archives are not schema migration inputs.
873
+ names = backup_input_names
874
+ inventory = backup_inventory(path: PWN_ROOT, names: names)
875
+ names.each do |name|
876
+ src = File.join(PWN_ROOT, name)
877
+ next unless File.exist?(src)
878
+
879
+ target = File.join(partial, name)
880
+ FileUtils.mkdir_p(File.dirname(target), mode: 0o700)
881
+ FileUtils.cp_r(src, target, preserve: true)
882
+ end
883
+ raise IOError, 'Migration input changed during backup' unless inventory == backup_inventory(path: PWN_ROOT, names: names)
884
+ raise IOError, 'Backup verification failed' unless inventory == backup_inventory(path: partial, names: names)
885
+
886
+ manifest = JSON.generate(version: 1, complete: true, scope: names.sort, files: inventory)
887
+ File.open(File.join(partial, '.snapshot-manifest.json'), File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
888
+ file.write(manifest)
889
+ file.flush
890
+ file.fsync
891
+ end
892
+ File.rename(partial, dst)
893
+ File.open(BACKUP_ROOT, File::RDONLY, &:fsync)
894
+ dst
626
895
  end
627
- dst
628
896
  end
629
897
 
630
898
  private_class_method def self.stamp!
@@ -773,6 +1041,15 @@ module PWN
773
1041
  iv: 'optional - iv value consumed by #backfill_vault'
774
1042
  )
775
1043
 
1044
+ # Prune duplicate trees, or explicitly expire historical generations.
1045
+ #{self}.prune_backups(
1046
+ expire_history: 'optional - permanently expire old unpinned history after current recovery coverage verification; default reads backup/.retention-policy.json',
1047
+ dry_run: 'optional - preview without writes, default true',
1048
+ keep: 'optional - recent recovery points to retain, minimum three',
1049
+ limit: 'optional - maximum older snapshots examined per invocation, default ten',
1050
+ admission: 'optional - callable byte/work budget admission guard'
1051
+ )
1052
+
776
1053
  # Run env template and return its result
777
1054
  #{self}.env_template
778
1055
 
@@ -101,16 +101,17 @@ module PWN
101
101
  sha = Digest::SHA256.file(path).hexdigest
102
102
  sid = (opts[:session_id] || 'triage').to_s
103
103
  cache_dir = File.join(Dir.home, '.pwn', 'cache', 'triage')
104
- FileUtils.mkdir_p(cache_dir)
105
104
  cache = File.join(cache_dir, "#{sha}.json")
106
- if File.file?(cache)
107
- cached = JSON.parse(File.read(cache), symbolize_names: true)
105
+ require File.expand_path('../../../etc/default_cron/session_cleanup', __dir__)
106
+ cached_bytes = PWN::SessionCleanup.cache(path: cache)
107
+ if cached_bytes
108
+ cached = JSON.parse(cached_bytes, symbolize_names: true)
108
109
  spilled = persist_triage(body: cached, session_id: sid)
109
110
  return cached.merge(cached: true, handle: spilled[:handle], artifact: spilled)
110
111
  end
111
112
 
112
113
  body = inspect_binary(opts.merge(path: path, sha256: sha))
113
- File.write(cache, JSON.pretty_generate(body))
114
+ PWN::SessionCleanup.cache(path: cache, bytes: JSON.pretty_generate(body))
114
115
  facts_dir = File.join(Dir.home, '.pwn', 'engagements', 'default', 'binaries', sha)
115
116
  FileUtils.mkdir_p(facts_dir)
116
117
  File.write(File.join(facts_dir, 'facts.json'), JSON.pretty_generate(body))
@@ -37,13 +37,14 @@ module PWN
37
37
 
38
38
  sha = Digest::SHA256.file(path).hexdigest if File.file?(path)
39
39
  cache = File.join(Dir.home, '.pwn', 'cache', 'decompile', "#{sha}.json") if sha
40
- return JSON.parse(File.read(cache), symbolize_names: true).merge(cached: true) if cache && File.file?(cache)
40
+ require File.expand_path('../../../etc/default_cron/session_cleanup', __dir__)
41
+ cached = PWN::SessionCleanup.cache(path: cache) if cache
42
+ return JSON.parse(cached, symbolize_names: true).merge(cached: true) if cached
41
43
 
42
44
  row = analyze(opts)
43
45
  fn = opts[:function].to_s
44
46
  row[:function] = fn unless fn.empty?
45
- FileUtils.mkdir_p(File.dirname(cache)) if cache
46
- File.write(cache, JSON.generate(row)) if cache
47
+ PWN::SessionCleanup.cache(path: cache, bytes: JSON.generate(row)) if cache
47
48
  row
48
49
  end
49
50
 
@@ -60,8 +60,9 @@ module PWN
60
60
  log_file_path = "/tmp/pwn-ai-#{session}.json" if level == :learning
61
61
  log_file = File.open(log_file_path, 'w')
62
62
  else
63
- log_file_path = '/tmp/pwn.log'
64
- log_file = File.open(log_file_path, 'a')
63
+ require 'stringio'
64
+ log_file_path = File.join(Dir.home, '.pwn', 'logs', 'pwn.log')
65
+ log_file = StringIO.new
65
66
  end
66
67
 
67
68
  # Leave 10 "old" log files where
@@ -124,6 +125,10 @@ module PWN
124
125
  end
125
126
 
126
127
  logger.add(logger.level, PWN::Redaction.redact(value: log_event), PWN::Redaction.redact(value: which_self))
128
+ unless level == :learning
129
+ require File.expand_path('../../../etc/default_cron/session_cleanup', __dir__)
130
+ PWN::SessionCleanup.append_log(path: log_file_path, bytes: log_file.string)
131
+ end
127
132
  logger.close
128
133
  rescue Interrupt
129
134
  note_interrupt!(where: 'CTRL+C', which_self: self) if debug_enabled?
@@ -424,9 +429,18 @@ module PWN
424
429
  unless @debug_file.nil? || @debug_file.closed?
425
430
  @debug_file.puts(PWN::Redaction.token(kind: 'stream', value: @stderr_redaction_buffer)) unless @stderr_redaction_buffer.to_s.empty?
426
431
  @debug_file.close
432
+ @debug_lease&.close
433
+ @debug_lease = nil
434
+ if @debug_path.to_s.start_with?("#{File.join(Dir.home, '.pwn', 'logs')}/")
435
+ require File.expand_path('../../../etc/default_cron/session_cleanup', __dir__)
436
+ PWN::SessionCleanup.register_disposable(path: @debug_path, category: 'logs')
437
+ end
427
438
  end
428
439
  rescue StandardError
429
440
  nil
441
+ ensure
442
+ @debug_lease&.close
443
+ @debug_lease = nil
430
444
  end
431
445
  @debug_file = nil
432
446
  @stderr_redaction_buffer = ''
@@ -439,6 +453,8 @@ module PWN
439
453
 
440
454
  close_debug_file!
441
455
  FileUtils.mkdir_p(File.dirname(path))
456
+ @debug_lease = File.open("#{path}.lease", File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
457
+ @debug_lease.flock(File::LOCK_EX)
442
458
  io = File.open(path, 'a', 0o600)
443
459
  io.chmod(0o600)
444
460
  io.sync = true
@@ -250,7 +250,7 @@ module PWN
250
250
  end
251
251
  end
252
252
  PWN_AI_SLASH_COMMANDS = %w[
253
- /back /cron /debug /delegate /help /learning /memory /mcp /model /sessions /skills /steer /swarm /system-role /trace /vault
253
+ /back /cron /debug /delegate /help /learning /maintenance /memory /mcp /model /sessions /skills /steer /swarm /system-role /trace /vault
254
254
  ].freeze
255
255
 
256
256
  PWN_AI_SLASH_SUBCOMMANDS = {
@@ -265,6 +265,7 @@ module PWN
265
265
  '/model' => %w[list],
266
266
  '/swarm' => %w[mission agents status create use spawn retire dm debate broadcast tail steer pause resume cancel help],
267
267
  '/sessions' => %w[list resume delete stats cleanup],
268
+ '/maintenance' => %w[status run dry-run],
268
269
 
269
270
  '/skills' => %w[list recall],
270
271
  '/learning' => %w[list requeue]
@@ -580,6 +581,16 @@ module PWN
580
581
  end
581
582
  when '/cron'
582
583
  pwn_ai_run_cron(args: args)
584
+ when '/maintenance'
585
+ require File.expand_path('../../../../etc/default_cron/session_cleanup', __dir__)
586
+ raise ArgumentError, 'usage: /maintenance [status|run|dry-run]' unless args.empty? || (args.length == 1 && %w[status run dry-run].include?(args.first))
587
+
588
+ result = if args.empty? || args.first == 'status'
589
+ PWN::SessionCleanup.status
590
+ else
591
+ PWN::SessionCleanup.maintain(dry_run: args.first == 'dry-run', current_session_id: pi&.config&.pwn_ai_session_id)
592
+ end
593
+ pwn_ai_print_result(value: result)
583
594
  when '/sessions'
584
595
  identifier = request.sub(%r{\A/sessions[[:space:]]+resume(?:[[:space:]]+|$)}, '') if args.first == 'resume'
585
596
  pwn_ai_run_sessions(args: args, identifier: identifier, pry: pi)
@@ -277,6 +277,10 @@ module PWN
277
277
  class Control < PWN::AI::Agent::Loop::Steering
278
278
  class Stopped < Exception; end # rubocop:disable Lint/InheritException
279
279
 
280
+ def stopping?
281
+ @stopping == true
282
+ end
283
+
280
284
  def stop
281
285
  @stopping = true
282
286
  request_model_cancel
@@ -741,7 +745,7 @@ module PWN
741
745
  end
742
746
  # Maintenance is one safe transaction: never label its committed
743
747
  # deletions cancelled merely because stop was requested mid-write.
744
- if request.match?(%r{\A/sessions\s+cleanup(?:\s|$)})
748
+ if request.match?(%r{\A/(?:sessions\s+cleanup|maintenance)(?:\s|$)})
745
749
  @events << [:notice, 'Session cleanup finished; completed work is not undone.']
746
750
  else
747
751
  control.checkpoint(messages: [])
@@ -848,7 +852,7 @@ module PWN
848
852
  open_vault_edit(line)
849
853
  elsif line.strip == '/system-role'
850
854
  open_system_role
851
- elsif line.match?(%r{\A/(?:mcp(?:\s|$)|cron\s+run(?:\s|$)|model(?:\s|$)|sessions\s+cleanup(?:\s|$))})
855
+ elsif line.match?(%r{\A/(?:maintenance(?:\s|$)|mcp(?:\s|$)|cron\s+run(?:\s|$)|model(?:\s|$)|sessions\s+cleanup(?:\s|$))})
852
856
  start_request(line, command: true)
853
857
  elsif line.start_with?('/') || line.start_with?('ai.profile ', 'ai.memory ')
854
858
  if line.match?(%r{\A/sessions[[:space:]]+resume(?:[[:space:]]|$)})
@@ -164,7 +164,7 @@ module PWN
164
164
  when 'create' then create(tokens.join(' '))
165
165
  when 'use' then use(tokens.first)
166
166
  when 'spawn' then spawn(tokens)
167
- when 'retire' then retire(tokens.first)
167
+ when 'retire' then retire(tokens.first, continuation: tokens.include?('--continuation'))
168
168
  when 'dm' then ask(tokens, opts)
169
169
  when 'mission' then solve(tokens, opts)
170
170
  when 'debate' then debate(tokens, opts)
@@ -180,7 +180,7 @@ module PWN
180
180
  def help_text
181
181
  {
182
182
  ok: true,
183
- usage: '/swarm mission REQUEST|agents|status [JOB]|create [topic]|use ID|spawn NAME ROLE [--engine E --model M --toolsets a,b]|retire NAME|dm NAME REQUEST|debate NAMES TOPIC [--rounds N]|broadcast REQUEST [--names a,b]|tail [N]|steer JOB INSTRUCTION|pause JOB|resume JOB|cancel JOB|all'
183
+ usage: '/swarm mission REQUEST|agents|status [JOB]|create [topic]|use ID|spawn NAME ROLE [--engine E --model M --toolsets a,b]|retire NAME [--continuation]|dm NAME REQUEST|debate NAMES TOPIC [--rounds N]|broadcast REQUEST [--names a,b]|tail [N]|steer JOB INSTRUCTION|pause JOB|resume JOB|cancel JOB|all'
184
184
  }
185
185
  end
186
186
 
@@ -215,10 +215,10 @@ module PWN
215
215
  { ok: true, persona: result[:name], swarm_id: @swarm_id, scope: :swarm }
216
216
  end
217
217
 
218
- def retire(name)
218
+ def retire(name, continuation: false)
219
219
  return { ok: false, error: 'invalid persona name' } unless identifier?(name)
220
220
 
221
- result = PWN::AI::Agent::Swarm.retire(name: name, swarm_id: @swarm_id, local: true)
221
+ result = PWN::AI::Agent::Swarm.retire(name: name, swarm_id: @swarm_id, local: true, continuation: continuation)
222
222
  { ok: result[:removed], scope: result[:scope], name: name }
223
223
  end
224
224