Module: PWN::Cron

Defined in:
lib/pwn/cron.rb

Overview

PWN::Cron provides cron / scheduled task management for the pwn-ai agent. Jobs are defined in ~/.pwn/cron/jobs.yml and can be triggered by system cron, manual run, or from within pwn-ai agent loops.

Each job can contain a prompt (for pwn-ai), a ruby script snippet, or reference to external script. Delivery can be 'log' (default), 'email', etc. (email would require additional plugins).

Constant Summary collapse

CRON_DIR =
if ENV['PWN_CRON_DIR'].to_s.strip.empty?
  File.join(Dir.home, '.pwn', 'cron')
else
  ENV.fetch('PWN_CRON_DIR', nil)
end
JOBS_FILE =
File.join(CRON_DIR, 'jobs.yml')
DEFAULT_INTERVAL =

In-process scheduler. System crontab is optional (install_crontab) and install_defaults never writes it, so last_run only moves when something ticks jobs.yml. The worker is that something: poll once or loop, fire due enabled jobs via PWN::Cron.run, and stay alive behind a pidfile so pwn setup can start / restart it.

Spawned workers honor PWN_CRON_DIR so tests can sandbox the daemon without touching the operator's real ~/.pwn/cron.

60
DEFAULT_LOCK_STALE =
7_200
PID_FILE =

Kept for callers / older snippets. Prefer the methods above.

File.join(CRON_DIR, 'worker.pid')
WORKER_LOG =
File.join(CRON_DIR, 'worker.log')

Class Method Summary collapse

Class Method Details

.authorsObject

Author(s)

0day Inc. support@0dayinc.com



686
687
688
# File 'lib/pwn/cron.rb', line 686

public_class_method def self.authors
  "AUTHOR(S):\n  0day Inc. <support@0dayinc.com>\n"
end

.create(opts = {}) ⇒ Object

Supported Method Parameters

job = PWN::Cron.create( name: 'optional', schedule: 'required e.g. "0 * * * *" or "30m" or "every 2h"', prompt: 'optional - pwn-ai prompt to run', ruby: 'optional - ruby snippet to eval', script: 'optional - path to external script', delivery: 'log|stdout (default log)', enabled: true )



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/pwn/cron.rb', line 48

public_class_method def self.create(opts = {})
  jobs = load_jobs
  id = SecureRandom.hex(6)
  name = opts[:name] || "job-#{id}"
  job = {
    id: id,
    name: name,
    schedule: opts[:schedule] || '0 * * * *',
    prompt: opts[:prompt],
    ruby: opts[:ruby],
    script: opts[:script],
    delivery: opts[:delivery] || 'log',
    enabled: opts.fetch(:enabled, true),
    created_at: Time.now.utc.iso8601,
    last_run: nil,
    last_status: nil
  }
  jobs[id] = job
  save_jobs(jobs: jobs)

  # Optionally install a crontab entry (user must have permission)
  install_crontab_entry(job: job) if opts[:install_crontab]

  job
end

.cron_dirObject

Supported Method Parameters

dir = PWN::Cron.cron_dir



27
28
29
30
# File 'lib/pwn/cron.rb', line 27

public_class_method def self.cron_dir
  FileUtils.mkdir_p(CRON_DIR)
  CRON_DIR
end

.disable(opts = {}) ⇒ Object



155
156
157
# File 'lib/pwn/cron.rb', line 155

public_class_method def self.disable(opts = {})
  toggle(id: opts[:id], enabled: false)
end

.due?(opts = {}) ⇒ Boolean

Supported Method Parameters

PWN::Cron.due?( schedule: 'required - 5-field cron or relative ("30m", "every 2h")', last_run: 'optional - Time / iso8601 / nil', now: 'optional - Time (default Time.now)' )

Returns:

  • (Boolean)


319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
# File 'lib/pwn/cron.rb', line 319

