pwn 0.5.782 → 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 (64) 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 +280 -15
  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/documentation/pwn-REPL.md +31 -3
  15. data/documentation/pwn-ai-Agent.md +62 -2
  16. data/etc/default_cron/session_cleanup.rb +1544 -44
  17. data/lib/pwn/ai/agent/curriculum.rb +2 -2
  18. data/lib/pwn/ai/agent/extrospection.rb +6 -7
  19. data/lib/pwn/ai/agent/learning.rb +59 -11
  20. data/lib/pwn/ai/agent/mistakes.rb +22 -3
  21. data/lib/pwn/ai/agent/policy.rb +5 -0
  22. data/lib/pwn/ai/agent/swarm.rb +31 -16
  23. data/lib/pwn/ai/agent/tools/sessions.rb +6 -6
  24. data/lib/pwn/ai/http_retry.rb +3 -0
  25. data/lib/pwn/ai/router.rb +48 -3
  26. data/lib/pwn/cron.rb +120 -49
  27. data/lib/pwn/memory.rb +62 -6
  28. data/lib/pwn/migrate.rb +292 -15
  29. data/lib/pwn/plugins/binary_parser.rb +5 -4
  30. data/lib/pwn/plugins/ghidra.rb +4 -3
  31. data/lib/pwn/plugins/log.rb +18 -2
  32. data/lib/pwn/plugins/repl/ai.rb +25 -4
  33. data/lib/pwn/plugins/repl/ai_console.rb +41 -21
  34. data/lib/pwn/plugins/repl/ai_swarm.rb +4 -4
  35. data/lib/pwn/session_trace.rb +1 -1
  36. data/lib/pwn/sessions.rb +359 -92
  37. data/lib/pwn/setup.rb +9 -17
  38. data/lib/pwn/version.rb +1 -1
  39. data/scripts/build_gate.rb +58 -0
  40. data/spec/integration/build_gate_spec.rb +73 -0
  41. data/spec/integration/persistence_roundtrip_spec.rb +1 -7
  42. data/spec/lib/pwn/ai/agent/learning_spec.rb +65 -2
  43. data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
  44. data/spec/lib/pwn/ai/agent/swarm_spec.rb +3 -3
  45. data/spec/lib/pwn/ai/router_spec.rb +30 -2
  46. data/spec/lib/pwn/cron_spec.rb +113 -6
  47. data/spec/lib/pwn/memory_spec.rb +58 -1
  48. data/spec/lib/pwn/migrate_spec.rb +160 -5
  49. data/spec/lib/pwn/plugins/log_spec.rb +7 -0
  50. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +203 -6
  51. data/spec/lib/pwn/plugins/repl/ai_result_chronology_spec.rb +2 -2
  52. data/spec/lib/pwn/plugins/repl/ai_spec.rb +28 -11
  53. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +11 -0
  54. data/spec/lib/pwn/redaction_spec.rb +13 -10
  55. data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
  56. data/spec/lib/pwn/session_cleanup_default_spec.rb +166 -3
  57. data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
  58. data/spec/lib/pwn/session_cleanup_payload_spec.rb +800 -36
  59. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +389 -0
  60. data/spec/lib/pwn/sessions_spec.rb +228 -16
  61. data/spec/lib/pwn/setup_spec.rb +3 -1
  62. data/spec/support/gate_failure_formatter.rb +14 -0
  63. data/third_party/pwn_rdoc.jsonl +27 -1
  64. metadata +14 -7
@@ -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
@@ -637,15 +641,14 @@ module PWN
637
641
  finish_request_metrics
638
642
  @running = false
639
643
  @control = nil
640
- queue_title_update(text) if text
644
+ queue_title_update(*text) if text
641
645
  when :session_title
642
646
  @session_metadata = text if text && text[:id] == @pry.config.pwn_ai_session_id
643
647
  when :title_done
644
648
  @title_worker.join
645
649
  @title_worker = nil
646
- pending = @title_pending
647
- @title_pending = nil
648
- queue_title_update(pending) if pending
650
+ pending = @title_pending&.shift
651
+ queue_title_update(*pending) if pending
649
652
  else
650
653
  add(type, text)
651
654
  end
@@ -654,13 +657,16 @@ module PWN
654
657
  nil
655
658
  end
656
659
 
657
- def queue_title_update(session_id)
660
+ def queue_title_update(session_id, ai_config = Marshal.load(Marshal.dump(PWN::Env[:ai])))
661
+ @title_pending ||= {}
658
662
  if @title_worker
659
- @title_pending = session_id
663
+ @title_pending[session_id] = ai_config
660
664
  return
