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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'pwn/redaction'
5
+
6
+ RSpec.describe PWN::Redaction, 'hardware and MFA field redaction' do
7
+ it 'masks complete management-secret containers in serialized text' do
8
+ samples = [
9
+ 'scp_keys=["fixture-enc", "fixture-mac"] slot=2',
10
+ 'scp_keys: {enc: "fixture-enc", nested: ["fixture-mac", "fixture ] bracket"]} slot=2',
11
+ "scp_keys=[\n\"fixture-enc\",\n[\"fixture-mac\"]\n] slot=2"
12
+ ]
13
+ samples.each do |text|
14
+ clean = described_class.redact(value: text)
15
+ expect(clean).not_to include('fixture-enc', 'fixture-mac', 'fixture ] bracket')
16
+ expect(clean).to include('[REDACTED]', 'slot=2')
17
+ expect(described_class.redact(value: clean)).to eq(clean)
18
+ end
19
+ expect(described_class.redact(value: 'scp_keys=["fixture-enc"')).to eq('scp_keys=[REDACTED]')
20
+ expect(described_class.redact(value: 'oath_secret="fixture truncated secret')).to eq('oath_secret=[REDACTED]')
21
+ end
22
+
23
+ it 'does not let an existing mask exempt an adjacent ordinary secret from capture' do
24
+ text = 'password=fixture-prefix[REDACTED]fixture-suffix'
25
+ clean = described_class.capture(value: text, vault: false)
26
+ expect(clean[:redacted]).not_to include('fixture-prefix', 'fixture-suffix')
27
+ expect(clean[:refs]).not_to be_empty
28
+ end
29
+
30
+ it 'masks key-value text before ordinary token hashing or loot capture' do
31
+ samples = [
32
+ 'pin=123456', 'PUK: 654321', 'new_pin="123 456"', "'management_key' => 'fixture key'",
33
+ '--access-code=112233', 'oath_secret=fixture-oath', 'static_password=fixture-static',
34
+ 'scp_enc=fixture-enc', 'mfa_code=102938', '{"pin": "123456", "puk": 654321}'
35
+ ]
36
+ expect(PWN::Plugins::Vault).not_to receive(:store)
37
+ expect(PWN::Plugins::Vault).not_to receive(:fetch)
38
+ expect(PWN::Plugins::Vault).not_to receive(:expand)
39
+ samples.each do |text|
40
+ clean = described_class.redact(value: text)
41
+ expect(clean).to include('[REDACTED]'), text
42
+ expect(clean).not_to match(/123|654|112|102|fixture|\[REDACTED:[^\]]+/), text
43
+ expect(described_class.redact(value: clean)).to eq(clean)
44
+ expect(described_class.capture(value: text, entropy: true, yubikey: { slot: 2 })).to eq(redacted: clean, refs: [])
45
+ end
46
+ expect(described_class.redact(value: { api_key: 'fixture-api' })).to eq(api_key: described_class.token(kind: 'api_key', value: 'fixture-api'))
47
+ end
48
+
49
+ it 'uses a constant mask for structured PINs, PUKs, MFA codes and management secrets' do
50
+ fields = %w[pin PIN new_pin current-pin admin_pin user_pin fido2_pin piv_pin openpgp_pin puk new_puk access_code management_key management_password oath_secret static_password scp scp_key scp_keys scp_enc scp_mac scp_dek scp03_enc_key mfa_code mfa_token otp totp hotp otp_code totp_secret recovery_code verification_code]
51
+ fields.each do |field|
52
+ first = described_class.redact(value: { field => '123456' })
53
+ second = described_class.redact(value: { field => '654321' })
54
+ expect(first).to eq(field => '[REDACTED]'), field
55
+ expect(second).to eq(first), field
56
+ end
57
+ input = { nested: [{ pin: 1234, puk: nil, scp_keys: %w[fixture-one fixture-two] }], slot: 2, serial: '12345678', shipping: 'public' }
58
+ expect(described_class.redact(value: input)).to eq(nested: [{ pin: '[REDACTED]', puk: nil, scp_keys: '[REDACTED]' }], slot: 2, serial: '12345678', shipping: 'public')
59
+ expect(input[:nested].first[:pin]).to eq(1234)
60
+ end
61
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'timeout'
5
+ require File.expand_path('../../../etc/default_cron/session_cleanup', __dir__)
6
+
7
+ RSpec.describe PWN::SessionCleanup, 'concurrent maintenance' do
8
+ include_context 'pwn tmp sandbox'
9
+
10
+ before do
11
+ allow(Dir).to receive(:home).and_return(File.join(@tmp, 'home'))
12
+ FileUtils.mkdir_p(File.join(Dir.home, '.pwn'))
13
+ stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(Dir.home, '.pwn', 'learning.jsonl'))
14
+ @processes = File.join(@tmp, 'processes')
15
+ FileUtils.mkdir_p(@processes)
16
+ @children = []
17
+ end
18
+
19
+ after do
20
+ @children.each do |pid, input, output|
21
+ input.close unless input.closed?
22
+ output.close unless output.closed?
23
+ Timeout.timeout(5) { Process.wait(pid) }
24
+ rescue Timeout::Error
25
+ Process.kill('KILL', pid)
26
+ Process.wait(pid)
27
+ rescue Errno::ECHILD
28
+ nil
29
+ end
30
+ end
31
+
32
+ def worker(&block)
33
+ reader, output = IO.pipe
34
+ input, writer = IO.pipe
35
+ pid = fork do
36
+ reader.close
37
+ writer.close
38
+ @children.each do |_, old_input, old_output|
39
+ old_input.close
40
+ old_output.close
41
+ end
42
+ $0 = 'pwn-ai'
43
+ block.call(input, output)
44
+ exit! 0
45
+ rescue Exception => e # rubocop:disable Lint/RescueException -- communicate child assertion failures
46
+ output.puts("ERROR: #{e.class}: #{e.message}")
47
+ exit! 1
48
+ end
49
+ input.close
50
+ output.close
51
+ @children << [pid, writer, reader]
52
+ File.symlink("/proc/#{pid}", File.join(@processes, pid.to_s))
53
+ [writer, reader]
54
+ end
55
+
56
+ def message(reader)
57
+ raise 'Child did not respond before deadline' unless reader.wait_readable(10)
58
+
59
+ reader.gets&.strip
60
+ end
61
+
62
+ def maintain
63
+ Timeout.timeout(5) { described_class.maintain(proc_root: @processes) }
64
+ end
65
+
66
+ it 'retains two real active clients, deletes unrelated work and revisits released evidence' do
67
+ ids = 3.times.map { PWN::Sessions.create[:id] }
68
+ learning = PWN::AI::Agent::Learning
69
+ File.write(learning::LEARNING_FILE, "#{JSON.generate(session_id: ids.first, lesson: 'résumé 日本語')}\n")
70
+ clients = ids.first(2).map do |id|
71
+ worker do |input, output|
72
+ lease = PWN::Sessions.activate(session_id: id)
73
+ output.puts('ready')
74
+ input.gets
75
+ PWN::Sessions.append(session_id: id, role: 'assistant', content: 'final 日本語 evidence')
76
+ PWN::Sessions.deactivate(lease: lease)
77
+ output.puts('released')
78
+ input.gets
79
+ end
80
+ end
81
+ clients.each { |client| expect(message(client.last)).to eq('ready') }
82
+ expect(described_class.legacy_owners(@processes)).to eq([])
83
+ first = maintain
84
+ expect(first[:deleted_ids]).to eq([ids.last])
85
+ expect(first[:evidence_migration][:deferred_active_sessions]).to eq(1)
86
+ expect(JSON.parse(File.read(learning::LEARNING_FILE))).not_to have_key('evidence_ref')
87
+ ids.first(2).each { |id| expect(File).to exist(File.join(PWN::Sessions.sessions_dir, "#{id}.jsonl")) }
88
+ clients.each do |input, output|
89
+ input.puts('release')
90
+ expect(message(output)).to eq('released')
91
+ end
92
+ second = maintain
93
+ expect(second[:deleted_ids]).to match_array(ids.first(2))
94
+ expect(learning.session_evidence(session_id: ids.first).last[:content]).to eq('final 日本語 evidence')
95
+ expect(second[:evidence_migration][:migrated_rows]).to eq(1)
96
+ end
97
+
98
+ it 'defers promptly behind a real learning writer and retries without losing its write' do
99
+ id = PWN::Sessions.create[:id]
100
+ learning = PWN::AI::Agent::Learning
101
+ input, output = worker do |commands, replies|
102
+ PWN::Sessions.send(:register_activity_owner)
103
+ lock = learning.send(:learning_lock)
104
+ replies.puts('locked')
105
+ commands.gets
106
+ File.write(learning::LEARNING_FILE, "#{JSON.generate(session_id: id, lesson: 'preserved 日本語')}\n")
107
+ learning.send(:learning_unlock, lock: lock)
108
+ replies.puts('written')
109
+ commands.gets
110
+ end
111
+ expect(message(output)).to eq('locked')
112
+ result = maintain
113
+ expect(result[:evidence_migration]).to include(deferred: 'learning_writer_active', migrated_rows: 0)
114
+ expect(result[:apply_blocked]).to be(true)
115
+ expect(File).to exist(File.join(PWN::Sessions.sessions_dir, "#{id}.jsonl"))
116
+ input.puts('write')
117
+ expect(message(output)).to eq('written')
118
+ expect(maintain[:deleted_ids]).to eq([id])
119
+ expect(JSON.parse(File.read(learning::LEARNING_FILE))['lesson']).to eq('preserved 日本語')
120
+ expect(learning.session_evidence(session_id: id)).not_to be_empty
121
+ end
122
+
123
+ it 'retains a real provider-blocked title worker without delaying unrelated cleanup' do
124
+ id = PWN::Sessions.create[:id]
125
+ other = PWN::Sessions.create[:id]
126
+ PWN::Sessions.append(session_id: id, role: 'user', content: 'Title fixture topic')
127
+ input, output = worker do |commands, replies|
128
+ allow(PWN::AI::Router).to receive(:summarize) do
129
+ replies.puts('provider entered')
130
+ commands.gets
131
+ JSON.generate(title: 'Fixture conversation summary', summary: 'Offline provider fixture summary')
132
+ end
133
+ PWN::Sessions.refresh_title(session_id: id)
134
+ replies.puts('titled')
135
+ commands.gets
136
+ end
137
+ expect(message(output)).to eq('provider entered')
138
+ expect(described_class.legacy_owners(@processes)).to eq([])
139
+ expect(maintain[:deleted_ids]).to eq([other])
140
+ input.puts('finish provider')
141
+ expect(message(output)).to eq('titled')
142
+ expect(PWN::Sessions.title_status(session_id: id)[:is_summarized]).to be(true)
143
+ expect(maintain[:deleted_ids]).to eq([])
144
+ end
145
+
146
+ it 'serializes two real outcome writers with repeated maintenance and preserves every row' do
147
+ learning = PWN::AI::Agent::Learning
148
+ clients = 2.times.map do |index|
149
+ worker do |commands, replies|
150
+ PWN::Sessions.send(:register_activity_owner)
151
+ replies.puts('ready')
152
+ commands.gets
153
+ 5.times { |turn| learning.note_outcome(task: "concurrent fixture #{index}/#{turn}", success: true) }
154
+ replies.puts('written')
155
+ commands.gets
156
+ end
157
+ end
158
+ clients.each { |client| expect(message(client.last)).to eq('ready') }
159
+ clients.each { |client| client.first.puts('start') } # rubocop:disable Style/CombinableLoops -- release only after both children are ready
160
+ 3.times { maintain }
161
+ clients.each { |client| expect(message(client.last)).to eq('written') }
162
+ maintain
163
+ rows = File.readlines(learning::LEARNING_FILE).map { |line| JSON.parse(line) }
164
+ expect(rows.length).to eq(10)
165
+ expect(rows.map { |row| row.fetch('task') }.uniq.length).to eq(10)
166
+ end
167
+
168
+ it 'admits only one real maintenance owner and leaves a retry possible' do
169
+ id = PWN::Sessions.create[:id]
170
+ input, output = worker do |commands, replies|
171
+ PWN::Sessions.send(:register_activity_owner)
172
+ described_class.send(:with_maintenance_lock, dry_run: false) do
173
+ replies.puts('locked')
174
+ commands.gets
175
+ end
176
+ replies.puts('released')
177
+ commands.gets
178
+ end
179
+ expect(message(output)).to eq('locked')
180
+ expect { maintain }.to raise_error(IOError, /maintenance already running/)
181
+ expect(File).to exist(File.join(PWN::Sessions.sessions_dir, "#{id}.jsonl"))
182
+ input.puts('release')
183
+ expect(message(output)).to eq('released')
184
+ expect(maintain[:deleted_ids]).to eq([id])
185
+ end
186
+
187
+ it 'does not mistake a genuinely untracked real client for a cooperating owner' do
188
+ id = PWN::Sessions.create[:id]
189
+ _, output = worker do |commands, replies|
190
+ PWN::Sessions.instance_variable_get(:@activity_owners)&.each_value { |file| file.close unless file.closed? }
191
+ replies.puts('legacy')
192
+ commands.gets
193
+ end
194
+ expect(message(output)).to eq('legacy')
195
+ expect(described_class.legacy_owners(@processes)).to include(hash_including(reason: 'legacy_session_ownership_unknown'))
196
+ result = maintain
197
+ expect(result[:apply_blocked]).to be(true)
198
+ expect(result[:deleted_ids]).to eq([])
199
+ expect(File).to exist(File.join(PWN::Sessions.sessions_dir, "#{id}.jsonl"))
200
+ end
201
+ end
@@ -6,7 +6,169 @@ require 'open3'
6
6
  RSpec.describe 'Bundled session cleanup' do