public_class_method def self.due?(opts = {})
  schedule = opts[:schedule].to_s.strip
  return false if schedule.empty?

  now = opts[:now] || Time.now
  last = parse_time(value: opts[:last_run])

  if (secs = relative_seconds(schedule: schedule))
    return true if last.nil?

    return (now - last) >= secs
  end

  fields = schedule.split
  return false unless fields.length == 5

  prev = last || (now - (24 * 60 * 60))
  # Walk minute-aligned slots from the minute AFTER last_run
  # (or the last 24h if never run) up to `now`.
  t = Time.at(((prev.to_i / 60) + 1) * 60)
  t = Time.local(t.year, t.month, t.day, t.hour, t.min, 0)
  limit = Time.local(now.year, now.month, now.day, now.hour, now.min, 0)
  safety = 0
  while t <= limit && safety < 10_080
    return true if cron_match?(fields: fields, time: t)

    t += 60
    safety += 1
  end
  false
rescue StandardError
  false
end

.due_jobs(opts = {}) ⇒ Object

Supported Method Parameters

PWN::Cron.due_jobs(now: Time)



355
356
357
358
359
360
361
362
363
364
# File 'lib/pwn/cron.rb', line 355

public_class_method def self.due_jobs(opts = {})
  now = opts[:now] || Time.now
  load_jobs.each_with_object({}) do |(id, job), acc|
    next unless job.is_a?(Hash)
    next unless job[:enabled]
    next unless due?(schedule: job[:schedule], last_run: job[:last_run], now: now)

    acc[id] = job
  end
end

.enable(opts = {}) ⇒ Object

Supported Method Parameters

PWN::Cron.enable/disable(id:)



151
152
153
# File 'lib/pwn/cron.rb', line 151

public_class_method def self.enable(opts = {})
  toggle(id: opts[:id], enabled: true)
end

.ensure_worker(opts = {}) ⇒ Object

PWN::Setup hook — start (or restart) the background worker after any setup action so YAML-only default jobs actually fire.



518
519
520
521
522
523
524
# File 'lib/pwn/cron.rb', line 518

public_class_method def self.ensure_worker(opts = {})
  start_worker(
    interval: opts[:interval] || DEFAULT_INTERVAL,
    restart: opts.fetch(:restart, false),
    foreground: false
  )
end

.helpObject

Display Usage for this Module



691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
# File 'lib/pwn/cron.rb', line 691

public_class_method def self.help
  puts <<~USAGE
            USAGE:
              PWN::Cron.create(schedule: '0 * * * *', prompt: 'Run daily recon on target.com using NmapIt and report', name: 'daily-recon')
              PWN::Cron.list
              res = PWN::Cron.run(id: 'abc123')
              PWN::Cron.enable(id: 'abc123')
              PWN::Cron.disable(id: 'abc123')
              PWN::Cron.remove(id: 'abc123')
              # To have system cron call it, use install_crontab_entry or the :install_crontab option on create
    PWN::Cron.install_defaults  # seed S1/P3/W2/M1 curriculum jobs (idempotent)
    PWN::Cron.ensure_worker     # start/restart the background scheduler
    PWN::Cron.worker_status
    PWN::Cron.tick              # fire currently-due jobs once

              #{self}.authors
  USAGE
end

.install_crontab_entry(opts = {}) ⇒ Object

Install a crontab line that invokes this job via pwn (assumes /opt/pwn and the active rvm ruby@pwn gemset - user can edit crontab)



161
162
163
164
165
166
167
168
169
170
171
# File 'lib/pwn/cron.rb', line 161

public_class_method def self.install_crontab_entry(opts = {})
  job = opts[:job]
  cron_line = "#{job[:schedule]} cd /opt/pwn && /usr/local/rvm/bin/rvm ruby-#{RUBY_VERSION}@pwn do ruby -I lib -e 'require \"pwn\"; PWN::Cron.run(id: \"#{job[:id]}\")' >> #{File.join(cron_dir, 'cron.log')} 2>&1"
  # Append to user's crontab (non-destructive)
  existing = `crontab -l 2>/dev/null || true`
  unless existing.include?(job[:id])
    new_cron = existing + "\n# pwn-cron #{job[:name]} (#{job[:id]})\n#{cron_line}\n"
    IO.popen('crontab -', 'w') { |io| io.write(new_cron) }
  end
  cron_line
end

.install_defaultsObject

Supported Method Parameters

PWN::Cron.install_defaults