661
665
  end
662
666
  @title_worker = Thread.new do
663
- @events << [:session_title, PWN::Sessions.refresh_title(session_id: session_id)]
667
+ @events << [:session_title, PWN::Sessions.refresh_title(session_id: session_id, ai_config: ai_config, on_error: lambda { |kind|
668
+ @events << [:warning, "Session title refresh unavailable for #{session_id} (#{kind}); previous title retained. Resume this session to retry."]
669
+ })]
664
670
  ensure
665
671
  @events << [:title_done, nil]
666
672
  end
@@ -689,8 +695,9 @@ module PWN
689
695
  PWN::Plugins::Log.mirror_tui!(msg: "#{type.to_s.upcase}\n#{clean}") if @pry.config.pwn_ai_debug && type != :notice
690
696
  end
691
697
 
692
- def start_request(request, command: false)
698
+ def start_request(request, command: false, &operation)
693
699
  session_id = @pry.config.pwn_ai_session_id
700
+ title_config = Marshal.load(Marshal.dump(PWN::Env[:ai]))
694
701
  @locals = Thread.current.keys.select { |key| key.to_s.start_with?('pwn_') }.to_h { |key| [key, Thread.current[key]] }
695
702
  @thread_variables = Thread.current.thread_variables.select { |key| key.to_s.start_with?('pwn_') }.to_h { |key| [key, Thread.current.thread_variable_get(key)] }
696
703
  # A fresh forwarding pipe prevents unread input crossing requests.
@@ -725,16 +732,24 @@ module PWN
725
732
  end
726
733
  if command
727
734
  control.checkpoint(messages: [], phase: :tool)
728
- if request.match?(%r{\A/model(?:\s|$)})
735
+ if operation
736
+ operation.call
737
+ elsif request.match?(%r{\A/model(?:\s|$)})
729
738
  REPL.pwn_ai_run_model(args: request.split.drop(1), prompt: lambda { |selection|
730
739
  control.checkpoint(messages: [])
731
740
  @events << [:model_prompt, selection]
732
741
  :deferred
733
742
  })
734
743
  else
735
- REPL.pwn_ai_dispatch_slash!(request: request, pry: @pry)
744
+ REPL.pwn_ai_dispatch_slash!(request: request, pry: @pry, raise_errors: true)
745
+ end
746
+ # Maintenance is one safe transaction: never label its committed
747
+ # deletions cancelled merely because stop was requested mid-write.
748
+ if request.match?(%r{\A/(?:sessions\s+cleanup|maintenance)(?:\s|$)})
749
+ @events << [:notice, 'Session cleanup finished; completed work is not undone.']
750
+ else
751
+ control.checkpoint(messages: [])
736
752
  end
737
- control.checkpoint(messages: [])
738
753
  else
739
754
  workspace = File.realpath(Dir.pwd)