7
7
  include_context 'pwn tmp sandbox'
8
8
 
9
+ it 'builds and installs the package into an isolated gem home for fresh and schema-10 upgrade paths' do
10
+ package = File.join(@tmp, 'pwn.gem')
11
+ repository = File.expand_path('../../..', __dir__)
12
+ output, error, built = Open3.capture3(RbConfig.ruby, '-S', 'gem', 'build', 'pwn.gemspec', '--output', package, chdir: repository)
13
+ expect(built.success?).to be(true), "#{output}\n#{error}"
14
+ gem_home = File.join(@tmp, 'gems')
15
+ env = { 'HOME' => File.join(@tmp, 'installed-home'), 'GEM_HOME' => gem_home,
16
+ 'GEM_PATH' => [gem_home, *Gem.path].join(File::PATH_SEPARATOR), 'RUBYOPT' => nil, 'BUNDLE_GEMFILE' => nil,
17
+ 'PWN_CRON_DIR' => File.join(@tmp, 'installed-home', '.pwn', 'cron') }
18
+ FileUtils.mkdir_p(env['HOME'])
19
+ output, error, installed = Open3.capture3(env, RbConfig.ruby, '-S', 'gem', 'install', '--local', package, '--ignore-dependencies', '--no-document', '--install-dir', gem_home)
20
+ expect(installed.success?).to be(true), "#{output}\n#{error}"
21
+ script = <<~RUBY
22
+ require 'pwn'
23
+ require 'json'
24
+ require 'stringio'
25
+ raise 'wrong gem activated' unless Gem.loaded_specs.fetch('pwn').full_gem_path.start_with?(ENV.fetch('GEM_HOME'))
26
+ PWN::Cron.install_defaults
27
+ jobs = File.binread(PWN::Cron.jobs_file)
28
+ PWN::Cron.install_defaults
29
+ raise 'non-idempotent jobs' unless File.binread(PWN::Cron.jobs_file) == jobs
30
+ policy = File.join(Dir.home, '.pwn', 'maintenance', 'policy.json')
31
+ config = JSON.parse(File.read(policy)).merge('batch_size' => 7)
32
+ File.write(policy, JSON.generate(config))
33
+ File.write(PWN::Migrate::SCHEMA_FILE, JSON.generate(schema: 10))
34
+ upgrade = PWN::Migrate.run(fix: false, backup: false, io: StringIO.new)
35
+ raise 'missing upgrade' unless upgrade[:applied_migrations] == [11, 12]
36
+ raise 'overwritten policy' unless JSON.parse(File.read(policy))['batch_size'] == 7
37
+ raise 'non-idempotent upgrade' unless PWN::Migrate.run(fix: false, backup: false, io: StringIO.new)[:applied_migrations].empty?
38
+ raise 'wrong helper' unless File.read(File.join(PWN::Cron.cron_dir, 'session_cleanup.rb')).include?('session_cleanup v10')
39
+ report = PWN::SessionCleanup.status
40
+ raise 'wrong inventory' unless report[:categories].length == 9
41
+ # Exercise the installed startup request and original next-slot guard.
42
+ configured = PWN::Cron.list
43
+ configured.each_value { |job| job[:enabled] = false unless job[:name] == 'session_cleanup' }
44
+ File.write(PWN::Cron.jobs_file, YAML.dump(configured))
45
+ tick = PWN::Cron.tick
46
+ raise 'startup failed' unless tick.length == 1 && tick.first[:status] == 'success'
47
+ raise 'startup guard bypassed' unless Dir.glob(File.join(Dir.home, '.pwn', 'maintenance', 'runs', '*.json')).empty?
48
+ raise 'startup not coalesced' unless PWN::Cron.tick.empty?
49
+ # Actual installed producer, with only external analysis transport replaced.
50
+ binary = File.join(Dir.home, 'fixture.bin')
51
+ File.binwrite(binary, 'fixture binary')
52
+ PWN::Plugins::Ghidra.define_singleton_method(:analyze) do |_opts = {}|
53
+ { stdout: 'fixture analysis', exit: 0 }
54
+ end
55
+ PWN::Plugins::Ghidra.decompile(bin: binary)
56
+ raise 'installed cache miss' unless PWN::Plugins::Ghidra.decompile(bin: binary)[:cached]
57
+ sid = PWN::Sessions.create[:id]
58
+ release = File.join(Dir.home, 'release-job')
59
+ command = "while [ ! -f \#{release} ]; do sleep 0.05; done; printf fixture-complete"
60
+ begin
61
+ job = PWN::Plugins::Jobs.start(command: command, session_id: sid, cwd: Dir.home, max_runtime: 20)
62
+ raise 'job not running' unless PWN::Plugins::Jobs.status(id: job[:id])[:status] == 'RUNNING'
63
+ proc_root = File.join(Dir.home, 'fixture-proc')
64
+ FileUtils.mkdir_p(proc_root)
65
+ maintenance = PWN::SessionCleanup.coordinate(dry_run: false, proc_root: proc_root)
66
+ raise "missing coordinator phases: \#{maintenance.inspect}" unless maintenance[:phases].keys == %i[maintenance memory_consolidation learning_retention mistakes_retention policy_retention]
67
+ raise 'active job session deleted' unless File.file?(File.join(PWN::Sessions.sessions_dir, sid + '.jsonl'))
68
+ raise 'job dependency omitted' unless PWN::Sessions.dependency_index[:dependencies].fetch(sid).any? { |path| path.include?('/jobs/') }
69
+ File.write(release, 'release')
70
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 10
71
+ while PWN::Plugins::Jobs.status(id: job[:id])[:status] == 'RUNNING'
72
+ raise 'job did not finish' if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
73
+ sleep 0.02
74
+ end
75
+ raise 'job outcome changed' unless PWN::Plugins::Jobs.status(id: job[:id])[:status] == 'COMPLETED'
76
+ ensure
77
+ File.write(release, 'release')
78
+ PWN::Plugins::Jobs.stop(id: job[:id]) if job && PWN::Plugins::Jobs.status(id: job[:id])[:status] == 'RUNNING'
79
+ end
80
+ puts JSON.generate(installed: true, schema: PWN::Migrate.installed_schema, categories: report[:categories].keys)
81
+ RUBY
82
+ output, error, ran = Open3.capture3(env, RbConfig.ruby, '-e', script, chdir: @tmp)
83
+ expect(ran.success?).to be(true), "#{output}\n#{error}"
84
+ expect(JSON.parse(output)).to include('installed' => true, 'schema' => 12)
85
+ end
86
+
87
+ it 'never seeds curriculum on fresh install, upgrade or repeated migration after deletion' do
88
+ PWN::Cron.install_defaults
89
+ expect(PWN::Cron.list.values.map { |row| row[:name] }).not_to include('curriculum_practice_nightly', 'curriculum_train_weekly', 'curriculum_offline_judge')
90
+ PWN::Cron::CURRICULUM_JOBS.each do |spec|
91
+ job = PWN::Cron.create(spec.merge(sync_native: false))
92
+ PWN::Cron.remove(id: job[:id], sync_native: false)
93
+ end
94
+ custom = PWN::Cron.create(name: 'custom-curriculum', ruby: '42', enabled: false, sync_native: false)
95
+ File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 7))
96
+ 2.times { PWN::Migrate.run(fix: false, backup: false, io: StringIO.new) }
97
+ expect(PWN::Cron.list.values.map { |row| row[:name] }).to contain_exactly('session_cleanup', 'custom-curriculum')
98
+ expect(PWN::Cron.list.fetch(custom[:id])).to eq(custom)
99
+ end
100
+
101
+ it 'coordinates evidence-first maintenance, memory consolidation and retention exactly once' do
102
+ require_relative '../../../etc/default_cron/session_cleanup'
103
+ allow(Dir).to receive(:home).and_return(@tmp)
104
+ FileUtils.mkdir_p(PWN::Sessions.sessions_dir)
105
+ cleanup = PWN::SessionCleanup
106
+ expect(cleanup).to receive(:maintain).with(dry_run: false, current_session_id: 'active', proc_root: '/proc').ordered.and_return(state: 'committed')
107
+ expect(PWN::AI::Agent::Learning).to receive(:consolidate).once.ordered.and_return(removed: 0)
108
+ expect(PWN::AI::Agent::Learning).to receive(:prune_outcomes!).with(dry_run: false).once.ordered.and_return(removed: 0)
109
+ expect(PWN::AI::Agent::Mistakes).to receive(:lean!).with(dry_run: false).once.ordered.and_return(removed: 0)
110
+ expect(PWN::AI::Agent::Policy).to receive(:lean!).with(dry_run: false).once.ordered.and_return(removed: 0)
111
+ expect(PWN::AI::Agent::Learning).not_to receive(:gc_stores!)
112
+ result = cleanup.coordinate(dry_run: false, current_session_id: 'active')
113
+ expect(result[:phases].keys).to eq(%i[maintenance memory_consolidation learning_retention mistakes_retention policy_retention])
114
+ end
115
+
116
+ it 'keeps blocked coordinator phases nonapplying' do
117
+ require_relative '../../../etc/default_cron/session_cleanup'
118
+ allow(Dir).to receive(:home).and_return(@tmp)
119
+ FileUtils.mkdir_p(PWN::Sessions.sessions_dir)
120
+ allow(PWN::SessionCleanup).to receive(:maintain).and_return(apply_blocked: true, state: 'blocked')
121
+ expect(PWN::AI::Agent::Learning).not_to receive(:consolidate)
122
+ expect(PWN::AI::Agent::Learning).not_to receive(:prune_outcomes!)
123
+ expect(PWN::AI::Agent::Mistakes).not_to receive(:lean!)
124
+ expect(PWN::AI::Agent::Policy).not_to receive(:lean!)
125
+ expect(PWN::SessionCleanup.coordinate[:state]).to eq('blocked')
126
+ end
127
+
128
+ it 'never applies retention during coordinator previews and rejects duplicate maintenance owners' do
129
+ require_relative '../../../etc/default_cron/session_cleanup'
130
+ allow(Dir).to receive(:home).and_return(@tmp)
131
+ FileUtils.mkdir_p(PWN::Sessions.sessions_dir)
132
+ allow(PWN::SessionCleanup).to receive(:maintain).and_return(state: 'planned')
133
+ expect(PWN::AI::Agent::Learning).not_to receive(:consolidate)
134
+ expect(PWN::AI::Agent::Learning).to receive(:prune_outcomes!).with(dry_run: true).once.and_return({})
135
+ expect(PWN::AI::Agent::Mistakes).to receive(:lean!).with(dry_run: true).once.and_return({})
136
+ expect(PWN::AI::Agent::Policy).to receive(:lean!).with(dry_run: true).once.and_return({})
137
+ expect(PWN::SessionCleanup.coordinate(dry_run: true)[:state]).to eq('planned')
138
+ expect(Dir.exist?(File.join(@tmp, '.pwn', 'maintenance', 'runs'))).to be(false)
139
+ PWN::SessionCleanup.send(:with_maintenance_lock, dry_run: false) do
140
+ thread = Thread.new do
141
+ expect { PWN::SessionCleanup.coordinate }.to raise_error(IOError, /already running/)
142
+ end
143
+ expect(thread.join(2)).to eq(thread)
144
+ thread.value
145
+ end
146
+ end
147
+
148
+ it 'preserves legacy maintenance jobs and avoids adding duplicate retention phases' do
149
+ legacy = PWN::Cron.create(name: 'learning_consolidate_nightly', ruby: 'custom', enabled: false, sync_native: false)
150
+ PWN::Cron.install_defaults
151
+ expect(PWN::Cron.list.fetch(legacy[:id])).to eq(legacy)
152
+ job = PWN::Cron.list.values.find { |row| row[:name] == 'session_cleanup' }
153
+ expect(job[:ruby]).to include('SessionCleanup.maintain(')
154
+ expect(job[:ruby]).not_to include('SessionCleanup.coordinate(')
155
+ end
156
+
157
+ it 'installs housekeeping defaults without overwriting policy edits' do
158
+ PWN::Cron.install_defaults
159
+ path = File.join(File.dirname(PWN::Cron.cron_dir), 'maintenance', 'policy.json')
160
+ expect(File.file?(path)).to be(true)
161
+ value = JSON.parse(File.read(path))
162
+ value['batch_size'] = 3
163
+ File.write(path, JSON.generate(value))
164
+ PWN::Cron.install_defaults
165
+ expect(JSON.parse(File.read(path))['batch_size']).to eq(3)
166
+ end
167
+
9
168
  it 'seeds a portable guarded daily job and helper only once' do
