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.
- 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 +280 -15
- 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/documentation/pwn-REPL.md +31 -3
- data/documentation/pwn-ai-Agent.md +62 -2
- data/etc/default_cron/session_cleanup.rb +1544 -44
- data/lib/pwn/ai/agent/curriculum.rb +2 -2
- data/lib/pwn/ai/agent/extrospection.rb +6 -7
- data/lib/pwn/ai/agent/learning.rb +59 -11
- data/lib/pwn/ai/agent/mistakes.rb +22 -3
- data/lib/pwn/ai/agent/policy.rb +5 -0
- data/lib/pwn/ai/agent/swarm.rb +31 -16
- data/lib/pwn/ai/agent/tools/sessions.rb +6 -6
- data/lib/pwn/ai/http_retry.rb +3 -0
- data/lib/pwn/ai/router.rb +48 -3
- data/lib/pwn/cron.rb +120 -49
- data/lib/pwn/memory.rb +62 -6
- 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 +25 -4
- data/lib/pwn/plugins/repl/ai_console.rb +41 -21
- data/lib/pwn/plugins/repl/ai_swarm.rb +4 -4
- data/lib/pwn/session_trace.rb +1 -1
- data/lib/pwn/sessions.rb +359 -92
- 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/learning_spec.rb +65 -2
- data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +3 -3
- data/spec/lib/pwn/ai/router_spec.rb +30 -2
- data/spec/lib/pwn/cron_spec.rb +113 -6
- data/spec/lib/pwn/memory_spec.rb +58 -1
- 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 +203 -6
- data/spec/lib/pwn/plugins/repl/ai_result_chronology_spec.rb +2 -2
- data/spec/lib/pwn/plugins/repl/ai_spec.rb +28 -11
- 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 +166 -3
- data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +800 -36
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +389 -0
- data/spec/lib/pwn/sessions_spec.rb +228 -16
- data/spec/lib/pwn/setup_spec.rb +3 -1
- data/spec/support/gate_failure_formatter.rb +14 -0
- data/third_party/pwn_rdoc.jsonl +27 -1
- metadata +14 -7
data/lib/pwn/cron.rb
CHANGED
|
@@ -48,6 +48,8 @@ module PWN
|
|
|
48
48
|
# enabled: true
|
|
49
49
|
# )
|
|
50
50
|
public_class_method def self.create(opts = {})
|
|
51
|
+
return with_registry_lock { create(opts) } unless registry_owned?
|
|
52
|
+
|
|
51
53
|
jobs = load_jobs
|
|
52
54
|
id = SecureRandom.hex(6)
|
|
53
55
|
name = opts[:name] || "job-#{id}"
|
|
@@ -69,7 +71,7 @@ module PWN
|
|
|
69
71
|
|
|
70
72
|
# Optionally install a crontab entry (user must have permission)
|
|
71
73
|
install_crontab_entry(job: job) if opts[:install_crontab]
|
|
72
|
-
sync_one_job(job: job) if cron_enabled? && !opts[:install_crontab]
|
|
74
|
+
sync_one_job(job: job) if cron_enabled? && !opts[:install_crontab] && opts.fetch(:sync_native, true)
|
|
73
75
|
|
|
74
76
|
job
|
|
75
77
|
end
|
|
@@ -83,6 +85,7 @@ module PWN
|
|
|
83
85
|
jobs = load_jobs
|
|
84
86
|
job = jobs[id] || jobs.values.find { |j| j[:name] == id || j[:id] == id }
|
|
85
87
|
raise "Job #{id} not found" unless job
|
|
88
|
+
return { skipped: true, reason: :housekeeping_internal_only, status: 'skipped' } if native_execution? && housekeeping_job?(job: job)
|
|
86
89
|
|
|
87
90
|
start = Time.now
|
|
88
91
|
result = nil
|
|
@@ -133,20 +136,28 @@ module PWN
|
|
|
133
136
|
|
|
134
137
|
job[:last_run] = Time.now.utc.iso8601
|
|
135
138
|
job[:last_status] = status
|
|
136
|
-
|
|
137
|
-
|
|
139
|
+
with_registry_lock do
|
|
140
|
+
current = load_jobs
|
|
141
|
+
if current.key?(job[:id])
|
|
142
|
+
current[job[:id]][:last_run] = job[:last_run]
|
|
143
|
+
current[job[:id]][:last_status] = status
|
|
144
|
+
save_jobs(jobs: current)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
138
147
|
|
|
139
148
|
{ job: job, result: result, duration: Time.now - start, status: status }
|
|
140
149
|
end
|
|
141
150
|
|
|
142
151
|
# Supported Method Parameters::
|
|
143
|
-
# PWN::Cron.remove(id:)
|
|
144
|
-
public_class_method def self.remove(opts = {})
|
|
152
|
+
# PWN::Cron.remove(id:, sync_native: false)
|
|
153
|
+
public_class_method def self.remove(opts = {})
|
|
154
|
+
return with_registry_lock { remove(opts) } unless registry_owned?
|
|
155
|
+
|
|
145
156
|
id = opts[:id].to_s
|
|
146
157
|
jobs = load_jobs
|
|
147
158
|
gone = jobs.delete(id)
|
|
148
159
|
save_jobs(jobs: jobs)
|
|
149
|
-
remove_native_job(job: gone) if gone
|
|
160
|
+
remove_native_job(job: gone) if gone && opts.fetch(:sync_native, true)
|
|
150
161
|
true
|
|
151
162
|
end
|
|
152
163
|
|
|
@@ -181,16 +192,48 @@ module PWN
|
|
|
181
192
|
# and break jobs[id] / jobs.delete(id) / toggle lookups).
|
|
182
193
|
raw.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
183
194
|
rescue StandardError => e
|
|
184
|
-
|
|
185
|
-
|
|
195
|
+
raise IOError, "Cron registry unreadable (#{e.class}); refusing an empty replacement"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# A permanent inode coordinates registry writers, never job execution.
|
|
199
|
+
private_class_method def self.with_registry_lock
|
|
200
|
+
return yield if registry_owned?
|
|
201
|
+
|
|
202
|
+
owner = Thread.current[:pwn_cron_registry_owner]
|
|
203
|
+
File.open(File.join(cron_dir, '.jobs.write.lock'), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |file|
|
|
204
|
+
file.flock(File::LOCK_EX)
|
|
205
|
+
begin
|
|
206
|
+
Thread.current[:pwn_cron_registry_owner] = [Process.pid, Thread.current, jobs_file]
|
|
207
|
+
yield
|
|
208
|
+
ensure
|
|
209
|
+
Thread.current[:pwn_cron_registry_owner] = owner
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private_class_method def self.registry_owned?
|
|
215
|
+
Thread.current[:pwn_cron_registry_owner] == [Process.pid, Thread.current, jobs_file]
|
|
186
216
|
end
|
|
187
217
|
|
|
188
218
|
private_class_method def self.save_jobs(opts = {})
|
|
189
219
|
jobs = opts[:jobs] ||= {}
|
|
190
|
-
|
|
220
|
+
temporary = "#{jobs_file}.#{SecureRandom.hex(8)}.tmp"
|
|
221
|
+
begin
|
|
222
|
+
File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
223
|
+
file.write(YAML.dump(jobs))
|
|
224
|
+
file.flush
|
|
225
|
+
file.fsync
|
|
226
|
+
end
|
|
227
|
+
File.rename(temporary, jobs_file)
|
|
228
|
+
File.open(cron_dir, File::RDONLY, &:fsync)
|
|
229
|
+
ensure
|
|
230
|
+
FileUtils.rm_f(temporary)
|
|
231
|
+
end
|
|
191
232
|
end
|
|
192
233
|
|
|
193
234
|
private_class_method def self.toggle(opts = {})
|
|
235
|
+
return with_registry_lock { toggle(opts) } unless registry_owned?
|
|
236
|
+
|
|
194
237
|
id = opts[:id].to_s
|
|
195
238
|
enabled = opts[:enabled]
|
|
196
239
|
jobs = load_jobs
|
|
@@ -202,9 +245,29 @@ module PWN
|
|
|
202
245
|
jobs[id]
|
|
203
246
|
end
|
|
204
247
|
|
|
248
|
+
private_class_method def self.native_execution?
|
|
249
|
+
return true if ENV['PWN_CRON_NATIVE'] == '1'
|
|
250
|
+
|
|
251
|
+
# An interactive terminal may inherit INVOCATION_ID; only PWN scheduler
|
|
252
|
+
# cgroups identify legacy units that predate the explicit marker.
|
|
253
|
+
File.read('/proc/self/cgroup').match?(%r{/pwn-cron-(?:worker|j[^/]+)\.service(?:/|$)})
|
|
254
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
255
|
+
false
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Housekeeping is owned exclusively by the portable worker/manual commands.
|
|
259
|
+
private_class_method def self.housekeeping_job?(opts = {})
|
|
260
|
+
job = opts[:job]
|
|
261
|
+
return false unless job.is_a?(Hash)
|
|
262
|
+
|
|
263
|
+
%w[session_cleanup learning_consolidate_nightly pwn_stores_lean_nightly].include?(job[:name].to_s) ||
|
|
264
|
+
[job[:ruby], job[:script]].any? { |payload| payload.to_s.match?(/SessionCleanup|session_cleanup\.rb/) }
|
|
265
|
+
end
|
|
266
|
+
|
|
205
267
|
private_class_method def self.sync_one_job(opts = {})
|
|
206
268
|
job = opts[:job]
|
|
207
269
|
return unless job.is_a?(Hash)
|
|
270
|
+
return if housekeeping_job?(job: job)
|
|
208
271
|
|
|
209
272
|
if job[:enabled] && cron_enabled?
|
|
210
273
|
install_native_job(job: job)
|
|
@@ -218,15 +281,11 @@ module PWN
|
|
|
218
281
|
|
|
219
282
|
# Supported Method Parameters::
|
|
220
283
|
# PWN::Cron.install_defaults
|
|
221
|
-
#
|
|
222
|
-
#
|
|
223
|
-
#
|
|
224
|
-
#
|
|
225
|
-
|
|
226
|
-
# Hygiene jobs are enabled. Curriculum self-play / judge / LoRA are
|
|
227
|
-
# seeded disabled: practice runs Loop.run (real tools) overnight;
|
|
228
|
-
# offline_judge spends API tokens; train needs a GPU trainer.
|
|
229
|
-
DEFAULT_JOBS = [
|
|
284
|
+
# Fresh installs seed one internal housekeeping coordinator. Existing
|
|
285
|
+
# schedules are preserved, not automatically merged or re-enabled.
|
|
286
|
+
# Curriculum templates below require explicit create; installation and
|
|
287
|
+
# migration never seed them, including after operator deletion.
|
|
288
|
+
CURRICULUM_JOBS = [
|
|
230
289
|
{
|
|
231
290
|
name: 'curriculum_practice_nightly',
|
|
232
291
|
schedule: '0 3 * * *',
|
|
@@ -245,29 +304,24 @@ module PWN
|
|
|
245
304
|
ruby: 'PWN::AI::Agent::Curriculum.offline_judge(since_hours: 24) if defined?(PWN::AI::Agent::Curriculum)',
|
|
246
305
|
aliases: %w[offline_judge_nightly],
|
|
247
306
|
enabled: false
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
ruby: 'PWN::AI::Agent::Learning.consolidate if defined?(PWN::AI::Agent::Learning)',
|
|
253
|
-
enabled: true
|
|
254
|
-
},
|
|
307
|
+
}
|
|
308
|
+
].freeze
|
|
309
|
+
|
|
310
|
+
DEFAULT_JOBS = [
|
|
255
311
|
{
|
|
256
312
|
name: 'session_cleanup',
|
|
257
313
|
schedule: '45 3 * * *',
|
|
258
|
-
ruby: "require File.join(PWN::Cron.cron_dir, 'session_cleanup.rb'); PWN::SessionCleanup.
|
|
259
|
-
enabled: true
|
|
260
|
-
},
|
|
261
|
-
{
|
|
262
|
-
name: 'pwn_stores_lean_nightly',
|
|
263
|
-
schedule: '15 5 * * *',
|
|
264
|
-
ruby: 'PWN::AI::Agent::Learning.gc_stores! if defined?(PWN::AI::Agent::Learning)',
|
|
314
|
+
ruby: "require File.join(PWN::Cron.cron_dir, 'session_cleanup.rb'); PWN::SessionCleanup.coordinate(dry_run: false)",
|
|
265
315
|
enabled: true
|
|
266
316
|
}
|
|
267
317
|
].freeze
|
|
268
318
|
|
|
269
319
|
public_class_method def self.install_defaults
|
|
320
|
+
return with_registry_lock { install_defaults } unless registry_owned?
|
|
321
|
+
|
|
270
322
|
install_default_payloads
|
|
323
|
+
require File.expand_path('../../etc/default_cron/session_cleanup', __dir__)
|
|
324
|
+
PWN::SessionCleanup.install(root: File.dirname(cron_dir))
|
|
271
325
|
jobs = load_jobs
|
|
272
326
|
names = jobs.values.map { |j| j[:name].to_s }
|
|
273
327
|
seeded = []
|
|
@@ -282,21 +336,13 @@ module PWN
|
|
|
282
336
|
schedule: spec[:schedule],
|
|
283
337
|
ruby: default_payload(spec: spec),
|
|
284
338
|
delivery: 'log',
|
|
339
|
+
sync_native: false,
|
|
285
340
|
enabled: spec.fetch(:enabled, true)
|
|
286
341
|
)
|
|
287
342
|
names << spec[:name].to_s
|
|
288
343
|
end
|
|
289
344
|
|
|
290
|
-
|
|
291
|
-
if names.include?('curriculum_offline_judge') && names.include?('offline_judge_nightly')
|
|
292
|
-
begin
|
|
293
|
-
dup = jobs.values.find { |j| j[:name].to_s == 'offline_judge_nightly' }
|
|
294
|
-
disable(id: dup[:id]) if dup && dup[:enabled]
|
|
295
|
-
rescue StandardError
|
|
296
|
-
nil
|
|
297
|
-
end
|
|
298
|
-
end
|
|
299
|
-
|
|
345
|
+
PWN::SessionCleanup.request_background(root: File.dirname(cron_dir))
|
|
300
346
|
seeded
|
|
301
347
|
rescue StandardError => e
|
|
302
348
|
warn("[PWN::Cron] install_defaults failed: #{e.class}: #{e.message}")
|
|
@@ -327,7 +373,9 @@ module PWN
|
|
|
327
373
|
|
|
328
374
|
slot = Time.at(((Time.now.to_i / 60) + 1) * 60)
|
|
329
375
|
slot += 60 until slot.hour == 3 && slot.min == 45
|
|
330
|
-
|
|
376
|
+
payload = spec[:ruby]
|
|
377
|
+
payload = payload.sub('SessionCleanup.coordinate(', 'SessionCleanup.maintain(') if load_jobs.values.any? { |job| %w[learning_consolidate_nightly pwn_stores_lean_nightly].include?(job[:name]) }
|
|
378
|
+
"if Time.now.to_i < #{slot.to_i}; { deferred: true, deleted: 0 }; else; #{payload}; end"
|
|
331
379
|
end
|
|
332
380
|
|
|
333
381
|
# ------------------------------------------------------------------
|
|
@@ -410,6 +458,7 @@ module PWN
|
|
|
410
458
|
load_jobs.each_with_object({}) do |(id, job), acc|
|
|
411
459
|
next unless job.is_a?(Hash)
|
|
412
460
|
next unless job[:enabled]
|
|
461
|
+
next if native_execution? && housekeeping_job?(job: job)
|
|
413
462
|
next unless due?(schedule: job[:schedule], last_run: job[:last_run], now: now)
|
|
414
463
|
|
|
415
464
|
acc[id] = job
|
|
@@ -533,7 +582,16 @@ module PWN
|
|
|
533
582
|
|
|
534
583
|
# Single tick used by the loop AND by tests (no sleep).
|
|
535
584
|
public_class_method def self.tick(opts = {})
|
|
536
|
-
run_due(now: opts[:now] || Time.now)
|
|
585
|
+
results = run_due(now: opts[:now] || Time.now)
|
|
586
|
+
return results if native_execution?
|
|
587
|
+
|
|
588
|
+
require File.expand_path('../../etc/default_cron/session_cleanup', __dir__)
|
|
589
|
+
background = PWN::SessionCleanup.background_request(root: File.dirname(cron_dir)) do
|
|
590
|
+
job = load_jobs.values.find { |entry| entry[:name] == 'session_cleanup' && entry[:enabled] }
|
|
591
|
+
results.find { |entry| entry[:id] == job[:id] } || run(id: job[:id]).merge(id: job[:id]) if job
|
|
592
|
+
end
|
|
593
|
+
results << background if background && results.none? { |entry| entry[:id] == background[:id] }
|
|
594
|
+
results
|
|
537
595
|
end
|
|
538
596
|
|
|
539
597
|
# Blocking poll loop. `once: true` runs a single tick then returns
|
|
@@ -1064,6 +1122,7 @@ module PWN
|
|
|
1064
1122
|
|
|
1065
1123
|
public_class_method def self.install_native_job(opts = {})
|
|
1066
1124
|
job = opts[:job]
|
|
1125
|
+
return { skipped: true, reason: :housekeeping_internal_only } if housekeeping_job?(job: job)
|
|
1067
1126
|
return nil unless job.is_a?(Hash) && job[:id]
|
|
1068
1127
|
|
|
1069
1128
|
backend = (opts[:backend] || scheduler_backend).to_sym
|
|
@@ -1080,6 +1139,8 @@ module PWN
|
|
|
1080
1139
|
|
|
1081
1140
|
public_class_method def self.remove_native_job(opts = {})
|
|
1082
1141
|
job = opts[:job] || { id: opts[:id], name: opts[:name] }
|
|
1142
|
+
return { skipped: true, reason: :housekeeping_internal_only } if housekeeping_job?(job: job)
|
|
1143
|
+
|
|
1083
1144
|
backend = (opts[:backend] || scheduler_backend).to_sym
|
|
1084
1145
|
apply = apply_native?(opts)
|
|
1085
1146
|
case backend
|
|
@@ -1110,7 +1171,7 @@ module PWN
|
|
|
1110
1171
|
public_class_method def self.install_crontab_worker(opts = {})
|
|
1111
1172
|
apply = apply_native?(opts)
|
|
1112
1173
|
marker = 'PWN::Cron.ensure_worker'
|
|
1113
|
-
line = "@reboot #{ruby_eval_shell(snippet: 'require "pwn"; PWN::Cron.ensure_worker(restart: false)', log: worker_log)}"
|
|
1174
|
+
line = "@reboot #{ruby_eval_shell(snippet: 'ENV["PWN_CRON_NATIVE"] = "1"; require "pwn"; PWN::Cron.ensure_worker(restart: false)', log: worker_log)}"
|
|
1114
1175
|
preview = "# pwn-cron worker\n#{line}\n"
|
|
1115
1176
|
write_native_preview(name: 'worker.crontab', body: preview)
|
|
1116
1177
|
return { backend: :crontab, apply: false, line: line } unless apply && crontab_available?
|
|
@@ -1124,6 +1185,8 @@ module PWN
|
|
|
1124
1185
|
|
|
1125
1186
|
public_class_method def self.install_crontab_job(opts = {})
|
|
1126
1187
|
job = opts[:job]
|
|
1188
|
+
return { skipped: true, reason: :housekeeping_internal_only } if housekeeping_job?(job: job)
|
|
1189
|
+
|
|
1127
1190
|
apply = apply_native?(opts)
|
|
1128
1191
|
snippet = "require \"pwn\"; PWN::Cron.run(id: #{job[:id].to_s.inspect})"
|
|
1129
1192
|
line = "#{job[:schedule]} #{ruby_eval_shell(snippet: snippet, log: File.join(cron_dir, 'cron.log'))}"
|
|
@@ -1165,7 +1228,7 @@ module PWN
|
|
|
1165
1228
|
public_class_method def self.install_systemd_user_worker(opts = {})
|
|
1166
1229
|
apply = apply_native?(opts)
|
|
1167
1230
|
exec_start = ruby_eval_shell(
|
|
1168
|
-
snippet: 'require "pwn"; PWN::Cron.start_worker(restart: false, foreground: true)'
|
|
1231
|
+
snippet: 'ENV["PWN_CRON_NATIVE"] = "1"; require "pwn"; PWN::Cron.start_worker(restart: false, foreground: true)'
|
|
1169
1232
|
)
|
|
1170
1233
|
body = <<~UNIT
|
|
1171
1234
|
[Unit]
|
|
@@ -1194,6 +1257,8 @@ module PWN
|
|
|
1194
1257
|
|
|
1195
1258
|
public_class_method def self.install_systemd_user_job(opts = {})
|
|
1196
1259
|
job = opts[:job]
|
|
1260
|
+
return { skipped: true, reason: :housekeeping_internal_only } if housekeeping_job?(job: job)
|
|
1261
|
+
|
|
1197
1262
|
apply = apply_native?(opts)
|
|
1198
1263
|
cal = cron_to_calendar(schedule: job[:schedule])
|
|
1199
1264
|
oncal = systemd_on_calendar(calendar: cal)
|
|
@@ -1272,7 +1337,7 @@ module PWN
|
|
|
1272
1337
|
label = LAUNCHD_WORKER_LABEL
|
|
1273
1338
|
plist = launchd_plist(
|
|
1274
1339
|
label: label,
|
|
1275
|
-
argv: ruby_eval_argv(snippet: 'require "pwn"; PWN::Cron.start_worker(restart: false, foreground: true)'),
|
|
1340
|
+
argv: ruby_eval_argv(snippet: 'ENV["PWN_CRON_NATIVE"] = "1"; require "pwn"; PWN::Cron.start_worker(restart: false, foreground: true)'),
|
|
1276
1341
|
keepalive: true,
|
|
1277
1342
|
run_at_load: true
|
|
1278
1343
|
)
|
|
@@ -1283,6 +1348,8 @@ module PWN
|
|
|
1283
1348
|
|
|
1284
1349
|
public_class_method def self.install_launchd_job(opts = {})
|
|
1285
1350
|
job = opts[:job]
|
|
1351
|
+
return { skipped: true, reason: :housekeeping_internal_only } if housekeeping_job?(job: job)
|
|
1352
|
+
|
|
1286
1353
|
apply = apply_native?(opts)
|
|
1287
1354
|
ints = launchd_calendar_interval(schedule: job[:schedule])
|
|
1288
1355
|
return { backend: :launchd, id: job[:id], skipped: true, reason: :no_calendar } unless ints
|
|
@@ -1325,7 +1392,7 @@ module PWN
|
|
|
1325
1392
|
|
|
1326
1393
|
public_class_method def self.install_schtasks_worker(opts = {})
|
|
1327
1394
|
apply = apply_native?(opts)
|
|
1328
|
-
tr = ruby_eval_shell(snippet: 'require "pwn"; PWN::Cron.ensure_worker(restart: false)')
|
|
1395
|
+
tr = ruby_eval_shell(snippet: 'ENV["PWN_CRON_NATIVE"] = "1"; require "pwn"; PWN::Cron.ensure_worker(restart: false)')
|
|
1329
1396
|
argv = ['schtasks', '/Create', '/F', '/TN', SCHTASKS_WORKER_NAME, '/SC', 'ONLOGON', '/RL', 'LIMITED', '/TR', tr]
|
|
1330
1397
|
write_native_preview(name: 'worker.schtasks.txt', body: argv.inspect)
|
|
1331
1398
|
run_argv(argv: argv) if apply && schtasks_available?
|
|
@@ -1334,6 +1401,8 @@ module PWN
|
|
|
1334
1401
|
|
|
1335
1402
|
public_class_method def self.install_schtasks_job(opts = {})
|
|
1336
1403
|
job = opts[:job]
|
|
1404
|
+
return { skipped: true, reason: :housekeeping_internal_only } if housekeeping_job?(job: job)
|
|
1405
|
+
|
|
1337
1406
|
apply = apply_native?(opts)
|
|
1338
1407
|
spec = schtasks_spec(schedule: job[:schedule])
|
|
1339
1408
|
return { backend: :schtasks, id: job[:id], skipped: true, reason: :no_calendar } unless spec
|
|
@@ -1568,6 +1637,7 @@ module PWN
|
|
|
1568
1637
|
script: 'optional - path to external script',
|
|
1569
1638
|
delivery: 'optional - log|stdout (default log)',
|
|
1570
1639
|
enabled: 'optional - enabled value consumed by #create',
|
|
1640
|
+
sync_native: 'optional - synchronize generic native jobs (default true); defaults are internal only',
|
|
1571
1641
|
install_crontab: 'optional - install crontab value consumed by #create'
|
|
1572
1642
|
)
|
|
1573
1643
|
|
|
@@ -1578,7 +1648,8 @@ module PWN
|
|
|
1578
1648
|
|
|
1579
1649
|
# Run remove and return its result
|
|
1580
1650
|
#{self}.remove(
|
|
1581
|
-
id: 'optional - id value consumed by #remove'
|
|
1651
|
+
id: 'optional - id value consumed by #remove',
|
|
1652
|
+
sync_native: 'optional - false deletes only the schedule, preserving native schedulers and evidence'
|
|
1582
1653
|
)
|
|
1583
1654
|
|
|
1584
1655
|
# Run enable and return its result
|
data/lib/pwn/memory.rb
CHANGED
|
@@ -23,6 +23,31 @@ module PWN
|
|
|
23
23
|
EPHEMERAL_KEY_PREFIXES = %w[session_].freeze
|
|
24
24
|
EPHEMERAL_TTL_SECS = 7 * 86_400
|
|
25
25
|
|
|
26
|
+
# Permanent writer inode; nested calls share ownership only in this thread
|
|
27
|
+
# and process. Never unlink this lock when replacing a data file.
|
|
28
|
+
public_class_method def self.transaction(opts = {})
|
|
29
|
+
path = File.expand_path(opts[:path] || MEMORY_FILE)
|
|
30
|
+
return yield if transaction_owned?(path: path)
|
|
31
|
+
|
|
32
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
33
|
+
File.open("#{path}.write.lock", File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
|
|
34
|
+
lock.flock(File::LOCK_EX)
|
|
35
|
+
owners = (Thread.current[:pwn_store_transactions] ||= {})
|
|
36
|
+
owners[path] = Process.pid
|
|
37
|
+
begin
|
|
38
|
+
yield
|
|
39
|
+
ensure
|
|
40
|
+
owners.delete(path)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Test ownership without mistaking an inherited post-fork marker for a lock.
|
|
46
|
+
public_class_method def self.transaction_owned?(opts = {})
|
|
47
|
+
path = File.expand_path(opts[:path] || MEMORY_FILE)
|
|
48
|
+
Thread.current[:pwn_store_transactions]&.fetch(path, nil) == Process.pid
|
|
49
|
+
end
|
|
50
|
+
|
|
26
51
|
# Supported Method Parameters::
|
|
27
52
|
# memory = PWN::Memory.load
|
|
28
53
|
public_class_method def self.load
|
|
@@ -47,6 +72,8 @@ module PWN
|
|
|
47
72
|
# Supported Method Parameters::
|
|
48
73
|
# PWN::Memory.save(mem: memory_hash)
|
|
49
74
|
public_class_method def self.save(opts = {})
|
|
75
|
+
return transaction(path: MEMORY_FILE) { save(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
|
|
76
|
+
|
|
50
77
|
mem = opts[:mem] ||= {}
|
|
51
78
|
force = opts[:force] ? true : false
|
|
52
79
|
FileUtils.mkdir_p(File.dirname(MEMORY_FILE))
|
|
@@ -58,8 +85,15 @@ module PWN
|
|
|
58
85
|
warn "[pwn-ai/memory] refusing empty overwrite of #{File.size(path)}B #{path} (pass force:true to clear)"
|
|
59
86
|
return load_raw_or_empty(path: path)
|
|
60
87
|
end
|
|
88
|
+
atomic_write(path: path, body: JSON.pretty_generate(mem))
|
|
89
|
+
mem
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Caller holds the permanent store transaction across read and replacement.
|
|
93
|
+
private_class_method def self.atomic_write(opts = {})
|
|
94
|
+
path = opts[:path]
|
|
95
|
+
body = opts[:body]
|
|
61
96
|
tmp = File.join(File.dirname(path), ".#{File.basename(path)}.#{Process.pid}.tmp")
|
|
62
|
-
body = JSON.pretty_generate(mem)
|
|
63
97
|
File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o644) do |f|
|
|
64
98
|
f.flock(File::LOCK_EX)
|
|
65
99
|
f.write(body)
|
|
@@ -67,7 +101,7 @@ module PWN
|
|
|
67
101
|
f.fsync
|
|
68
102
|
end
|
|
69
103
|
File.rename(tmp, path)
|
|
70
|
-
|
|
104
|
+
File.open(File.dirname(path), File::RDONLY, &:fsync)
|
|
71
105
|
ensure
|
|
72
106
|
FileUtils.rm_f(tmp) if defined?(tmp) && tmp && File.exist?(tmp)
|
|
73
107
|
end
|
|
@@ -93,6 +127,8 @@ module PWN
|
|
|
93
127
|
# ttl: 'optional - seconds until stale (M3; consolidate evicts stale low-conf first)'
|
|
94
128
|
# )
|
|
95
129
|
public_class_method def self.remember(opts = {})
|
|
130
|
+
return transaction(path: MEMORY_FILE) { remember(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
|
|
131
|
+
|
|
96
132
|
key = opts[:key]
|
|
97
133
|
value = opts[:value]
|
|
98
134
|
category = opts[:category] || :fact
|
|
@@ -479,7 +515,9 @@ module PWN
|
|
|
479
515
|
# force: 'optional - Boolean bypass protect policy (default false)'
|
|
480
516
|
# )
|
|
481
517
|
# Refuses PROTECT_KEY_PREFIXES / PROTECT_CATEGORIES unless force:true.
|
|
482
|
-
public_class_method def self.forget(opts = {})
|
|
518
|
+
public_class_method def self.forget(opts = {})
|
|
519
|
+
return transaction(path: MEMORY_FILE) { forget(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
|
|
520
|
+
|
|
483
521
|
key = opts[:key]
|
|
484
522
|
raise 'ERROR: key is required' if key.nil?
|
|
485
523
|
|
|
@@ -498,6 +536,8 @@ module PWN
|
|
|
498
536
|
# PWN::Memory.clear(force: true)
|
|
499
537
|
# Requires force:true — protected prefs/SOPs must not vanish via bare clear.
|
|
500
538
|
public_class_method def self.clear(opts = {})
|
|
539
|
+
return transaction(path: MEMORY_FILE) { clear(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
|
|
540
|
+
|
|
501
541
|
force = if opts.is_a?(Hash)
|
|
502
542
|
opts[:force] ? true : false
|
|
503
543
|
else
|
|
@@ -514,6 +554,8 @@ module PWN
|
|
|
514
554
|
# context = PWN::Memory.to_context(limit: 20)
|
|
515
555
|
# (used internally by pwn-ai hook to inject into system prompt)
|
|
516
556
|
public_class_method def self.to_context(opts = {})
|
|
557
|
+
return transaction(path: MEMORY_FILE) { to_context(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
|
|
558
|
+
|
|
517
559
|
limit = (opts[:limit] || 12).to_i
|
|
518
560
|
limit = 12 if limit <= 0
|
|
519
561
|
mem = load
|
|
@@ -556,14 +598,20 @@ module PWN
|
|
|
556
598
|
end
|
|
557
599
|
|
|
558
600
|
private_class_method def self.archive_expired!(opts = {})
|
|
601
|
+
return transaction(path: MEMORY_FILE) { archive_expired!(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
|
|
602
|
+
|
|
559
603
|
rows = opts[:rows]
|
|
560
604
|
return if rows.nil? || rows.empty?
|
|
561
605
|
|
|
606
|
+
mem = load
|
|
607
|
+
rows = rows.select { |key, value| mem[key] == value }
|
|
608
|
+
return if rows.empty?
|
|
609
|
+
|
|
562
610
|
arch = File.file?(ARCHIVE_FILE) ? JSON.parse(File.read(ARCHIVE_FILE), symbolize_names: true) : {}
|
|
611
|
+
raise IOError, 'Invalid memory archive' unless arch.is_a?(Hash)
|
|
612
|
+
|
|
563
613
|
arch.merge!(rows)
|
|
564
|
-
|
|
565
|
-
File.write(ARCHIVE_FILE, JSON.pretty_generate(arch))
|
|
566
|
-
mem = load
|
|
614
|
+
atomic_write(path: ARCHIVE_FILE, body: JSON.pretty_generate(arch))
|
|
567
615
|
rows.each_key { |k| mem.delete(k) }
|
|
568
616
|
save(mem: mem, force: mem.empty?)
|
|
569
617
|
rescue StandardError
|
|
@@ -590,6 +638,8 @@ module PWN
|
|
|
590
638
|
# Compact overlong values and drop expired ephemeral session_* keys.
|
|
591
639
|
# Never removes protected prefs/SOPs/fix lessons. Safe with empty-save guard.
|
|
592
640
|
public_class_method def self.lean!(opts = {})
|
|
641
|
+
return transaction(path: MEMORY_FILE) { lean!(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
|
|
642
|
+
|
|
593
643
|
dry = opts[:dry_run] ? true : false
|
|
594
644
|
vmax = (opts[:value_max_chars] || VALUE_MAX_CHARS).to_i
|
|
595
645
|
ttl_secs = (opts[:ephemeral_ttl_secs] || EPHEMERAL_TTL_SECS).to_i
|
|
@@ -650,6 +700,12 @@ module PWN
|
|
|
650
700
|
# Display Usage for this Module
|
|
651
701
|
public_class_method def self.help
|
|
652
702
|
puts "USAGE:
|
|
703
|
+
# Serialize a complete read-modify-write operation.
|
|
704
|
+
#{self}.transaction(path: 'optional - data store path, defaults to memory') { operation }
|
|
705
|
+
|
|
706
|
+
# Check current process and thread ownership.
|
|
707
|
+
#{self}.transaction_owned?(path: 'optional - data store path, defaults to memory')
|
|
708
|
+
|
|
653
709
|
# Run load and return its result
|
|
654
710
|
#{self}.load
|
|
655
711
|
|