740
755
  workspace_lock = File.open(File.join(Dir.tmpdir, "pwn-solve-#{Digest::SHA256.hexdigest(workspace)}.lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen -- worker ensure owns the lease
@@ -757,7 +772,7 @@ module PWN
757
772
  @events << [:warning, "#{e.class}: #{e.message}"]
758
773
  ensure
759
774
  workspace_lock&.close
760
- @events << [:done, command ? nil : session_id]
775
+ @events << [:done, command ? nil : [session_id, title_config]]
761
776
  end
762
777
  @worker.report_on_exception = false
763
778
  @control = ready.pop
@@ -810,7 +825,7 @@ module PWN
810
825
  clear_view
811
826
  elsif ['/sessions', '/sessions picker'].include?(line.strip)
812
827
  open_operator(:sessions)
813
- elsif line.strip == '/sessions cleanup'
828
+ elsif line.strip == '/sessions cleanup --dry-run'
814
829
  open_operator(:cleanup)
815
830
  elsif line.strip == '/evidence'
816
831
  open_operator(:evidence)
@@ -837,7 +852,7 @@ module PWN
837
852
  open_vault_edit(line)
838
853
  elsif line.strip == '/system-role'
839
854
  open_system_role
840
- elsif line.match?(%r{\A/(?:mcp(?:\s|$)|cron\s+run(?:\s|$)|model(?:\s|$))})
855
+ elsif line.match?(%r{\A/(?:maintenance(?:\s|$)|mcp(?:\s|$)|cron\s+run(?:\s|$)|model(?:\s|$)|sessions\s+cleanup(?:\s|$))})
841
856
  start_request(line, command: true)
842
857
  elsif line.start_with?('/') || line.start_with?('ai.profile ', 'ai.memory ')
843
858
  if line.match?(%r{\A/sessions[[:space:]]+resume(?:[[:space:]]|$)})
@@ -845,6 +860,7 @@ module PWN
845
860
 
846
861
  REPL.pwn_ai_dispatch_slash!(request: line, pry: @pry)
847
862
  @session_metadata = PWN::Sessions.metadata(session_id: @pry.config.pwn_ai_session_id)
863
+ queue_title_update(@pry.config.pwn_ai_session_id)
848
864
  elsif !REPL.pwn_ai_dispatch_slash!(request: line, pry: @pry)
849
865
  add(:warning, 'Unknown command. Use / or Tab for commands.')
850
866
  end
@@ -2027,7 +2043,7 @@ module PWN
2027
2043
  view[:rows] = PWN::Sessions.list.sort_by { |row| [row[:mtime].to_s, row[:id]] } if view[:kind] == :sessions
2028
2044
  if view[:kind] == :cleanup
2029
2045
  require File.expand_path('../../../../etc/default_cron/session_cleanup', __dir__)
2030
- view[:plan] = PWN::SessionCleanup.run(current_session_id: session_id)
2046
+ view[:plan] = PWN::SessionCleanup.maintain(dry_run: true, current_session_id: session_id)
2031
2047
  view[:rows] = view[:plan][:records].map do |row|
2032
2048
  category = row[:skip] || row[:apply_blocked_by] || (if row[:classification] == :named
2033
2049
  :summarized
@@ -2352,7 +2368,7 @@ module PWN
2352
2368
  ['START HERE · local guide; no work is launched here.',
2353
2369
  'Write a task in MISSION CONTROL. /readiness checks routes, tools and workspace locally.',
2354
2370
  'Ctrl+S opens the team workspace. v previews your exact mission draft before confirmation.',
2355
- '/evidence inspects actual checks and revisions. /sessions finds stable IDs; /sessions cleanup previews retention.',
2371
+ '/evidence inspects actual checks and revisions. /sessions cleanup runs maintenance; add --dry-run to preview.',
2356
2372
  '/learning inbox reviews retained evidence. /steer queues guidance; Ctrl+C requests cancellation, not undo.',
2357
2373
  '1 Insert: Inspect this workspace and explain its entry points without changing files.',
2358
2374
  '2 Insert: Add a regression test for the reported bug, fix it, and execute the test.',
@@ -2390,13 +2406,17 @@ module PWN
2390
2406
  end
2391
2407
 
2392
2408
  def apply_operator_cleanup
2409
+ return add(:warning, 'Maintenance waits until the active request or swarm finishes.') if busy?
2410
+
2393
2411
  view = @operator_view
2394
- approved = view[:plan][:records].select { |row| view[:plan][:candidate_ids].include?(row[:id]) }.to_h { |row| [row[:id], row[:files]] }
2395
- result = PWN::SessionCleanup.run(dry_run: false, current_session_id: @pry.config.pwn_ai_session_id, approved_candidates: approved)
2412
+ approved = view[:plan][:records].select { |row| view[:plan][:candidate_ids].include?(row[:id]) }.to_h { |row| [row[:id], { files: row[:files], dependency_records: row[:dependency_records] }] }
2413
+ session_id = @pry.config.pwn_ai_session_id
2396
2414
  view.delete(:confirm)
2397
- view[:notice] = "Deleted: #{result[:deleted_ids].join(', ')}; backup: #{result[:archive_dir] || 'none'}; skipped: #{result[:skipped]}"
2398
- add(:command, REPL.pwn_ai_result_text(value: result))
2399
- refresh_operator
2415
+ @operator_view = nil
2416
+ start_request('/sessions cleanup (approved preview)', command: true) do
2417
+ result = PWN::SessionCleanup.run(dry_run: false, current_session_id: session_id, approved_candidates: approved)
2418
+ @events << [:command, REPL.pwn_ai_result_text(value: result)]
2419
+ end
2400
2420
  end
2401
2421
 
2402
2422
  def operator_viewport(page)
@@ -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
 
@@ -119,7 +119,7 @@ module PWN
119
119
  id = opts[:session_id].to_s
120
120
  raise ArgumentError, 'Invalid session id' unless id.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]{0,127}\z/)
121
121
 
122
- dir = File.join(PWN::Sessions.sessions_dir, id)
122
+ dir = File.dirname(PWN::Sessions.support_path(kind: :trace, session_id: id))
123
123
  raise ArgumentError, 'Symlinked trace directory' if File.symlink?(dir)
124
124
 
125
125
  File.join(dir, 'trace.jsonl')