Idempotently seed the RL feedback-loop cron jobs so a fresh install closes the loop by default (S1 practice + P3 offline_judge + W2 train dry-run + M1 consolidate). Re-running is a no-op if jobs with these names already exist. These are seeded to jobs.yml only — pass install_crontab: true to PWN::Cron.create yourself if you also want a system crontab entry.



215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/pwn/cron.rb', line 215

public_class_method def self.install_defaults
  jobs = load_jobs
  names = jobs.values.map { |j| j[:name] }
  seeded = []

  unless names.include?('curriculum_practice_nightly')
    seeded << create(
      name: 'curriculum_practice_nightly',
      schedule: '0 3 * * *',
      ruby: 'PWN::AI::Agent::Curriculum.practice(limit: 3) if defined?(PWN::AI::Agent::Curriculum)',
      delivery: 'log',
      enabled: true
    )
  end

  unless names.include?('curriculum_train_weekly')
    seeded << create(
      name: 'curriculum_train_weekly',
      schedule: '0 4 * * 0',
      ruby: 'PWN::AI::Agent::Curriculum.train_and_gate(dry_run: true) if defined?(PWN::AI::Agent::Curriculum)',
      delivery: 'log',
      enabled: true
    )
  end

  # P3 — backfill ORM/PRM labels when local :failure_only introspect is on.
  # P13 — treat legacy alias offline_judge_nightly as the same job so
  # install_defaults never double-seeds the 30 3 * * * slot.
  offline_names = %w[curriculum_offline_judge offline_judge_nightly]
  unless names.intersect?(offline_names)
    seeded << create(
      name: 'curriculum_offline_judge',
      schedule: '30 3 * * *',
      ruby: 'PWN::AI::Agent::Curriculum.offline_judge(since_hours: 24, limit: 40) if defined?(PWN::AI::Agent::Curriculum)',
      delivery: 'log',
      enabled: true
    )
  end
  # Disable duplicate alias if both exist (idempotent cleanup).
  if names.include?('curriculum_offline_judge') && names.include?('offline_judge_nightly')
    begin
      dup = jobs.values.find { |j| j[:name].to_s == 'offline_judge_nightly' }
      disable(id: dup[:id]) if dup && dup[:enabled]
    rescue StandardError
      nil
    end
  end

  # M1/M3 — nightly memory GC so the injected MEMORY block stays high-signal
  unless names.include?('learning_consolidate_nightly')
    seeded << create(
      name: 'learning_consolidate_nightly',
      schedule: '0 5 * * *',
      ruby: 'PWN::AI::Agent::Learning.consolidate if defined?(PWN::AI::Agent::Learning)',
      delivery: 'log',
      enabled: true
    )
  end

  seeded
rescue StandardError => e
  warn("[PWN::Cron] install_defaults failed: #{e.class}: #{e.message}")
  []
end

.install_worker_crontabObject

Supported Method Parameters

PWN::Cron.install_worker_crontab

Append an @reboot line so the worker comes back after a reboot. Idempotent. Does not remove existing per-job crontab lines.



530
531
532
533
534
535
536
537
538
539
540
541
542
543
# File 'lib/pwn/cron.rb', line 530

public_class_method def self.install_worker_crontab
  marker = 'PWN::Cron.ensure_worker'
  existing = `crontab -l 2>/dev/null || true`
  return existing if existing.include?(marker)

  ruby = worker_ruby_cmd
  cron_line = "@reboot #{ruby} -e 'require \"pwn\"; PWN::Cron.ensure_worker(restart: false)' >> #{worker_log} 2>&1"
  new_cron = "#{existing}\n# pwn-cron worker\n#{cron_line}\n"
  IO.popen('crontab -', 'w') { |io| io.write(new_cron) }
  cron_line
rescue StandardError => e
  warn("[PWN::Cron] install_worker_crontab failed: #{e.class}: #{e.message}")
  nil
end

.jobs_fileObject

Supported Method Parameters

PWN::Cron.jobs_file / PWN::Cron.pid_file / PWN::Cron.worker_log

Runtime paths follow cron_dir (and therefore a stubbed CRON_DIR).



297
298
299
# File 'lib/pwn/cron.rb', line 297

