pwn 0.5.783 → 0.5.785

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 (264) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +8 -8
  4. data/README.md +37 -0
  5. data/Rakefile +19 -1
  6. data/bin/pwn +2 -1
  7. data/bin/pwn_setup +1 -1
  8. data/bin/pwn_web_cache_deception +35 -7
  9. data/build_gem.sh +1 -1
  10. data/documentation/AI-Integration.md +7 -5
  11. data/documentation/Agent-Tool-Registry.md +2 -2
  12. data/documentation/Backup-Retention.md +82 -0
  13. data/documentation/Configuration.md +10 -6
  14. data/documentation/Contributing.md +1 -1
  15. data/documentation/Cron.md +64 -35
  16. data/documentation/Diagrams.md +2 -2
  17. data/documentation/General-PWN-Usage.md +4 -3
  18. data/documentation/Home.md +6 -5
  19. data/documentation/How-PWN-Works.md +7 -6
  20. data/documentation/Installation.md +254 -10
  21. data/documentation/Mission-Cancellation.md +134 -0
  22. data/documentation/Operability.md +17 -0
  23. data/documentation/Persistence.md +8 -6
  24. data/documentation/Plugin-Degradation.md +12 -0
  25. data/documentation/Plugins.md +3 -2
  26. data/documentation/Reinforcement-Learning.md +5 -6
  27. data/documentation/Runtime-Source-Protection.md +162 -0
  28. data/documentation/Transparent-Browser.md +11 -0
  29. data/documentation/Troubleshooting.md +3 -2
  30. data/documentation/What-is-PWN.md +2 -2
  31. data/documentation/YubiKey-Vaults.md +183 -0
  32. data/documentation/YubiKey.md +339 -0
  33. data/documentation/diagrams/cron-scheduling.svg +130 -128
  34. data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
  35. data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
  36. data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
  37. data/documentation/diagrams/persistence-filesystem.svg +172 -142
  38. data/documentation/diagrams/plugin-ecosystem.svg +64 -64
  39. data/documentation/pwn-ai-Agent.md +10 -4
  40. data/etc/default_cron/session_cleanup.rb +1245 -22
  41. data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
  42. data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
  43. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
  44. data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
  45. data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
  46. data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
  47. data/etc/default_skills/pwn/backups/SKILL.md +54 -0
  48. data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
  49. data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
  50. data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
  51. data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
  52. data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
  53. data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
  54. data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
  55. data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
  56. data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
  57. data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
  58. data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
  59. data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
  60. data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
  61. data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
  62. data/lib/pwn/ai/agent/dispatch.rb +26 -15
  63. data/lib/pwn/ai/agent/extrospection.rb +6 -7
  64. data/lib/pwn/ai/agent/learning.rb +62 -25
  65. data/lib/pwn/ai/agent/loop.rb +17 -1
  66. data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
  67. data/lib/pwn/ai/agent/policy.rb +5 -0
  68. data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
  69. data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
  70. data/lib/pwn/ai/agent/request_runtime.rb +122 -18
  71. data/lib/pwn/ai/agent/reward.rb +5 -3
  72. data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
  73. data/lib/pwn/ai/agent/skill_review.rb +9 -5
  74. data/lib/pwn/ai/agent/swarm.rb +35 -16
  75. data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
  76. data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
  77. data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
  78. data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
  79. data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
  80. data/lib/pwn/ai/agent.rb +1 -0
  81. data/lib/pwn/ai/anthropic.rb +8 -33
  82. data/lib/pwn/ai/cli.rb +34 -5
  83. data/lib/pwn/ai/gemini.rb +1 -0
  84. data/lib/pwn/ai/grok.rb +39 -28
  85. data/lib/pwn/ai/ollama.rb +4 -2
  86. data/lib/pwn/ai/open_ai.rb +11 -32
  87. data/lib/pwn/ai/open_web_ui.rb +62 -4
  88. data/lib/pwn/ai/router.rb +53 -3
  89. data/lib/pwn/ai/server_tools.rb +231 -0
  90. data/lib/pwn/ai.rb +1 -0
  91. data/lib/pwn/backups.rb +347 -0
  92. data/lib/pwn/config.rb +70 -39
  93. data/lib/pwn/cron.rb +120 -49
  94. data/lib/pwn/migrate.rb +150 -68
  95. data/lib/pwn/module_skills.rb +7 -0
  96. data/lib/pwn/plugins/ai_sandbox.rb +538 -1
  97. data/lib/pwn/plugins/authentication_helper.rb +74 -4
  98. data/lib/pwn/plugins/basic_auth.rb +2 -1
  99. data/lib/pwn/plugins/binary_parser.rb +5 -4
  100. data/lib/pwn/plugins/ghidra.rb +4 -3
  101. data/lib/pwn/plugins/google_workspace.rb +4 -21
  102. data/lib/pwn/plugins/jobs.rb +1 -1
  103. data/lib/pwn/plugins/log.rb +21 -3
  104. data/lib/pwn/plugins/preflight_checker.rb +34 -9
  105. data/lib/pwn/plugins/recon.rb +4 -1
  106. data/lib/pwn/plugins/repl/ai.rb +305 -27
  107. data/lib/pwn/plugins/repl/ai_console.rb +273 -89
  108. data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
  109. data/lib/pwn/plugins/repl/mesh.rb +3 -16
  110. data/lib/pwn/plugins/repl/vault.rb +4 -15
  111. data/lib/pwn/plugins/repl.rb +1 -216
  112. data/lib/pwn/plugins/sandbox/driver.rb +4 -0
  113. data/lib/pwn/plugins/transparent_browser.rb +215 -0
  114. data/lib/pwn/plugins/vault.rb +412 -135
  115. data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
  116. data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
  117. data/lib/pwn/plugins/yubikey/management.rb +686 -0
  118. data/lib/pwn/plugins/yubikey/oath.rb +766 -0
  119. data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
  120. data/lib/pwn/plugins/yubikey/otp.rb +897 -0
  121. data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
  122. data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
  123. data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
  124. data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
  125. data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
  126. data/lib/pwn/plugins/yubikey.rb +844 -0
  127. data/lib/pwn/plugins.rb +1 -0
  128. data/lib/pwn/redaction.rb +69 -12
  129. data/lib/pwn/session_trace.rb +4 -0
  130. data/lib/pwn/sessions.rb +58 -16
  131. data/lib/pwn/setup.rb +123 -41
  132. data/lib/pwn/version.rb +1 -1
  133. data/lib/pwn/www/app_cobalt_io.rb +16 -5
  134. data/lib/pwn/www/bug_crowd.rb +16 -5
  135. data/lib/pwn/www/coinbase_pro.rb +16 -5
  136. data/lib/pwn/www/github.rb +30 -14
  137. data/lib/pwn/www/paypal.rb +15 -4
  138. data/lib/pwn/www/synack.rb +16 -5
  139. data/lib/pwn/www/twitter.rb +17 -6
  140. data/lib/pwn.rb +1 -0
  141. data/scripts/build_gate.rb +58 -0
  142. data/spec/documentation/installation_md_spec.rb +1 -1
  143. data/spec/fixtures/yubikey/apdu_frames.json +162 -0
  144. data/spec/integration/authentication_helper_oath_spec.rb +117 -0
  145. data/spec/integration/build_gate_spec.rb +73 -0
  146. data/spec/integration/controller_spill_spec.rb +48 -0
  147. data/spec/integration/harness_process_spec.rb +97 -0
  148. data/spec/integration/mission_cancel_spec.rb +79 -0
  149. data/spec/integration/mission_curses_pty_spec.rb +44 -0
  150. data/spec/integration/persistence_roundtrip_spec.rb +1 -7
  151. data/spec/integration/readme_security_spec.rb +47 -0
  152. data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
  153. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
  154. data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
  155. data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
  156. data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
  157. data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
  158. data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
  159. data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
  160. data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
  161. data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
  162. data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
  163. data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
  164. data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
  165. data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
  166. data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
  167. data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
  168. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
  169. data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
  170. data/spec/lib/pwn/ai/cli_spec.rb +6 -9
  171. data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
  172. data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
  173. data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
  174. data/spec/lib/pwn/ai/router_spec.rb +31 -3
  175. data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
  176. data/spec/lib/pwn/backups_spec.rb +114 -0
  177. data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
  178. data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
  179. data/spec/lib/pwn/cron_spec.rb +113 -6
  180. data/spec/lib/pwn/migrate_spec.rb +108 -5
  181. data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
  182. data/spec/lib/pwn/module_skills_spec.rb +8 -0
  183. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
  184. data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
  185. data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
  186. data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
  187. data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
  188. data/spec/lib/pwn/plugins/log_spec.rb +7 -0
  189. data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
  190. data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
  191. data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
  192. data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
  193. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +125 -78
  194. data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
  195. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -16
  196. data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
  197. data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
  198. data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
  199. data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
  200. data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
  201. data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
  202. data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
  203. data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
  204. data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
  205. data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
  206. data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
  207. data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
  208. data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
  209. data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
  210. data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
  211. data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
  212. data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
  213. data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
  214. data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
  215. data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
  216. data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
  217. data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
  218. data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
  219. data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
  220. data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
  221. data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
  222. data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
  223. data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
  224. data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
  225. data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
  226. data/spec/lib/pwn/redaction_spec.rb +13 -10
  227. data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
  228. data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
  229. data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
  230. data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
  231. data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
  232. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
  233. data/spec/lib/pwn/sessions_spec.rb +112 -0
  234. data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
  235. data/spec/lib/pwn/setup_spec.rb +3 -1
  236. data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
  237. data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
  238. data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
  239. data/spec/spec_helper.rb +15 -0
  240. data/spec/support/gate_failure_formatter.rb +14 -0
  241. data/spec/support/harness/cli_faults.rb +13 -9
  242. data/spec/support/harness/controller_spill.rb +26 -0
  243. data/spec/support/harness/controller_state.rb +54 -0
  244. data/spec/support/harness/evaluate.rb +12 -1
  245. data/spec/support/harness/experiments.rb +1 -1
  246. data/spec/support/harness/fake_provider.rb +22 -6
  247. data/spec/support/harness/fault_preload.rb +3 -1
  248. data/spec/support/harness/mission_console.rb +54 -0
  249. data/spec/support/harness/mission_curses_pty.rb +41 -0
  250. data/spec/support/harness/mission_dispatch.rb +30 -0
  251. data/spec/support/harness/mission_fixture_preload.rb +24 -0
  252. data/spec/support/harness/mission_job_lifetime.rb +25 -0
  253. data/spec/support/harness/mission_legacy.rb +25 -0
  254. data/spec/support/harness/mission_normal_exit.rb +34 -0
  255. data/spec/support/harness/mission_swarm.rb +23 -0
  256. data/spec/support/harness/mission_unsupported.rb +29 -0
  257. data/spec/support/harness/package_acceptance.rb +33 -5
  258. data/spec/support/harness/session_acceptance.rb +4 -4
  259. data/spec/support/harness/swarm_acceptance.rb +1 -1
  260. data/spec/support/harness/swarm_control.rb +44 -0
  261. data/spec/support/mission_namespace.rb +14 -0
  262. data/spec/support/mission_pipe_queue.rb +33 -0
  263. data/third_party/pwn_rdoc.jsonl +599 -15
  264. metadata +140 -17
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe PWN::Backups do
6
+ before do
7
+ @home = Dir.mktmpdir('pwn-backups-')
8
+ Thread.current[:pwn_backup_root] = nil
9
+ allow(Dir).to receive(:home).and_return(@home)
10
+ @source = File.join(@home, 'source')
11
+ File.write(@source, 'original')
12
+ end
13
+
14
+ after { FileUtils.remove_entry(@home) }
15
+
16
+ it 'retains no more than seven generations across producers' do
17
+ 10.times { described_class.capture(path: @source, label: 'fixture') }
18
+ root = File.join(@home, '.pwn', 'backup')
19
+ expect(Dir.children(root).count { |name| File.directory?(File.join(root, name)) }).to eq(7)
20
+ end
21
+
22
+ it 'keeps interrupted transactions prepared and refuses an eighth pinned generation' do
23
+ 7.times do
24
+ expect do
25
+ described_class.transaction do
26
+ described_class.capture(path: @source, label: 'fixture')
27
+ raise IOError, 'interrupted'
28
+ end
29
+ end.to raise_error(IOError, 'interrupted')
30
+ end
31
+ expect { described_class.capture(path: @source) }.to raise_error(IOError, /capacity/)
32
+ expect(File.read(@source)).to eq('original')
33
+ end
34
+
35
+ it 'pins actual evidence paths but not quoted inventory prose' do
36
+ copies = 7.times.map { described_class.capture(path: @source) }
37
+ File.write(File.join(@home, '.pwn', 'learning.jsonl'), JSON.generate(evidence_path: copies.first, prose: "old path #{copies[1]}"))
38
+ described_class.capture(path: @source)
39
+ expect(File).to exist(copies.first)
40
+ expect(File).not_to exist(copies[1])
41
+ end
42
+
43
+ it 'fails admission on malformed reference data without losing any recovery copy' do
44
+ copies = 7.times.map { described_class.capture(path: @source) }
45
+ File.write(File.join(@home, '.pwn', 'learning.jsonl'), '{broken')
46
+ expect { described_class.capture(path: @source) }.to raise_error(IOError, /references/)
47
+ expect(copies.all? { |path| File.file?(path) }).to be(true)
48
+ end
49
+
50
+ it 'uses one generation for nested producers and preserves failed result recovery' do
51
+ copies = []
52
+ described_class.transaction do
53
+ copies << described_class.capture(path: @source, label: 'learning')
54
+ copies << described_class.capture(path: @source, label: 'preferences')
55
+ { ok: false, error: 'fixture' }
56
+ end
57
+ generations = copies.map { |path| File.dirname(path, 2) }.uniq
58
+ expect(generations.length).to eq(1)
59
+ expect(JSON.parse(File.read(File.join(generations.first, '.generation.json')))['state']).to eq('prepared')
60
+ end
61
+
62
+ it 'serializes separate processes at the global seven-folder bound' do
63
+ root = File.join(@home, '.pwn', 'backup')
64
+ pids = 4.times.map do
65
+ fork do
66
+ 4.times { described_class.capture(path: @source) }
67
+ exit! 0
68
+ rescue StandardError
69
+ exit! 1
70
+ end
71
+ end
72
+ expect(pids.map { |pid| Process.wait2(pid).last.exitstatus }).to eq([0, 0, 0, 0])
73
+ expect(Dir.children(root).count { |name| File.directory?(File.join(root, name)) }).to eq(7)
74
+ end
75
+
76
+ it 'requires exact manifest acknowledgement to release interrupted recovery' do
77
+ backup = nil
78
+ expect do
79
+ described_class.transaction do
80
+ backup = described_class.capture(path: @source)
81
+ raise IOError, 'interrupted'
82
+ end
83
+ end.to raise_error(IOError)
84
+ generation = File.basename(File.dirname(backup, 2))
85
+ manifest = File.join(File.dirname(backup, 2), '.generation.json')
86
+ expect { described_class.reconcile(generation: generation, confirmed: true, expected_sha256: 'wrong') }.to raise_error(IOError)
87
+ result = described_class.reconcile(generation: generation, confirmed: true, expected_sha256: Digest::SHA256.file(manifest).hexdigest, pinned: false)
88
+ expect(result).to include(state: 'complete', pinned: false)
89
+ expect(File.read(backup)).to eq('original')
90
+ end
91
+
92
+ it 'relocates only explicitly selected unreferenced legacy copies after quiescence confirmation' do
93
+ root = File.join(@home, '.pwn')
94
+ FileUtils.mkdir_p(root)
95
+ legacy = File.join(root, 'learning.jsonl.bak-lean-20200101')
96
+ File.write(legacy, 'legacy bytes')
97
+ expect(described_class.relocate_legacy(paths: [legacy])[:moved]).to eq([])
98
+ expect { described_class.relocate_legacy(paths: [legacy], dry_run: false) }.to raise_error(ArgumentError, /writers/)
99
+ File.write(File.join(root, 'memory.json'), JSON.generate(evidence: legacy))
100
+ expect { described_class.relocate_legacy(paths: [legacy], dry_run: false, writers_stopped: true) }.to raise_error(IOError, /referenced/)
101
+ File.write(File.join(root, 'memory.json'), '{}')
102
+ result = described_class.relocate_legacy(paths: [legacy], dry_run: false, writers_stopped: true)
103
+ expect(File).not_to exist(legacy)
104
+ expect(File.read(result[:moved].fetch(legacy))).to eq('legacy bytes')
105
+ end
106
+
107
+ it 'tightens an owned group-writable backup directory before writing' do
108
+ root = File.join(@home, '.pwn', 'backup')
109
+ FileUtils.mkdir_p(root)
110
+ File.chmod(0o775, root)
111
+ described_class.capture(path: @source, label: 'fixture')
112
+ expect(File.stat(root).mode & 0o777).to eq(0o700)
113
+ end
114
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PWN::Config, 'YubiKey configuration redaction' do
6
+ it 'redacts string-keyed hardware settings inside arrays without hiding public selectors' do
7
+ config = { devices: [{ 'PIN' => '1', 'management-key' => 'synthetic-key', 'oath-secret' => 'synthetic-oath', 'serial' => '12345678', 'slot' => 2 }] }
8
+ result = described_class.redact_sensitive_artifacts(config: config)
9
+ masked = result[:devices].first
10
+ expect(masked.values_at('PIN', 'management-key', 'oath-secret').uniq).to eq(['>>> REDACTED >>> USE `pwn-vault` FOR ADMINISTRATION <<< REDACTED <<<'])
11
+ expect(masked).to include('serial' => '12345678', 'slot' => 2)
12
+ expect(config.dig(:devices, 0, 'PIN')).to eq('1')
13
+ end
14
+
15
+ it 'uses the same constant mask for short PINs and hardware secrets while retaining public selectors' do
16
+ fields = %i[pin new_pin admin_pin puk new_puk management_key new_management_key management_password access_code new_access_code oath_secret oath_password unlock_code unlock_password lock_code new_lock_code reset_code scp scp_key scp_keys scp03_key scp11_key static_password hmac_secret]
17
+ values = fields.to_h { |field| [field, '123456'] }
18
+ values[:pin] = '1'
19
+ values[:puk] = '87654321'
20
+ config = { plugins: { yubikey: values.merge(serial: '12345678', slot: 2, reader: 'fixture-reader') } }
21
+ before = Marshal.dump(config)
22
+ redacted = described_class.redact_sensitive_artifacts(config: config).dig(:plugins, :yubikey)
23
+ mask = described_class.redact_sensitive_artifacts(config: { password: 'different-value' })[:password]
24
+ fields.each { |field| expect(redacted[field]).to eq(mask), "#{field} must use the constant mask" }
25
+ expect(redacted).to include(serial: '12345678', slot: 2, reader: 'fixture-reader')
26
+ expect(Marshal.dump(config)).to eq(before)
27
+ end
28
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe PWN::Config, 'hardware vault configuration' do
7
+ around do |example|
8
+ Dir.mktmpdir('config-yubikey-') do |root|
9
+ @root = root
10
+ example.run
11
+ end
12
+ end
13
+
14
+ before do
15
+ allow(Dir).to receive(:home).and_return(@root)
16
+ stub_const('PWN::Env', {})
17
+ stub_const('PWN::EnvRedacted', {})
18
+ stub_const('PWN::Plugins::YubiKey', double('synthetic YubiKey'))
19
+ allow(PWN::Plugins::YubiKey).to receive(:challenge_response) do |opts|
20
+ OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts[:challenge])
21
+ end
22
+ allow(described_class).to receive(:install_default_skills)
23
+ allow(described_class).to receive(:load_skills)
24
+ allow(PWN::Memory).to receive(:load)
25
+ allow(PWN::Cron).to receive(:install_defaults)
26
+ allow(PWN::Migrate).to receive(:needed?).and_return(false)
27
+ @path = File.join(@root, 'pwn.yaml')
28
+ end
29
+
30
+ it 'rejects an invalid hardware selector rather than falling back to a software sidecar' do
31
+ expect { described_class.default_env(pwn_env_path: @path, yubikey: false) }.to raise_error(ArgumentError)
32
+ expect(File).not_to exist(@path)
33
+ expect(File).not_to exist("#{@path}.decryptor")
34
+ end
35
+
36
+ it 'creates the default hardware configuration path when called directly' do
37
+ described_class.default_env(yubikey: { slot: 2 })
38
+ path = File.join(@root, '.pwn', 'pwn.yaml')
39
+ expect(PWN::Plugins::Vault.dump(file: path)).to eq(described_class.env_template)
40
+ expect(File).not_to exist("#{path}.decryptor")
41
+ end
42
+
43
+ it 'preserves multiline template values containing leading colons during creation' do
44
+ template = Marshal.load(Marshal.dump(described_class.env_template))
45
+ text = "before\n:leading-colon\nafter\n"
46
+ template[:ai][:ollama][:system_role_content] = text
47
+ allow(described_class).to receive(:env_template).and_return(template)
48
+ described_class.default_env(pwn_env_path: @path, yubikey: {})
49
+ expect(PWN::Plugins::Vault.dump(file: @path).dig(:ai, :ollama, :system_role_content)).to eq(text)
50
+ end
51
+
52
+ it 'retains software creation by default and honors the explicitly selected legacy decryptor' do
53
+ sidecar = File.join(@root, 'custom-sidecar')
54
+ expect(PWN::Plugins::YubiKey).not_to receive(:challenge_response)
55
+ described_class.refresh_env(pwn_env_path: @path, pwn_dec_path: sidecar)
56
+ expect(PWN::Plugins::Vault.hardware_encrypted?(file: @path)).to be(false)
57
+ credentials = YAML.safe_load_file(sidecar, symbolize_names: true)
58
+ expect(PWN::Plugins::Vault.dump(credentials.merge(file: @path))).to eq(described_class.env_template)
59
+ described_class.refresh_env(pwn_env_path: @path, pwn_dec_path: sidecar)
60
+ expect(PWN::Env.dig(:driver_opts, :pwn_dec_path)).to eq(sidecar)
61
+ expect(File).not_to exist("#{@path}.decryptor")
62
+ end
63
+
64
+ it 'creates a hardware vault only on explicit opt-in, without staging plaintext or making a sidecar' do
65
+ described_class.refresh_env(pwn_env_path: @path, yubikey: { slot: 2 })
66
+ expect(PWN::Plugins::Vault.hardware_encrypted?(file: @path)).to be(true)
67
+ expect(PWN::Plugins::Vault.dump(file: @path)).to eq(described_class.env_template)
68
+ expect(File).not_to exist("#{@path}.decryptor")
69
+ expect(File.stat(@path).mode & 0o777).to eq(0o600)
70
+ expect(PWN::Env).not_to have_key(:yubikey)
71
+ end
72
+
73
+ it 'leaves no plaintext configuration behind when opted-in hardware creation fails' do
74
+ allow(PWN::Plugins::YubiKey).to receive(:challenge_response).and_raise(IOError, 'synthetic device unavailable')
75
+ expect { described_class.default_env(pwn_env_path: @path, yubikey: { slot: 2 }) }.to raise_error(IOError)
76
+ expect(File).not_to exist(@path)
77
+ expect(File).not_to exist("#{@path}.decryptor")
78
+ end
79
+
80
+ it 'refreshes a self-describing hardware vault without a decryptor or software-key prompt' do
81
+ original = { ai: { active: 'ollama', ollama: { model: 'fixture-model' } }, retained: 'fixture-value' }
82
+ bytes = PWN::Plugins::Vault.encrypt_data(data: YAML.dump(original), yubikey: { slot: 2, serial: '12345678' })
83
+ File.binwrite(@path, bytes)
84
+ expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
85
+ described_class.refresh_env(pwn_env_path: @path)
86
+ expect(PWN::Env.dig(:ai, :ollama, :model)).to eq('fixture-model')
87
+ expect(PWN::Env[:retained]).to eq('fixture-value')
88
+ expect(PWN::Env.dig(:driver_opts, :pwn_env_path)).to eq(@path)
89
+ expect(File.binread(@path)).to eq(bytes)
90
+ expect(File).not_to exist("#{@path}.decryptor")
91
+ end
92
+ end
@@ -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
- @tmp = Dir.mktmpdir('pwn-cron-os-spec-')
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(@tmp) if @tmp && Dir.exist?(@tmp)
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(6)
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 include('pwn_stores_lean_nightly')
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('pwn_stores_lean_nightly', 'learning_consolidate_nightly', 'session_cleanup')
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])
@@ -2,6 +2,39 @@
2
2
 