169
+ allow(PWN::Cron).to receive(:systemd_user_available?).and_return(true)
170
+ expect(PWN::Cron).not_to receive(:install_native_job)
171
+ expect(PWN::Cron).not_to receive(:install_scheduler)
10
172
  PWN::Cron.install_defaults
11
173
  jobs = PWN::Cron.list.values.select { |job| job[:name] == 'session_cleanup' }
12
174
  expect(jobs.length).to eq(1)
@@ -21,10 +183,13 @@ RSpec.describe 'Bundled session cleanup' do
21
183
  end
22
184
 
23
185
  it 'upgrades schema 7 and preserves an existing job identity, schedule and payload' do
186
+ allow(PWN::Cron).to receive(:systemd_user_available?).and_return(true)
187
+ expect(PWN::Cron).not_to receive(:install_native_job)
188
+ expect(PWN::Cron).not_to receive(:install_scheduler)
24
189
  original = PWN::Cron.create(name: 'session_cleanup', schedule: '12 9 * * *', ruby: 'custom guard', enabled: false)
25
190
  File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 7))
26
191
  result = PWN::Migrate.run(fix: false, backup: false, io: StringIO.new)
27
- expect(result[:applied_migrations]).to eq([8, 9, 10])
192
+ expect(result[:applied_migrations]).to eq([8, 9, 10, 11, 12])
28
193
  expect(PWN::Cron.list.fetch(original[:id])).to eq(original)