public_class_method def self.jobs_file
  File.join(cron_dir, 'jobs.yml')
end

.listObject

Supported Method Parameters

jobs = PWN::Cron.list



34
35
36
# File 'lib/pwn/cron.rb', line 34

public_class_method def self.list
  load_jobs
end

.pid_fileObject



301
302
303
# File 'lib/pwn/cron.rb', line 301

public_class_method def self.pid_file
  File.join(cron_dir, 'worker.pid')
end

.remove(opts = {}) ⇒ Object

rubocop:disable Naming/PredicateMethod



141
142
143
144
145
146
147
# File 'lib/pwn/cron.rb', line 141

public_class_method def self.remove(opts = {}) # rubocop:disable Naming/PredicateMethod
  id = opts[:id].to_s
  jobs = load_jobs
  jobs.delete(id)
  save_jobs(jobs: jobs)
  true
end

.run(opts = {}) ⇒ Object

Supported Method Parameters

PWN::Cron.run(id: 'required or name') Executes the job (for pwn-ai prompt it will use current active AI engine via PWN::AI::* but without full REPL hook unless in pwn-ai).



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/pwn/cron.rb', line 78

public_class_method def self.run(opts = {})
  id = opts[:id].to_s
  jobs = load_jobs
  job = jobs[id] || jobs.values.find { |j| j[:name] == id || j[:id] == id }
  raise "Job #{id} not found" unless job

  start = Time.now
  result = nil
  status = 'success'

  begin
    if job[:prompt]
      engine = begin
        PWN::Env[:ai][:active].to_s.downcase.to_sym
      rescue StandardError
        :grok
      end
      case engine
      when :grok
        result = PWN::AI::Grok.chat(request: job[:prompt], spinner: false)
      when :ollama
        result = PWN::AI::Ollama.chat(request: job[:prompt], spinner: false)
      when :openai
        result = PWN::AI::OpenAI.chat(request: job[:prompt], spinner: false)
      when :anthropic
        result = PWN::AI::Anthropic.chat(request: job[:prompt], spinner: false)
      when :gemini
        result = PWN::AI::Gemini.chat(request: job[:prompt], spinner: false)
      end
      result = begin
        result[:choices].last[:content]
      rescue StandardError
        result.to_s
      end
    elsif job[:ruby]
      result = eval(job[:ruby], TOPLEVEL_BINDING) # rubocop:disable Security/Eval
    elsif job[:script] && File.exist?(job[:script])
      result = `#{job[:script]} 2>&1`
    else
      result = 'No prompt/ruby/script defined'
    end

    if job[:delivery] == 'log'
      log_path = File.join(cron_dir, "#{job[:id]}.log")
      File.open(log_path, 'a') do |f|
        f.puts("[#{Time.now}] RUN #{job[:name]} (#{job[:id]})\n#{result}\n---")
      end
    end
  rescue StandardError => e
    status = 'error'
    result = "ERROR: #{e.class} - #{e.message}\n#{e.backtrace.first(5).join("\n")}"
  end

  job[:last_run] = Time.now.utc.iso8601
  job[:last_status] = status
  jobs[job[:id]] = job
  save_jobs(jobs: jobs)

  { job: job, result: result, duration: Time.now - start, status: status }
end

.run_due(opts = {}) ⇒ Object

Supported Method Parameters

PWN::Cron.run_due(now: Time, jobs: optional Hash)

Fires every currently-due enabled job via run(). Returns the list of status:, duration: hashes (empty when nothing is due).



370
371
372
373
374
375
376
377
378
379
# File 'lib/pwn/cron.rb', line 370

public_class_method def self.run_due(opts = {})
  now = opts[:now] || Time.now
  jobs = opts[:jobs] || due_jobs(now: now)
  jobs.map do |id, _job|
    res = run(id: id)
    { id: id, status: res[:status], duration: res[:duration] }
  rescue StandardError => e
    { id: id, status: 'error', error: "#{e.class}: #{e.message}" }
  end
end

.start_worker(opts = {}) ⇒ Object

Supported Method Parameters

PWN::Cron.start_worker( interval: 60, foreground: false, restart: true )