3
3
  require 'spec_helper'
4
4
 
5
+ describe PWN::Migrate, 'exact migration snapshot scope' do
6
+ include_context 'pwn tmp sandbox'
7
+
8
+ it 'backs up nested registry paths and directory trees without cron runtime copies' do
9
+ FileUtils.mkdir_p(File.join(@tmp, 'cron', 'schedule-backups'))
10
+ FileUtils.mkdir_p(File.join(@tmp, 'skills', 'custom'))
11
+ File.write(File.join(@tmp, 'cron', 'jobs.yml'), "--- []\n")
12
+ File.write(File.join(@tmp, 'cron', 'worker.log'), 'diagnostic')
13
+ File.write(File.join(@tmp, 'cron', 'schedule-backups', 'receipt.json'), '{}')
14
+ File.write(File.join(@tmp, 'skills', 'custom', 'SKILL.md'), 'custom recovery')
15
+ snapshot = described_class.send(:backup!)
16
+ expect(File.read(File.join(snapshot, 'cron', 'jobs.yml'))).to eq("--- []\n")
17
+ expect(File.read(File.join(snapshot, 'skills', 'custom', 'SKILL.md'))).to eq('custom recovery')
18
+ expect(File).not_to exist(File.join(snapshot, 'cron', 'worker.log'))
19
+ expect(File).not_to exist(File.join(snapshot, 'cron', 'schedule-backups'))
20
+ end
21
+
22
+ it 'does not require new cron logs and maintenance copies in recovery coverage' do
23
+ File.write(File.join(@tmp, 'memory.json'), '{}')
24
+ 5.times do |i|
25
+ path = File.join(described_class::BACKUP_ROOT, "2026010#{i + 1}_000000")
26
+ FileUtils.mkdir_p(path)
27
+ File.write(File.join(path, 'memory.json'), '{}')
28
+ end
29
+ FileUtils.mkdir_p(File.join(@tmp, 'cron', 'schedule-backups'))
30
+ File.write(File.join(@tmp, 'cron', 'worker.log'), 'new diagnostic')
31
+ File.write(File.join(@tmp, 'cron', 'schedule-backups', 'receipt.json'), '{}')
32
+ result = described_class.prune_backups(dry_run: false, expire_history: true)
33
+ expect(result).to include(deleted: [], keep: 7, scope: 'global')
34
+ expect(File.read(File.join(@tmp, 'cron', 'worker.log'))).to eq('new diagnostic')
35
+ end
36
+ end
37
+
5
38
  describe PWN::Migrate do
