pwn 0.5.783 → 0.5.784
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile +3 -3
- data/Rakefile +19 -1
- data/bin/pwn_setup +1 -1
- data/build_gem.sh +1 -1
- data/documentation/Backup-Retention.md +27 -0
- data/documentation/Cron.md +64 -35
- data/documentation/How-PWN-Works.md +1 -1
- data/documentation/Installation.md +247 -6
- data/documentation/Persistence.md +1 -1
- data/documentation/Reinforcement-Learning.md +5 -6
- data/documentation/diagrams/cron-scheduling.svg +130 -128
- data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
- data/etc/default_cron/session_cleanup.rb +1217 -10
- data/lib/pwn/ai/agent/extrospection.rb +6 -7
- data/lib/pwn/ai/agent/learning.rb +48 -7
- data/lib/pwn/ai/agent/policy.rb +5 -0
- data/lib/pwn/ai/agent/swarm.rb +31 -16
- data/lib/pwn/ai/router.rb +45 -2
- data/lib/pwn/cron.rb +120 -49
- data/lib/pwn/migrate.rb +292 -15
- data/lib/pwn/plugins/binary_parser.rb +5 -4
- data/lib/pwn/plugins/ghidra.rb +4 -3
- data/lib/pwn/plugins/log.rb +18 -2
- data/lib/pwn/plugins/repl/ai.rb +12 -1
- data/lib/pwn/plugins/repl/ai_console.rb +6 -2
- data/lib/pwn/plugins/repl/ai_swarm.rb +4 -4
- data/lib/pwn/sessions.rb +58 -16
- data/lib/pwn/setup.rb +9 -17
- data/lib/pwn/version.rb +1 -1
- data/scripts/build_gate.rb +58 -0
- data/spec/integration/build_gate_spec.rb +73 -0
- data/spec/integration/persistence_roundtrip_spec.rb +1 -7
- data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
- data/spec/lib/pwn/ai/router_spec.rb +30 -2
- data/spec/lib/pwn/cron_spec.rb +113 -6
- data/spec/lib/pwn/migrate_spec.rb +160 -5
- data/spec/lib/pwn/plugins/log_spec.rb +7 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +16 -4
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +11 -0
- data/spec/lib/pwn/redaction_spec.rb +13 -10
- data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +165 -2
- data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +389 -0
- data/spec/lib/pwn/sessions_spec.rb +112 -0
- data/spec/lib/pwn/setup_spec.rb +3 -1
- data/spec/support/gate_failure_formatter.rb +14 -0
- data/third_party/pwn_rdoc.jsonl +15 -1
- metadata +14 -7
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
|
-
|
|
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(
|
|
95
|
-
return legacy unless File.exist?(path) || File.symlink?(path) || File.file?(File.join(
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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}.#{
|
|
1270
|
-
File.open(tmp, File::WRONLY | File::CREAT | File::
|
|
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.
|
|
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
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
data/spec/lib/pwn/cron_spec.rb
CHANGED
|
@@ -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
|
-
|
|
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(@
|
|
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(
|
|
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
|
|
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('
|
|
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])
|