pwn 0.5.766 → 0.5.772

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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -3
  3. data/documentation/Configuration.md +2 -0
  4. data/documentation/General-PWN-Usage.md +68 -0
  5. data/documentation/Harness-Evaluation.md +312 -0
  6. data/documentation/pwn-ai-Agent.md +135 -6
  7. data/etc/default_cron/session_cleanup.rb +6 -1
  8. data/etc/default_skills/pwn/ai/agent/dispatch/SKILL.md +1 -0
  9. data/etc/default_skills/pwn/ai/agent/open_goal/SKILL.md +1 -0
  10. data/etc/default_skills/pwn/ai/agent/skill_review/SKILL.md +2 -0
  11. data/etc/default_skills/pwn/ai/agent/solve/SKILL.md +4 -2
  12. data/etc/default_skills/pwn/ai/context_ingestion/SKILL.md +1 -1
  13. data/etc/default_skills/pwn/ai/http_retry/SKILL.md +1 -0
  14. data/etc/default_skills/pwn/plugins/repl/ai_console/SKILL.md +1 -1
  15. data/etc/default_skills/pwn/plugins/repl/mesh/SKILL.md +4 -0
  16. data/etc/default_skills/pwn/plugins/repl/mesh/references/urls.md +3 -0
  17. data/etc/default_skills/pwn/redaction/SKILL.md +1 -1
  18. data/etc/default_skills/pwn/runtime_fingerprint/SKILL.md +47 -0
  19. data/lib/pwn/ai/agent/dispatch.rb +50 -6
  20. data/lib/pwn/ai/agent/loop.rb +192 -115
  21. data/lib/pwn/ai/agent/open_goal.rb +43 -1
  22. data/lib/pwn/ai/agent/registry.rb +6 -1
  23. data/lib/pwn/ai/agent/request_runtime.rb +508 -17
  24. data/lib/pwn/ai/agent/result.rb +23 -6
  25. data/lib/pwn/ai/agent/reward.rb +4 -1
  26. data/lib/pwn/ai/agent/skill_review.rb +148 -1
  27. data/lib/pwn/ai/agent/solve.rb +420 -39
  28. data/lib/pwn/ai/agent/swarm.rb +11 -2
  29. data/lib/pwn/ai/agent/tools/shell.rb +23 -0
  30. data/lib/pwn/ai/agent/turn_finalizer.rb +37 -6
  31. data/lib/pwn/ai/agent/verification.rb +42 -5
  32. data/lib/pwn/ai/anthropic.rb +59 -5
  33. data/lib/pwn/ai/cli.rb +48 -4
  34. data/lib/pwn/ai/context.rb +1 -1
  35. data/lib/pwn/ai/context_ingestion.rb +16 -1
  36. data/lib/pwn/ai/gemini.rb +36 -5
  37. data/lib/pwn/ai/grok.rb +19 -15
  38. data/lib/pwn/ai/http_retry.rb +14 -0
  39. data/lib/pwn/ai/mcp/combo_nation.rb +5 -2
  40. data/lib/pwn/ai/mcp.rb +5 -2
  41. data/lib/pwn/ai/ollama.rb +46 -5
  42. data/lib/pwn/ai/open_ai.rb +11 -11
  43. data/lib/pwn/ai/open_web_ui.rb +46 -5
  44. data/lib/pwn/config.rb +10 -2
  45. data/lib/pwn/migrate.rb +9 -1
  46. data/lib/pwn/plugins/repl/ai.rb +31 -1
  47. data/lib/pwn/plugins/repl/ai_console.rb +843 -19
  48. data/lib/pwn/plugins/repl/ai_console_commands.rb +23 -5
  49. data/lib/pwn/plugins/repl/ai_swarm.rb +65 -5
  50. data/lib/pwn/plugins/repl/mesh.rb +894 -83
  51. data/lib/pwn/plugins/repl.rb +2 -0
  52. data/lib/pwn/redaction.rb +29 -5
  53. data/lib/pwn/runtime_fingerprint.rb +84 -0
  54. data/lib/pwn/version.rb +1 -1
  55. data/lib/pwn.rb +1 -0
  56. data/spec/fixtures/harness/adapters.json +62 -0
  57. data/spec/fixtures/harness/adapters.json.sha256 +1 -0
  58. data/spec/fixtures/harness/experiment_gold.json +9 -0
  59. data/spec/fixtures/harness/experiment_gold.json.sha256 +1 -0
  60. data/spec/fixtures/harness/experiment_tasks.json +12 -0
  61. data/spec/fixtures/harness/experiment_tasks.json.sha256 +1 -0
  62. data/spec/fixtures/harness/live_comparison.json +21 -0
  63. data/spec/fixtures/harness/live_comparison.json.sha256 +1 -0
  64. data/spec/fixtures/harness/manifest.json +290 -0
  65. data/spec/fixtures/harness/manifest.json.sha256 +1 -0
  66. data/spec/integration/harness_evaluation_spec.rb +171 -0
  67. data/spec/integration/harness_layout_spec.rb +15 -0
  68. data/spec/integration/harness_live_spec.rb +26 -0
  69. data/spec/integration/harness_package_spec.rb +38 -0
  70. data/spec/integration/native_ruby_helpers_spec.rb +4 -2
  71. data/spec/integration/reinforced_feedback_loop_spec.rb +2 -2
  72. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +47 -3
  73. data/spec/lib/pwn/ai/agent/loop_spec.rb +479 -17
  74. data/spec/lib/pwn/ai/agent/open_goal_spec.rb +17 -0
  75. data/spec/lib/pwn/ai/agent/registry_spec.rb +10 -0
  76. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +232 -0
  77. data/spec/lib/pwn/ai/agent/result_spec.rb +33 -0
  78. data/spec/lib/pwn/ai/agent/signal_hygiene_spec.rb +1 -1
  79. data/spec/lib/pwn/ai/agent/skill_review_spec.rb +17 -5
  80. data/spec/lib/pwn/ai/agent/solve_spec.rb +392 -7
  81. data/spec/lib/pwn/ai/agent/swarm_spec.rb +19 -0
  82. data/spec/lib/pwn/ai/agent/tools/ruby_eval_spec.rb +2 -2
  83. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +20 -0
  84. data/spec/lib/pwn/ai/agent/verification_spec.rb +12 -0
  85. data/spec/lib/pwn/ai/anthropic_spec.rb +10 -0
  86. data/spec/lib/pwn/ai/cli_spec.rb +40 -0
  87. data/spec/lib/pwn/ai/context_ingestion_spec.rb +12 -0
  88. data/spec/lib/pwn/ai/grok_spec.rb +35 -0
  89. data/spec/lib/pwn/ai/mcp/combo_nation_spec.rb +10 -0
  90. data/spec/lib/pwn/ai/mcp_spec.rb +10 -0
  91. data/spec/lib/pwn/ai/open_ai_spec.rb +13 -0
  92. data/spec/lib/pwn/migrate_spec.rb +37 -4
  93. data/spec/lib/pwn/plugins/repl/ai_console_completion_wrap_spec.rb +110 -0
  94. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +307 -3
  95. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +29 -0
  96. data/spec/lib/pwn/plugins/repl/ai_vault_edit_spec.rb +221 -0
  97. data/spec/lib/pwn/plugins/repl_mesh_channel_identity_spec.rb +134 -0
  98. data/spec/lib/pwn/plugins/repl_mesh_dispatch_spec.rb +2 -0
  99. data/spec/lib/pwn/plugins/repl_mesh_dm_transport_spec.rb +2 -1
  100. data/spec/lib/pwn/plugins/repl_mesh_multi_rx_spec.rb +315 -0
  101. data/spec/lib/pwn/plugins/repl_mesh_origin_gps_spec.rb +120 -0
  102. data/spec/lib/pwn/plugins/repl_mesh_rx_errors_spec.rb +325 -0
  103. data/spec/lib/pwn/plugins/repl_mesh_scroll_spec.rb +49 -0
  104. data/spec/lib/pwn/plugins/repl_mesh_transport_label_spec.rb +43 -15
  105. data/spec/lib/pwn/plugins/repl_spec.rb +31 -7
  106. data/spec/lib/pwn/redaction_spec.rb +17 -0
  107. data/spec/lib/pwn/runtime_fingerprint_spec.rb +53 -0
  108. data/spec/lib/pwn/session_cleanup_default_spec.rb +1 -1
  109. data/spec/spec_helper.rb +3 -1
  110. data/spec/support/harness/cli_faults.rb +112 -0
  111. data/spec/support/harness/evaluate.rb +191 -0
  112. data/spec/support/harness/experiment_preload.rb +20 -0
  113. data/spec/support/harness/experiments.rb +137 -0
  114. data/spec/support/harness/fake_provider.rb +82 -0
  115. data/spec/support/harness/fault_preload.rb +44 -0
  116. data/spec/support/harness/package_acceptance.rb +77 -0
  117. data/spec/support/harness/record_training.rb +18 -0
  118. data/spec/support/harness/reference_adapter.py +128 -0
  119. data/spec/support/harness/runtime_faults.rb +97 -0
  120. data/spec/support/harness/session_acceptance.rb +70 -0
  121. data/spec/support/harness/swarm_acceptance.rb +61 -0
  122. data/third_party/pwn_rdoc.jsonl +129 -3
  123. metadata +46 -8