6
39
  it 'should display information for authors' do
7
40
  authors_response = PWN::Migrate
@@ -43,6 +76,19 @@ describe PWN::Migrate do
43
76
  allow(PWN::Config).to receive(:pwn_skills_path).and_return(File.join(@tmp, 'skills'))
44
77
  end
45
78
 
79
+ it 'upgrades the existing three-folder policy to seven without pruning legacy data' do
80
+ FileUtils.mkdir_p(described_class::BACKUP_ROOT, mode: 0o700)
81
+ policy = File.join(described_class::BACKUP_ROOT, '.retention-policy.json')
82
+ File.write(policy, JSON.generate(version: 1, keep: 3, pins: [], expire_history: true))
83
+ File.write(described_class::SCHEMA_FILE, JSON.generate(schema: 11))
84
+ result = described_class.run(backup: false, io: io)
85
+ expect(result[:applied_migrations]).to eq([12])
86
+ expect(JSON.parse(File.read(policy))).to include('keep' => 7, 'scope' => 'global')
87
+ bytes = File.binread(policy)
88
+ expect(described_class.run(backup: false, io: io)[:applied_migrations]).to eq([])
89
+ expect(File.binread(policy)).to eq(bytes)
90
+ end
91
+
46
92
  it '.needed? / .status on a fresh (empty) root' do