29
194
  expect(PWN::Cron.list.values.count { |job| job[:name] == 'session_cleanup' }).to eq(1)
30
195
  expect(File).to exist(File.join(PWN::Cron.cron_dir, 'session_cleanup.rb'))
@@ -40,7 +205,7 @@ RSpec.describe 'Bundled session cleanup' do
40
205
  bytes = File.read(PWN::Cron.jobs_file)
41
206
  expect(PWN::Cron).not_to receive(:install_native_job)
42
207
  PWN::Cron.install_defaults
43
- expect(File.read(helper)).to include('# PWN managed default: session_cleanup v9')
208
+ expect(File.read(helper)).to include('# PWN managed default: session_cleanup v10')
44
209
  expect(File.read(PWN::Cron.jobs_file)).to eq(bytes)
45
210
  end
46
211
 
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require File.expand_path('../../../etc/default_cron/session_cleanup', __dir__)
5
+
6
+ RSpec.describe PWN::SessionCleanup do
7
+ include_context 'pwn tmp sandbox'
8
+
9
+ before do
10
+ allow(Dir).to receive(:home).and_return(File.join(@tmp, 'home'))
11
+ @root = File.join(Dir.home, '.pwn')
12
+ FileUtils.mkdir_p(File.join(@root, 'logs'))
13
+ FileUtils.mkdir_p(File.join(@root, 'sessions'))
14
+ File.write(File.join(@root, 'sessions', '.maintenance.lock'), '')
15
+ @path = File.join(@root, 'logs', 'pwn-ai-DEBUG-abc123-R1.log')
16
+ File.write(@path, 'debug session stop')
17
+ File.utime(Time.at(1), Time.at(1), @path)
18
+ allow(described_class).to receive(:legacy_open_files).and_return([])
19
+ @manifest = File.join(@tmp, 'expiry.json.gz')
20
+ end
21
+
22
+ it 'retains open descriptors, referenced paths, recent and unrecognized logs' do
23
+ File.write(File.join(@root, 'memory.json'), JSON.generate(evidence: @path))
24
+ expect(described_class.expire_legacy[:retained].first[:reason]).to eq('evidence_reference')
25
+ File.unlink(File.join(@root, 'memory.json'))
26
+ stat = File.stat(@path)
27
+ allow(described_class).to receive(:legacy_open_files).and_return([[stat.dev, stat.ino]])
28
+ expect(described_class.expire_legacy[:retained].first[:reason]).to eq('open_descriptor')
29
+ allow(described_class).to receive(:legacy_open_files).and_return([])
30
+ File.utime(Time.now, Time.now, @path)
31
+ expect(described_class.expire_legacy[:candidates]).to be_empty
32
+ File.utime(Time.at(1), Time.at(1), @path)
33
+ File.rename(@path, File.join(@root, 'logs', 'unknown.log'))
34
+ expect(described_class.expire_legacy[:candidates]).to be_empty
35
+ end
36
+
37
+ it 'fails closed on symlinks, changed inodes, unreadable proc and held maintenance locks' do
38
+ original = @path
39
+ File.rename(original, "#{original}.save")
40
+ File.symlink("#{original}.save", original)
41
+ expect { described_class.expire_legacy }.to raise_error(IOError, /Unsafe/)
42
+ File.unlink(original)
43
+ File.rename("#{original}.save", original)
44
+ allow(described_class).to receive(:legacy_open_files).and_raise(Errno::EACCES)
45
+ expect { described_class.expire_legacy }.to raise_error(Errno::EACCES)
46
+ allow(described_class).to receive(:legacy_open_files).and_return([])
47
+ File.open(File.join(@root, 'sessions', '.maintenance.lock'), 'r+') do |lock|
48
+ lock.flock(File::LOCK_EX)
49
+ expect { described_class.expire_legacy }.to raise_error(IOError, /Maintenance active/)
50
+ end
51
+ allow(described_class).to receive(:legacy_manifest).and_wrap_original do |method, *args|
52
+ method.call(*args)
53
+ File.write(original, 'replacement')
54
+ end
55
+ expect { described_class.expire_legacy(dry_run: false, manifest: @manifest) }.to raise_error(IOError, /changed/)
56
+ expect(File.read(original)).to eq('replacement')
57
+ end
58
+
59
+ it 'requires external metadata and preserves unclosed diagnostics' do
60
+ expect { described_class.expire_legacy(dry_run: false, manifest: File.join(@root, 'manifest.gz')) }.to raise_error(ArgumentError)
61
+ File.write(@path, 'still running')
62
+ File.utime(Time.at(1), Time.at(1), @path)
63
+ expect(described_class.expire_legacy[:candidates]).to be_empty
64
+ end
65
+
66
+ it 'expires triage cache only when byte-identical evidence survives outside the cache' do
67
+ directory = File.join(@root, 'cache', 'triage')
68
+ FileUtils.mkdir_p(directory)
69
+ cache = File.join(directory, "#{'a' * 64}.json")
70
+ File.write(cache, JSON.generate(sha256: 'a' * 64, path: '/missing/input'))
71
+ File.utime(Time.at(1), Time.at(1), cache)
72
+ FileUtils.mkdir_p(File.join(@root, 'artifacts', 'test'))
73
+ evidence = File.join(@root, 'artifacts', 'test', 'copy.bin')
74
+ FileUtils.cp(cache, evidence)
75
+ result = described_class.expire_legacy(dry_run: false, manifest: @manifest)
76
+ expect(result[:removed].map { |row| row[:path] }).to include(cache)
77
+ expect(File.exist?(evidence)).to be(true)
78
+ end
79
+
80
+ it 'keeps three valid recovery copies per known family and preserves exact evidence pins' do
81
+ File.write(File.join(@root, 'learning.jsonl'), "{}\n")
82
+ backups = (1..5).map do |day|
83
+ path = File.join(@root, format('learning.jsonl.bak-lean-202001%02d', day))
84
+ File.write(path, "{}\n")
85
+ File.utime(Time.at(day), Time.at(day), path)
86
+ path
87
+ end
88
+ File.write(File.join(@root, 'memory.json'), JSON.generate(evidence: backups.first))
89
+ result = described_class.expire_legacy(dry_run: false, manifest: @manifest)
90
+ expect(result[:removed].map { |row| row[:path] }).to include(backups[1])
91
+ expect(backups.select { |path| File.exist?(path) }).to eq(backups.values_at(0, 2, 3, 4))
92
+ end
93
+
94
+ it 'expires only closed recognized diagnostics with an external metadata manifest' do
95
+ result = described_class.expire_legacy(dry_run: false, manifest: @manifest)
96
+ expect(result[:removed].map { |row| row[:path] }).to eq([@path])
97
+ expect(File.exist?(@path)).to be(false)
98
+ expect(JSON.parse(Zlib::GzipReader.open(@manifest, &:read))['removed'].first['sha256']).to eq(Digest::SHA256.hexdigest('debug session stop'))
99
+ expect(Dir.children(File.join(@root, 'logs'))).to be_empty
100
+ end
101
+ end