@@ -3,6 +3,8 @@
3
3
  require 'json'
4
4
  require 'fileutils'
5
5
  require 'time'
6
+ require 'digest'
7
+ require 'securerandom'
6
8
 
7
9
  module PWN
8
10
  module AI
@@ -14,6 +16,9 @@ module PWN
14
16
  CONTINUE_RX = /\A\s*(continue|resume|keep going|pick up|carry on)(?:\s+(?:please|the\s+)?(?:goal|task|work))?\s*[.!]?\s*\z/i
15
17
 
16
18
  public_class_method def self.goal_file
19
+ runtime = Thread.current[:pwn_request_runtime]
20
+ return File.join(File.dirname(GOAL_FILE), 'open_goals', Digest::SHA256.hexdigest(runtime.session_id), "#{Digest::SHA256.hexdigest(runtime.request_id)}.json") if runtime
21
+
17
22
  GOAL_FILE
18
23
  end
19
24
 
@@ -29,6 +34,16 @@ module PWN
29
34
  nil
30
35
  end
31
36
 
37
+ public_class_method def self.for_session(opts = {})
38
+ sid = opts[:session_id].to_s
39
+ pointer = File.join(File.dirname(GOAL_FILE), 'open_goals', Digest::SHA256.hexdigest(sid), 'current.json')
40
+ path = File.file?(pointer) ? pointer : GOAL_FILE
41
+ return unless File.file?(path)
42
+
43
+ row = JSON.parse(File.read(path), symbolize_names: true)
44
+ row if row[:session_id] == sid && !row[:request].to_s.empty?
45
+ end
46
+
32
47
  public_class_method def self.begin!(opts = {})