47
93
  expect(PWN::Migrate.installed_schema).to eq(0)
48
94
  expect(PWN::Migrate.needed?).to be true
@@ -80,7 +126,7 @@ describe PWN::Migrate do
80
126
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 2))
81
127
  expect(described_class.needed?).to be(true)
82
128
  result = described_class.run(fix: false, backup: false, io: io)
83
- expect(result[:applied_migrations]).to eq([3, 4, 5, 6, 7, 8, 9, 10])
129
+ expect(result[:applied_migrations]).to eq([3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
84
130
  expected = Marshal.load(Marshal.dump(original))
85
131
  expected['custom']['model'] = nil
86
132
  expect(YAML.safe_load_file(path)).to eq(expected)
@@ -189,7 +235,7 @@ describe PWN::Migrate do
189
235
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 3))
190
236
  expect(described_class.needed?).to be(true)
191
237
  result = described_class.run(fix: true, backup: false, io: io)
192
- expect(result[:applied_migrations]).to eq([4, 5, 6, 7, 8, 9, 10])
238
+ expect(result[:applied_migrations]).to eq([4, 5, 6, 7, 8, 9, 10, 11, 12])
193
239
  creds = YAML.safe_load_file(dec, symbolize_names: true)
194
240
  user = PWN::Plugins::Vault.dump(file: yaml, key: creds[:key], iv: creds[:iv])