Idempotent. With restart:true (default) a live worker is replaced so pwn setup always leaves a current worker running.



402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
# File 'lib/pwn/cron.rb', line 402

public_class_method def self.start_worker(opts = {})
  interval = (opts[:interval] || DEFAULT_INTERVAL).to_i
  interval = DEFAULT_INTERVAL if interval <= 0
  foreground = opts[:foreground] ? true : false
  restart = opts.fetch(:restart, true)

  st = worker_status
  return { started: false, already_running: true, pid: st[:pid], interval: interval } if st[:running] && !restart && !foreground

  stop_worker if st[:running] && (restart || foreground)

  if foreground
    write_pid(pid: Process.pid)
    begin
      worker_loop(interval: interval)
    ensure
      clear_pid(pid: Process.pid)
    end
    return { started: true, pid: Process.pid, foreground: true, interval: interval }
  end

  FileUtils.mkdir_p(cron_dir)
  spawn_env = ENV.to_h.merge('PWN_CRON_DIR' => cron_dir.to_s)
  pid = Process.spawn(
    spawn_env,
    RbConfig.ruby,
    '-I', File.expand_path('..', __dir__),
    '-e', worker_spawn_snippet(interval: interval),
    %i[out err] => [worker_log, 'a'],
    in: File::NULL,
    pgroup: true
  )
  Process.detach(pid)

  # The child writes the pidfile; wait briefly.
  waited = 0
  live = nil
  while waited < 50
    live = read_pid
    break if live && process_alive?(pid: live)

    sleep 0.1
    waited += 1
  end
  {
    started: !(live && process_alive?(pid: live)).nil?,
    pid: live,
    spawn_pid: pid,
    interval: interval,
    log: worker_log,
    pid_file: pid_file
  }
end

.stop_workerObject

Supported Method Parameters

PWN::Cron.stop_worker



458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
# File 'lib/pwn/cron.rb', line 458

public_class_method def self.stop_worker
  pid = read_pid
  killed = false
  if pid && process_alive?(pid: pid)
    begin
      Process.kill('TERM', pid)
      20.times do
        break unless process_alive?(pid: pid)

        sleep 0.05
      end
      Process.kill('KILL', pid) if process_alive?(pid: pid)
      killed = true
    rescue Errno::ESRCH, Errno::EPERM
      nil
    end
  end
  FileUtils.rm_f(pid_file)
  { stopped: true, pid: pid, killed: killed }
end

.tick(opts = {}) ⇒ Object

Single tick used by the loop AND by tests (no sleep).



480
481
482
# File 'lib/pwn/cron.rb', line 480

public_class_method def self.tick(opts = {})
  run_due(now: opts[:now] || Time.now)
end

.worker_logObject



305
306
307
# File 'lib/pwn/cron.rb', line 305

public_class_method def self.worker_log
  File.join(cron_dir, 'worker.log')
end

.worker_loop(opts = {}) ⇒ Object

rubocop:disable Naming/PredicateMethod



487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
# File 'lib/pwn/cron.rb', line 487

public_class_method def self.worker_loop(opts = {}) # rubocop:disable Naming/PredicateMethod
  interval = (opts[:interval] || DEFAULT_INTERVAL).to_i
  interval = DEFAULT_INTERVAL if interval <= 0
  once = opts[:once] ? true : false
  trap_shutdown! unless once

  loop do
    begin
      tick
    rescue StandardError => e
      warn("[PWN::Cron] worker tick failed: #{e.class}: #{e.message}")
    end
    break if once
    break if @worker_stop
    break if File.exist?(pid_file) && read_pid != Process.pid

    slept = 0
    while slept < interval
      break if @worker_stop

      step = [1, interval - slept].min
      sleep(step)
      slept += step
    end
    break if @worker_stop
  end
  true
end

.worker_statusObject

Supported Method Parameters

PWN::Cron.worker_status



383
384
385
386
387
388
389
390
391
392
# File 'lib/pwn/cron.rb', line 383

public_class_method def self.worker_status
  pid = read_pid
  running = pid && process_alive?(pid: pid)
  {
    running: running ? true : false,
    pid: running ? pid : nil,
    pid_file: pid_file,
    log: worker_log
  }
end