33
48
  request = opts[:request].to_s.strip
34
49
  return if request.empty?
@@ -40,7 +55,10 @@ module PWN
40
55
  mission_id: opts[:mission_id].to_s,
41
56
  updated_at: Time.now.utc.iso8601
42
57
  }
43
- File.write(goal_file, "#{JSON.pretty_generate(row)}\n")
58
+ runtime = Thread.current[:pwn_request_runtime]
59
+ row.merge!(request_id: runtime.request_id, checkpoint_path: runtime.checkpoint_path) if runtime
60
+ atomic_write(path: goal_file, row: row)
61
+ atomic_write(path: File.join(File.dirname(goal_file), 'current.json'), row: row) if runtime
44
62
  row
45
63
  end
46
64
 
@@ -50,6 +68,11 @@ module PWN
50
68
  return :held if mission_open?
51
69
 
52
70
  File.delete(goal_file)
71
+ pointer = File.join(File.dirname(goal_file), 'current.json')
72
+ if Thread.current[:pwn_request_runtime] && File.file?(pointer)
73
+ row = JSON.parse(File.read(pointer), symbolize_names: true)
74
+ File.delete(pointer) if row[:request_id] == Thread.current[:pwn_request_runtime].request_id
75
+ end
53
76
  :ok
54
77
  rescue StandardError
55
78
  :ok
@@ -76,6 +99,11 @@ module PWN
76
99
  # Run current and return its result
77
100
  #{self}.current
78
101
 
102
+ # Look up only the named session's resumable request.
103
+ #{self}.for_session(
104
+ session_id: 'required - exact session identity; no cross-session fallback'
105
+ )
106
+
79
107
  # Run begin and return its result
80
108
  #{self}.begin!(
81
109
  request: 'required - request value consumed by #begin!',
@@ -97,6 +125,20 @@ module PWN
97
125
  constants.sort
98
126
  end
99
127
 