195
241
  expect(user[:ai][:active]).to eq('grok')
@@ -215,7 +261,7 @@ describe PWN::Migrate do
215
261
  PWN::Plugins::Vault.create(file: yaml, decryptor_file: dec)
216
262
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 4))
217
263
  result = described_class.run(fix: true, backup: false, io: io)
218
- expect(result[:applied_migrations]).to eq([5, 6, 7, 8, 9, 10])
264
+ expect(result[:applied_migrations]).to eq([5, 6, 7, 8, 9, 10, 11, 12])
219
265
  creds = YAML.safe_load_file(dec, symbolize_names: true)
220
266
  user = PWN::Plugins::Vault.dump(file: yaml, key: creds[:key], iv: creds[:iv])
221
267
  expect(user.dig(:ai, :agent, :skill_review)).to eq('off')
@@ -240,7 +286,7 @@ describe PWN::Migrate do
240
286
  File.write(yaml, YAML.dump(stale).gsub(/^(\s*):/, '\1'))
241
287
  PWN::Plugins::Vault.create(file: yaml, decryptor_file: dec)
242
288
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 5))
243
- expect(described_class.run(fix: true, backup: false, io: io)[:applied_migrations]).to eq([6, 7, 8, 9, 10])
289
+ expect(described_class.run(fix: true, backup: false, io: io)[:applied_migrations]).to eq([6, 7, 8, 9, 10, 11, 12])
244
290
  creds = YAML.safe_load_file(dec, symbolize_names: true)