128
+ private_class_method def self.atomic_write(opts = {})
129
+ path = opts[:path]
130
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
131
+ temp = "#{path}.#{SecureRandom.hex(8)}.tmp"
132
+ File.open(temp, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
133
+ file.write(JSON.generate(opts[:row]))
134
+ file.flush
135
+ file.fsync
136
+ end
137
+ File.rename(temp, path)
138
+ ensure
139
+ File.unlink(temp) if temp && File.exist?(temp)
140
+ end
141
+
100
142
  private_class_method def self.mission_open?(opts = {})
101
143
  return false unless defined?(PWN::AI::Agent::Mission)
102
144
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module PWN
4
6
  module AI
5
7
  module Agent
@@ -157,6 +159,9 @@ module PWN
157
159
  schema = opts[:schema]
158
160
  return schema unless schema.is_a?(Hash)
159
161
 
162
+ depth = opts.fetch(:depth, 0)
163
+ raise JSON::NestingError, 'tool schema exceeds JSON nesting limit (100)' if depth >= 100
164
+
160
165
  result = schema.dup
161
166
  type = schema[:type] || schema['type'] || opts[:inherited_type]
162
167
  result[:type] = type if type && !schema.key?(:type) && !schema.key?('type')
@@ -164,7 +169,7 @@ module PWN
164
169
  key = schema.key?(combiner) ? combiner : combiner.to_s
165
170
  next unless schema[key].is_a?(Array)
166
171
 
167
- result[key] = schema[key].map { |branch| explicit_root_types(schema: branch, inherited_type: type) }
172
+ result[key] = schema[key].map { |branch| explicit_root_types(schema: branch, inherited_type: type, depth: depth + 2) }
168
173
  end
169
174
  result
170
175
  end
@@ -1,6 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'base64'
5
+ require 'digest'
6
+ require 'fileutils'
7
+ require 'securerandom'
8
+ require 'time'
9
+ require 'uri'
4
10
  require 'pwn/ai/agent/profiles'
5
11
  require 'pwn/ai/agent/engagement_memory'
6
12
  require 'pwn/session_trace'
@@ -9,34 +15,368 @@ module PWN
9
15
  module AI
10
16
  module Agent
11
17
  # Connect request-scoped routing, evidence context and replay records.
12
- class RequestRuntime
18
+ class RequestRuntime # rubocop:disable Metrics/ClassLength -- one owner for routing, scope and durable evidence
19
+ OUTCOMES = %w[complete incomplete blocked cancelled verification_error].freeze
20
+ attr_reader :original_request, :request, :session_id, :request_id, :contract, :amendments, :outcome, :checkpoint_path, :engine, :last_provider_attempts
21
+
13
22
  def initialize(opts = {})
14
23
  @session_id = opts[:session_id].to_s
15
- @request = opts[:request].to_s
24
+ @request_id = (opts[:request_id] || SecureRandom.uuid).to_s.freeze
25
+ @original_request = opts[:request].to_s.dup.freeze
26
+ @request = @original_request
27
+ @cwd = Dir.pwd
28
+ @amendments = [].freeze
29
+ @observations = {}
30
+ @events = []
31
+ @effects = {}
32
+ @evidence = []
33
+ @provider_config = Marshal.load(Marshal.dump(opts[:provider_config] || (defined?(PWN::Env) ? PWN::Env[:ai] : {}) || {}))
34
+ @engine = opts[:engine] || Thread.current[:pwn_swarm_engine] || @provider_config[:active] || :openai
35
+ @model = opts[:model] || Thread.current[:pwn_swarm_model]
36
+ @explicit_route = opts[:engine] || opts[:model] || Thread.current[:pwn_swarm_engine] || Thread.current[:pwn_swarm_model]
37
+ @provider_attempts = opts.fetch(:provider_attempts, 5).to_i.clamp(1, 100)
38
+ @cancel = opts[:cancel]
39
+ @generation = 0
40
+ @enabled_toolsets = opts[:enabled_toolsets]&.map { |item| item.to_s.dup.freeze }&.freeze
41
+ @verification_contract = opts[:verification_contract] && Marshal.load(Marshal.dump(opts[:verification_contract]))
42
+ @mutex = Mutex.new
43
+ @started_at = Time.now
44
+ @trace = opts.fetch(:trace, true)
45
+ @retrieve = opts.fetch(:retrieve, true)
46
+ root = opts[:artifact_root] || File.join(PWN::Sessions.sessions_dir, 'requests')
47
+ @checkpoint_path = File.join(root, Digest::SHA256.hexdigest(@session_id), "#{Digest::SHA256.hexdigest(@request_id)}.json")
48
+ restore! if opts[:resume]
49
+ rebuild_contract!
16
50
  @profile = opts[:profile]
17
51
  @router = Profiles.new(profiles: opts[:profiles] || {})
52
+ @engine = @router.lookup(name: @profile)[:provider] if @profile && !@explicit_route
18
53
  @memory = EngagementMemory.new(original_goal: @request, session_id: @session_id,
19
- root: opts[:artifact_root] || File.expand_path('~/.pwn/artifacts'),
54
+ root: root,
20
55
  window: opts[:window] || 128_000, tool_cap: opts[:tool_cap] || 8192)
21
56
  @route = {}
22
57
  @retrieval = nil
58
+ checkpoint!
23
59
  record(event: 'request', data: { content: @request })
24
60
  end
25
61
 
62
+ # A resumed execution cannot inherit an earlier terminal success.
63
+ def start!
64
+ @outcome = nil
65
+ checkpoint!
66
+ end
67
+
68
+ # Steering is additive unless the operator explicitly says replace/cancel.
69
+ def amend!(opts = {})
70
+ instruction = opts.fetch(:instruction).to_s
71
+ mode = if instruction.strip.match?(/\Acancel\z/i)
72
+ 'cancel'
73
+ elsif instruction.match?(/\Areplace:\s*/i)
74
+ 'replace'
75
+ else
76
+ 'add'
77
+ end
78
+ @amendments = (@amendments + [{ mode: mode, instruction: instruction.dup.freeze }.freeze]).freeze
79
+ @request = if mode == 'replace'
80
+ instruction.sub(/\Areplace:\s*/i, '').freeze
81
+ elsif mode == 'add'
82
+ "#{@request}\n#{instruction}".freeze
83
+ else
84
+ @request
85
+ end
86
+ rebuild_contract!
87
+ mode == 'cancel' ? finish!(status: 'cancelled') : checkpoint!
88
+ end
89
+
90
+ # Only host observations enter this store, never parsed tool stdout.
91
+ def observe!(opts = {})
92
+ @mutex.synchronize do
93
+ opts.fetch(:observations).each { |path, row| @observations[path.to_s] = row }
94
+ checkpoint!
95
+ end
96
+ end
97
+
98
+ def observations
99
+ @mutex.synchronize { Marshal.load(Marshal.dump(@observations)) }
100
+ end
101
+
102
+ # Called only by the enclosing Loop, never by a provider tool payload.
103
+ # Recheck bytes/identity before rebinding to the parent's turn. The
104
+ # parent still runs its own literal and authored integration checks.
105
+ def accept_child!(opts = {})
106
+ child = opts.fetch(:runtime)
107
+ return unless child.outcome&.dig(:status) == 'complete' && child.contract[:started_at] >= @started_at
108
+
109
+ rows = child.observations.select do |path, row|
110
+ next false unless @contract[:required_artifacts].include?(path) && row[:artifact_turn_id] == child.contract[:id]
111
+
112
+ current = TurnFinalizer.artifact_snapshot(paths: [path])[path]
113
+ current && current == row.except(:artifact_turn_id, :action_id, :tool, :child_request_id, :child_turn_id, :child_provenance)
114
+ end
115
+ rows.transform_values! do |row|
116
+ row.merge(artifact_turn_id: @contract[:id], child_request_id: child.request_id, child_turn_id: child.contract[:id],
117
+ child_provenance: Array(row[:child_provenance]) + [{ request_id: child.request_id, turn_id: child.contract[:id], sha256: row[:sha256], action_id: row[:action_id] }])
118
+ end
119
+ observe!(observations: rows) unless rows.empty?
120
+ end
121
+
122
+ def events
123
+ @mutex.synchronize { Marshal.load(Marshal.dump(@events)) }
124
+ end
125
+
126
+ # Private copies keep credentials and defaults out of checkpoints.
127
+ def provider_config(engine)
128
+ Marshal.load(Marshal.dump(@provider_config[engine.to_sym] || {}))
129
+ end
130
+
131
+ def update_provider_oauth(engine, oauth)
132
+ @provider_config[engine.to_sym] ||= {}
133
+ @provider_config[engine.to_sym][:oauth] = Marshal.load(Marshal.dump(oauth))
134
+ :updated
135
+ end
136
+
137
+ def check_cancelled!
138
+ raise Interrupt, 'request cancelled' if @outcome&.dig(:status) == 'cancelled' || @cancel&.call
139
+ end
140
+
141
+ # Unknown tools are exclusive barriers; shell cannot opt into reads.
142
+ def each_tool_batch(calls)
143
+ batch = []
144
+ resources = []
145
+ calls.each do |call|
146
+ metadata = Dispatch.scheduling(tool_call: call)
147
+ if metadata[:exclusive] || batch.length >= 8 || resources.intersect?(metadata[:resources])
148
+ yield batch unless batch.empty?
149
+ batch = []
150
+ resources = []
151
+ end
152
+ batch << call
153
+ resources.concat(metadata[:resources])
154
+ next unless metadata[:exclusive]
155
+
156
+ yield batch
157
+ batch = []
158
+ resources = []
159
+ end
160
+ yield batch unless batch.empty?
161
+ end
162
+
163
+ def scheduled_calls(calls, session_id:, eligible: nil)
164
+ Enumerator.new do |output|
165
+ each_tool_batch(calls) do |batch|
166
+ reads = eligible ? batch.select { |call| eligible.call(call) } : batch
167
+ results = dispatch_reads(reads, session_id: session_id)
168
+ batch.each { |call| output << [call, results[call]] }
169
+ end
170
+ end
171
+ end
172
+
173
+ def dispatch_reads(calls, session_id:)
174
+ return {} if calls.length < 2
175
+
176
+ locals = Thread.current.keys.to_h { |key| [key, Thread.current[key]] }
177
+ workers = calls.map do |call|
178
+ Thread.new do
179
+ locals.each { |key, value| Thread.current[key] = value }
180
+ check_cancelled!
181
+ Dispatch.call(tool_call: call, session_id: session_id)
182
+ end
183
+ end
184
+ workers.each do |worker|
185
+ check_cancelled! until worker.join(0.05)
186
+ end
187
+ calls.zip(workers.map(&:value)).to_h
188
+ ensure
189
+ workers&.each { |worker| worker.kill if worker.alive? }
190
+ workers&.each(&:join)
191
+ end
192
+
193
+ def tool_allowed?(opts = {})
194
+ @enabled_toolsets.nil? || @enabled_toolsets.include?(opts[:toolset].to_s) || @enabled_toolsets.include?(opts[:name].to_s)
195
+ end
196
+
197
+ def verification_contract
198
+ Marshal.load(Marshal.dump(@verification_contract))
199
+ end
200
+
201
+ def bind_verification!(opts = {})
202
+ contract = opts[:contract]
203
+ return unless contract
204
+ raise ArgumentError, 'verification contract replacement requires a new request' if @verification_contract && @verification_contract != contract
205
+
206
+ @verification_contract = Marshal.load(Marshal.dump(contract))
207
+ checkpoint!
208
+ end
209
+
210
+ def session_bound?(opts = {})
211
+ return false unless opts[:session_id].to_s == @session_id && opts[:request].to_s == @request
212
+
213
+ users = Array(opts[:rows]).select { |row| row[:role].to_s == 'user' }.map { |row| row[:content].to_s }
214
+ expected = [@original_request] + @amendments.map { |row| row[:instruction] }
215
+ users.last(expected.length) == expected
216
+ end
217
+
218
+ def verification!(opts = {})
219
+ report = opts.fetch(:report)
220
+ @mutex.synchronize do
221
+ @events << { phase: 'verification', report: report, request_id: @request_id,
222
+ request_sha256: Digest::SHA256.hexdigest(@request),
223
+ original_sha256: Digest::SHA256.hexdigest(@original_request),
224
+ amendments_sha256: Digest::SHA256.hexdigest(JSON.generate(@amendments)),
225
+ at: Time.now.utc.iso8601(9) }
226
+ checkpoint!
227
+ end
228
+ end
229
+
230
+ # Host verifier adapters supply observations, never tool stdout. Each
231
+ # record is bound to a literal source clause and an exact remote target.
232
+ def observe_evidence!(opts = {})
233
+ requirement = opts.fetch(:requirement).to_s
234
+ source = [{ id: 'original', text: @original_request }] + @amendments.each_with_index.map { |row, index| { id: "amendment:#{index}", text: row[:instruction] } }
235
+ source = source.find { |row| row[:text].include?(requirement) }
236
+ raise ArgumentError, 'evidence must name an original or amended requirement' if requirement.empty? || !source || !@request.include?(requirement)
237
+ raise ArgumentError, 'unsupported evidence kind' unless %w[http command answer].include?(opts[:kind].to_s)
238
+
239
+ row = { id: SecureRandom.uuid, request_id: @request_id, source_id: source[:id], requirement: requirement,
240
+ offset: source[:text].index(requirement), requirement_sha256: Digest::SHA256.hexdigest(requirement),
241
+ scope_sha256: Digest::SHA256.hexdigest(@request), kind: opts[:kind].to_s, locator: opts[:locator],
242
+ sha256: opts.fetch(:sha256), passed: opts[:passed] == true && evidence_target_matches?(opts), provenance: 'host_verifier',
243
+ observed_at: Time.now.utc.iso8601(9), max_age: opts[:max_age] || (300 if opts[:kind].to_s == 'http') }
244
+ @mutex.synchronize do
245
+ @evidence << Marshal.load(Marshal.dump(row))
246
+ checkpoint!
247
+ end
248
+ row
249
+ end
250
+
251
+ def evidence
252
+ @mutex.synchronize { Marshal.load(Marshal.dump(@evidence)) }
253
+ end
254
+
255
+ def host_evidenced?(host)
256
+ evidence.any? do |row|
257
+ next false unless row[:kind] == 'http' && fresh_evidence?(row)
258
+
259
+ URI.parse(row[:locator]).host == host.to_s.downcase
260
+ end
261
+ end
262
+
263
+ def scope_evidenced?
264
+ remaining = @request.dup
265
+ evidence.select { |row| fresh_evidence?(row) }.each { |row| remaining.gsub!(row[:requirement], '') }
266
+ remaining.match?(/\A[\s.,;:!?]*\z/)
267
+ end
268
+
269
+ # Recovery is an explicit operator action after an independent target
270
+ # read. Merely obtaining a successful HTTP response never clears doubt.
271
+ def reconcile_effect!(opts = {})
272
+ raise ArgumentError, 'operator approval required' unless opts[:operator_approved] == true
273
+
274
+ key = opts.fetch(:payload_sha256)
275
+ row = evidence.find { |item| item[:id] == opts[:evidence_id] && item[:kind] == 'http' && fresh_evidence?(item) }
276
+ action = events.reverse.find { |item| item[:payload_sha256] == key && item[:phase] == 'start' }
277
+ raise ArgumentError, 'fresh exact-target reconciliation evidence required' unless row && action && action[:target] && row[:locator] == action[:target] && Time.iso8601(row[:observed_at]) > Time.iso8601(action[:at])
278
+
279
+ @mutex.synchronize do
280
+ raise ArgumentError, 'effect is not uncertain' unless @effects[key] == 'uncertain'
281
+
282
+ @effects[key] = 'reconciled'
283
+ @events << { phase: 'reconciliation', payload_sha256: key, evidence_id: row[:id], at: Time.now.utc.iso8601(9) }
284
+ checkpoint!
285
+ end
286
+ :reconciled
287
+ end
288
+
289
+ def fresh_evidence?(row)
290
+ observed = Time.iso8601(row[:observed_at])
291
+ latest = @evidence.reverse.find { |item| item[:kind] == row[:kind] && item[:locator] == row[:locator] && item[:requirement] == row[:requirement] }
292
+ return false unless latest && latest[:id] == row[:id]
293
+
294
+ row[:request_id] == @request_id && row[:scope_sha256] == Digest::SHA256.hexdigest(@request) && row[:passed] &&
295
+ observed >= @started_at && observed <= Time.now && (!row[:max_age] || Time.now - observed <= row[:max_age].to_f)
296
+ end
297
+
298
+ def unmet
299
+ @mutex.synchronize { @effects.value?('uncertain') ? ['uncertain_side_effect'] : [] }
300
+ end
301
+
302
+ # A started mutation stays uncertain across interruption or process death.
303
+ # Changing timeout/action IDs does not authorize replay of that mutation.
304
+ def execute_tool(opts = {}, &)
305
+ check_cancelled!
306
+ payload = opts.fetch(:args).reject { |key, _| key.to_s == 'timeout' }
307
+ key = Digest::SHA256.hexdigest(JSON.generate([opts.fetch(:name), canonical(payload)]))
308
+ mutation = !%w[read recall].include?(opts.fetch(:effect).to_s)
309
+ action = { action_id: opts[:action_id], tool: opts[:name], payload_sha256: key, effect: opts[:effect].to_s,
310
+ target: payload[:url] || payload['url'] }
311
+ denied = @mutex.synchronize do
312
+ if mutation && @effects[key] == 'uncertain'
313
+ true
314
+ else
315
+ @effects[key] = 'uncertain' if mutation
316
+ @events << action.merge(phase: 'start', at: Time.now.utc.iso8601(9))
317
+ checkpoint!
318
+ false
319
+ end
320
+ end
321
+ return { success: false, error: 'uncertain_side_effect', hint: 'Inspect the target before any operator-approved recovery; this mutation was not replayed.' } if denied
322
+
323
+ begin
324
+ result = provider_attempt(&)
325
+ failed = result.is_a?(Hash) && (result[:error] || result['error'] || result[:success] == false || result['success'] == false)
326
+ state = if failed
327
+ mutation ? 'uncertain' : 'failed'
328
+ else
329
+ 'completed'
330
+ end
331
+ result
332
+ ensure
333
+ @mutex.synchronize do
334
+ state ||= mutation ? 'uncertain' : 'interrupted'
335
+ @effects[key] = state if mutation
336
+ @events << action.merge(phase: 'end', state: state, at: Time.now.utc.iso8601(9))
337
+ checkpoint!
338
+ end
339
+ end
340
+ end
341
+
342
+ def finish!(opts = {})
343
+ status = opts.fetch(:status).to_s
344
+ raise ArgumentError, 'invalid request outcome' unless OUTCOMES.include?(status)
345
+
346
+ @outcome = { status: status, unmet: Array(opts[:unmet]), at: Time.now.utc.iso8601 }.freeze
347
+ checkpoint!
348
+ @outcome
349
+ end
350
+
26
351
  def call(messages:, tools:)
27
352
  prepared = @memory.compact(messages: messages)
28
- prepared = retrieval_context(prepared) unless tools.nil?
29
- routes = @router.routes(name: @profile, engine: Thread.current[:pwn_swarm_engine], model: Thread.current[:pwn_swarm_model])
30
- if routes.empty?
31
- @route = {}
32
- response = yield(prepared, @route)
33
- else
34
- response = @router.call(name: @profile, engine: Thread.current[:pwn_swarm_engine], model: Thread.current[:pwn_swarm_model]) do |route|
35
- @route = route
36
- input = prepared
37
- input = [{ role: 'system', content: route[:system_prompt] }] + prepared if route[:system_prompt] && !route[:system_prompt].empty?
38
- yield(input, route)
353
+ prepared = retrieval_context(prepared) if @retrieve && !tools.nil?
354
+ routes = @router.routes(name: @profile, engine: (@engine if !@profile || @explicit_route), model: (@model if !@profile || @explicit_route))
355
+ routes = [{ provider: @engine, model: @model }] if routes.empty?
356
+ attempt = 0
357
+ previous = Thread.current[:pwn_provider_attempt_owner]
358
+ begin
359
+ check_cancelled!
360
+ Thread.current[:pwn_provider_attempt_owner] = self
361
+ @route = routes[[attempt, routes.length - 1].min]
362
+ attempt += 1
363
+ @last_provider_attempts = attempt
364
+ input = Marshal.load(Marshal.dump(prepared))
365
+ input = [{ role: 'system', content: @route[:system_prompt] }] + input if @route[:system_prompt] && !@route[:system_prompt].empty?
366
+ provider_event('provider_start', attempt)
367
+ begin
368
+ response = provider_attempt { yield(input, @route) }
369
+ ensure
370
+ provider_event('provider_end', attempt)
39
371
  end
372
+ rescue StandardError => e
373
+ raise unless attempt < @provider_attempts && provider_transient?(e)
374
+
375
+ check_cancelled!
376
+ provider_backoff(e, attempt)
377
+ retry
378
+ ensure
379
+ Thread.current[:pwn_provider_attempt_owner] = previous
40
380
  end
41
381
  record(event: 'response', data: response)
42
382
  response
@@ -57,10 +397,140 @@ module PWN
57
397
 
58
398
  private
59
399
 
400
+ def evidence_target_matches?(opts)
401
+ return true unless opts[:kind].to_s == 'http'
402
+
403
+ uri = URI.parse(opts[:locator].to_s)
404
+ return false unless %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo
405
+
406
+ requirement = opts[:requirement].to_s
407
+ urls = URI.extract(requirement, %w[http https])
408
+ return urls.include?(uri.to_s) unless urls.empty?
409
+
410
+ hosts = requirement.scan(/(?<![\w.-])(?:[a-z0-9-]+\.)+[a-z0-9-]+(?![\w.-])/i).map(&:downcase)
411
+ hosts.empty? || hosts.include?(uri.host.downcase)
412
+ rescue URI::InvalidURIError
413
+ false
414
+ end
415
+
416
+ def provider_event(phase, attempt)
417
+ @mutex.synchronize do
418
+ @events << { phase: phase, attempt: attempt, provider: @route[:provider], model: @route[:model], at: Time.now.utc.iso8601(9) }
419
+ checkpoint!
420
+ end
421
+ end
422
+
423
+ def provider_backoff(error, attempt)
424
+ return unless error.respond_to?(:http_code) && error.http_code.to_i == 429
425
+
426
+ delay = PWN::AI::HttpRetry.retry_after_s(response: error.response, retry_count: attempt)
427
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + delay
428
+ while (remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)).positive?
429
+ check_cancelled!
430
+ sleep [remaining, 0.05].min
431
+ end
432
+ end
433
+
434
+ # A cancellation callback is observed even while a provider is blocked.
435
+ # No extra request deadline is introduced. Join every owned worker.
436
+ def provider_attempt
437
+ return yield unless @cancel
438
+
439
+ locals = Thread.current.keys.to_h { |key| [key, Thread.current[key]] }
440
+ worker = Thread.new do
441
+ locals.each { |key, value| Thread.current[key] = value }
442
+ yield
443
+ end
444
+ worker.report_on_exception = false
445
+ check_cancelled! until worker.join(0.05)
446
+ check_cancelled!
447
+ worker.value
448
+ ensure
449
+ worker.kill if worker&.alive?
450
+ worker&.join
451
+ end
452
+
453
+ def provider_transient?(error)
454
+ return false if PWN::AI::HttpRetry.quota_exhausted?(error: error)
455
+
456
+ status = error.respond_to?(:http_code) ? error.http_code.to_i : 0
457
+ error.is_a?(Timeout::Error) || error.is_a?(SocketError) || error.is_a?(EOFError) ||
458
+ [Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH].any? { |klass| error.is_a?(klass) } ||
459
+ status == 429 || status.between?(500, 599) || PWN::AI::HttpRetry.retryable?(error: error)
460
+ end
461
+
462
+ def canonical(value)
463
+ case value
464
+ when Hash then value.keys.sort_by(&:to_s).to_h { |key| [key.to_s, canonical(value[key])] }
465
+ when Array then value.map { |item| canonical(item) }
466
+ else value
467
+ end
468
+ end
469
+
470
+ def rebuild_contract!
471
+ @contract = { id: @request_id, request: @request, cwd: @cwd,
472
+ required_artifacts: TurnFinalizer.output_paths(request: @request, cwd: @cwd).map(&:freeze).freeze,
473
+ started_at: @started_at }.freeze
474
+ end
475
+
476
+ def checkpoint!
477
+ FileUtils.mkdir_p(File.dirname(@checkpoint_path), mode: 0o700)
478
+ File.open("#{@checkpoint_path}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
479
+ lock.flock(File::LOCK_EX)
480
+ current = File.file?(@checkpoint_path) ? JSON.parse(File.read(@checkpoint_path)).fetch('generation', 0) : 0
481
+ raise IOError, 'stale request checkpoint: reload before proceeding' unless current == @generation
482
+
483
+ @generation += 1
484
+ write_checkpoint!
485
+ end
486
+ end
487
+
488
+ def write_checkpoint!
489
+ rows = @observations.transform_values do |row|
490
+ row.merge(head: Base64.strict_encode64(row[:head]), tail: Base64.strict_encode64(row[:tail]))
491
+ end
492
+ data = { version: 1, generation: @generation, session_id: @session_id, request_id: @request_id, original_request: @original_request,
493
+ request: @request, amendments: @amendments, cwd: @cwd, started_at: @started_at.iso8601(9),
494
+ observations: rows, outcome: @outcome, events: @events, effects: @effects, evidence: @evidence,
495
+ enabled_toolsets: @enabled_toolsets, verification_contract: @verification_contract }
496
+ FileUtils.mkdir_p(File.dirname(@checkpoint_path), mode: 0o700)
497
+ temp = "#{@checkpoint_path}.#{SecureRandom.hex(8)}.tmp"
498
+ File.open(temp, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
499
+ file.write(JSON.generate(data))
500
+ file.flush
501
+ file.fsync
502
+ end
503
+ File.rename(temp, @checkpoint_path)
504
+ ensure
505
+ File.unlink(temp) if temp && File.exist?(temp)
506
+ end
507
+
508
+ def restore!
509
+ data = JSON.parse(File.read(@checkpoint_path), symbolize_names: true)
510
+ raise ArgumentError, 'request checkpoint identity mismatch' unless data[:version] == 1 && data[:session_id] == @session_id && data[:request_id] == @request_id
511
+
512
+ @original_request = data.fetch(:original_request).freeze
513
+ @generation = data.fetch(:generation, 0)
514
+ @enabled_toolsets = data[:enabled_toolsets]&.freeze
515
+ @verification_contract = data[:verification_contract]
516
+ @request = data.fetch(:request).freeze
517
+ @cwd = data.fetch(:cwd)
518
+ @started_at = Time.iso8601(data.fetch(:started_at))
519
+ @amendments = data.fetch(:amendments).map(&:freeze).freeze
520
+ @outcome = data[:outcome]&.freeze
521
+ @events = data.fetch(:events, [])
522
+ @effects = data.fetch(:effects, {}).transform_keys(&:to_s)
523
+ @evidence = data.fetch(:evidence, [])
524
+ @observations = data.fetch(:observations).to_h do |path, row|
525
+ [path.to_s, row.merge(head: Base64.strict_decode64(row[:head]), tail: Base64.strict_decode64(row[:tail]))]
526
+ end
527
+ end
528
+
60
529
  def record(event:, data:)
61
- env = defined?(PWN::Env) ? PWN::Env : {}
62
- engine = @route[:provider] || Thread.current[:pwn_swarm_engine] || env.dig(:ai, :active)
63
- model = @route[:model] || Thread.current[:pwn_swarm_model] || env.dig(:ai, engine.to_s.to_sym, :model)
530
+ return unless @trace
531
+
532
+ engine = @route[:provider] || @engine
533
+ model = @route[:model] || @model || @provider_config.dig(engine.to_s.to_sym, :model)
64
534
  PWN::SessionTrace.append(session_id: @session_id, event: event, data: data, model: model,
65
535
  params: @route.merge(provider: engine))
66
536
  end
@@ -86,6 +556,27 @@ module PWN
86
556
  #{self}.authors
87
557
  # Print this usage.
88
558
  #{self}.help
559
+
560
+ # RequestRuntime instances own immutable original_request, ordered amendments,
561
+ # an effective request, a revision-bound host observation ledger and outcome.
562
+ # new accepts request:, session_id:, request_id:, artifact_root:, resume:,
563
+ # profile:, profiles:, window:, tool_cap:, trace:, retrieve:,
564
+ # enabled_toolsets:, verification_contract:, provider_config:, engine:,
565
+ # model:, provider_attempts: (default 5 across all routes), cancel: callback.
566
+ # scheduled_calls(calls, session_id:) overlaps at most eight audited
567
+ # independent reads; conflicting resources and unknown tools are barriers.
568
+ # evidence holds typed host verifier observations with source clause IDs,
569
+ # exact locators, digests and freshness. Tool stdout is never evidence.
570
+ # reconcile_effect!(payload_sha256:, evidence_id:, operator_approved:)
571
+ # requires fresh independent exact-target proof after uncertainty.
572
+ # execute_tool owns durable start/end events and uncertain mutation replay denial.
573
+ # events includes verifier reports independent of compacted chat messages.
574
+ # Checkpoint generation checks reject stale writers. Loop restores a session's
575
+ # active OpenGoal identity on a bare continue request.
576
+ # amend!(instruction:) adds scope by default; replace: and cancel are explicit.
577
+ # observe!(observations:) accepts host-only TurnFinalizer snapshots.
578
+ # finish!(status:, unmet:) persists complete/incomplete/blocked/cancelled/verification_error.
579
+ # resume: true requires the same session_id/request_id/root and never replays tools.
89
580
  "
90
581
  end
91
582
  end