245
291
  user = PWN::Plugins::Vault.dump(file: yaml, key: creds[:key], iv: creds[:iv])
246
292
  theme = user.dig(:ai, :tui, :theme)
@@ -287,7 +333,7 @@ describe PWN::Migrate do
287
333
  File.write(yaml, YAML.dump(stale).gsub(/^(\s*):/, '\1'))
288
334
  PWN::Plugins::Vault.create(file: yaml, decryptor_file: dec)
289
335
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 8))
290
- expect(described_class.run(fix: true, backup: false, io: io)[:applied_migrations]).to eq([9, 10])
336
+ expect(described_class.run(fix: true, backup: false, io: io)[:applied_migrations]).to eq([9, 10, 11, 12])
291
337
  creds = YAML.safe_load_file(dec, symbolize_names: true)
292
338
  user = PWN::Plugins::Vault.dump(file: yaml, key: creds[:key], iv: creds[:iv])
293
339
  long_fast = user.dig(:plugins, :meshtastic, :channel, :LongFast)
@@ -311,3 +357,60 @@ describe PWN::Migrate do
311
357
  end
312
358
  end
313
359
  end
360
+
361
+ describe PWN::Migrate, 'backup housekeeping' do
362
+ include_context 'pwn tmp sandbox'
363
+ let(:io) { StringIO.new }
364
+
365
+ it 'does not snapshot or rewrite the stamp on a no-op migration' do
366
+ File.write(File.join(@tmp, '.schema'), JSON.generate(schema: described_class::SCHEMA_VERSION))
367
+ before = File.binread(File.join(@tmp, '.schema'))
368
+ expect(described_class).not_to receive(:backup!)
369
+ result = described_class.run(io: io)
370
+ expect(result[:backup]).to be_nil
371
+ expect(File.binread(File.join(@tmp, '.schema'))).to eq(before)
372
+ end
373
+
374
+ it 'leaves legacy snapshots alone instead of running a competing pruner' do
375
+ root = described_class::BACKUP_ROOT
376
+ 8.times do |i|
377
+ dir = File.join(root, "2026010#{i + 1}_000000")
378
+ FileUtils.mkdir_p(dir)
379
+ File.write(File.join(dir, 'learning.jsonl'), 'unique evidence')
380
+ end
381
+ result = described_class.prune_backups(dry_run: false, keep: 3, expire_history: true)
382
+ expect(result).to include(keep: 7, scope: 'global', deleted: [])
383
+ expect(Dir.children(root).length).to eq(8)
384
+ expect { described_class.send(:backup!) }.to raise_error(IOError)
385
+ expect(Dir.children(root).grep(/\A\d/).length).to eq(8)
386
+ end
387
+
388
+ it 'publishes a verified scoped snapshot and fails closed on a copy failure' do
389
+ File.write(File.join(@tmp, 'learning.jsonl'), 'historical evidence')
390
+ FileUtils.mkdir_p(File.join(@tmp, 'logs'))
391
+ File.write(File.join(@tmp, 'logs', 'debug.log'), 'not migration input')
392
+ path = described_class.send(:backup!)
393
+ expect(File.binread(File.join(path, 'learning.jsonl'))).to eq('historical evidence')
394
+ expect(File).not_to exist(File.join(path, 'logs'))
395
+ manifest = JSON.parse(File.binread(File.join(path, '.snapshot-manifest.json')))
396
+ expect(manifest['complete']).to be(true)
397
+ expect(manifest['files']).to have_key('learning.jsonl')
398
+ allow(FileUtils).to receive(:cp_r).and_raise(IOError, 'copy failed')
399
+ expect { described_class.send(:backup!) }.to raise_error(IOError, 'copy failed')
400
+ end
401
+
402
+ it 'rejects a symlinked backup root without touching its target' do
403
+ root = described_class::BACKUP_ROOT
404
+ File.symlink(@tmp, root)
405
+ expect { described_class.send(:backup!) }.to raise_error(IOError, /Symlink/)
406
+ expect(File.symlink?(root)).to be(true)
407
+ end
408
+
409
+ it 'skips backups for fix mode when schema, files and vault have no drift' do
410
+ File.write(File.join(@tmp, '.schema'), JSON.generate(schema: described_class::SCHEMA_VERSION))
411
+ allow(described_class).to receive(:vault_drift).and_return({ missing: [] })
412
+ allow(described_class).to receive(:status).and_return({ schema_installed: described_class::SCHEMA_VERSION, schema_current: described_class::SCHEMA_VERSION, incompatible: [], pwn_version: 'test' })
413
+ expect(described_class).not_to receive(:backup!)
414
+ expect(described_class.run(fix: true, io: io)[:backup]).to be_nil
415
+ end
416
+ end