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,403 @@
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
+ let(:id) { '20200101_010203_abcdef12' }
10
+ let(:title) { "pwn-ai session #{id}" }
11
+ let(:path) { File.join(PWN::Sessions.sessions_dir, "#{id}.jsonl") }
12
+ let(:meta_path) { path.sub('.jsonl', '.meta.json') }
13
+
14
+ before do
15
+ allow(Dir).to receive(:home).and_return(File.join(@tmp, 'home'))
16
+ FileUtils.mkdir_p(File.join(Dir.home, '.pwn'))
17
+ stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(Dir.home, '.pwn', 'learning.jsonl'))
18
+ PWN::Sessions.create(id: id)
19
+ end
20
+
21
+ def metadata(value)
22
+ File.write(meta_path, JSON.generate({ id: id, title: title, source: 'pwn-ai' }.merge(value)))
23
+ end
24
+
25
+ describe 'bounded housekeeping recovery' do
26
+ it 'restores legacy session bytes explicitly without overwriting newer sessions' do
27
+ allow(described_class).to receive(:legacy_owners).and_return([])
28
+ original = File.binread(path)
29
+ result = described_class.sweep(dry_run: false, proc_root: File.join(@tmp, 'no-proc'))
30
+ expect(result[:deleted_ids]).to include(id)
31
+ ledger = File.join(result[:archive_dir], 'ledger.json')
32
+ expect(described_class.recovery_status(ledger: ledger)[:actions]).to include('restore')
33
+ expect(described_class.recover(ledger: ledger, dry_run: false)[:restored]).to include(path)
34
+ expect(File.binread(path)).to eq(original)
35
+ File.write(path, 'newer bytes')
36
+ expect { described_class.recover(ledger: ledger, dry_run: false) }.to raise_error(IOError, /existing/)
37
+ expect(File.read(path)).to eq('newer bytes')
38
+ end
39
+
40
+ it 'rolls back a continuation retirement only while the post-retirement map is unchanged' do
41
+ root = File.join(Dir.home, '.pwn', 'swarm')
42
+ swarm = '20200101_010203_abcdef'
43
+ map = File.join(root, swarm, 'personas.json')
44
+ FileUtils.mkdir_p(File.dirname(map))
45
+ File.write(map, JSON.generate('analyst' => id))
46
+ result = described_class.retire_continuation(root: root, swarm_id: swarm, name: 'analyst')
47
+ ledger = File.join(File.dirname(result[:backup]), 'ledger.json')
48
+ expect(described_class.recover(ledger: ledger, action: 'rollback', dry_run: false)[:restored]).to eq([map])
49
+ expect(JSON.parse(File.read(map))).to eq('analyst' => id)
50
+ File.write(map, JSON.generate('changed' => id))
51
+ expect { described_class.recover(ledger: ledger, action: 'rollback', dry_run: false) }.to raise_error(IOError, /changed/)
52
+ end
53
+
54
+ it 'rolls back targeted dependency removal only after restoring source sessions and validating current bytes' do
55
+ store = File.join(Dir.home, '.pwn', 'mistakes.json')
56
+ original = JSON.generate(bad: { sessions: [id], verified_sessions: {} })
57
+ File.write(store, original)
58
+ allow(described_class).to receive(:legacy_owners).and_return([])
59
+ result = described_class.sweep(dry_run: false)
60
+ ledger = File.join(result[:archive_dir], 'ledger.json')
61
+ expect { described_class.recover(ledger: ledger, action: 'rollback_dependencies', dry_run: false) }.to raise_error(IOError, /source/)
62
+ described_class.recover(ledger: ledger, dry_run: false)
63
+ expect(described_class.recover(ledger: ledger, action: 'rollback_dependencies', dry_run: false)[:restored]).to eq([store])
64
+ expect(File.binread(store)).to eq(original.b)
65
+ end
66
+
67
+ it 'refuses a dependency store exceeding the read admission budget' do
68
+ described_class.install
69
+ config = described_class.policy.merge('work_bytes' => 1)
70
+ File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
71
+ File.write(PWN::AI::Agent::Learning::LEARNING_FILE, JSON.generate(session_id: id, outcome: 'fixture'))
72
+ report = described_class.maintain(dry_run: false, proc_root: File.join(@tmp, 'no-proc'))
73
+ expect(report).to include(state: 'interrupted', incomplete: true)
74
+ expect(report[:dependency_errors]).to include('incomplete_dependency_index')
75
+ expect(File.file?(path)).to be(true)
76
+ end
77
+
78
+ it 'stops between phases on the time budget without interrupting an admitted write' do
79
+ described_class.install
80
+ now = 100.0
81
+ allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC).and_return(now)
82
+ allow(described_class).to receive(:maintenance_transaction).and_wrap_original do |method, **options|
83
+ result = method.call(**options)
84
+ allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC).and_return(now + 60)
85
+ result
86
+ end
87
+ allow(described_class).to receive(:legacy_owners).and_return([])
88
+ report = described_class.maintain(dry_run: false)
89
+ expect(report).to include(state: 'interrupted', incomplete: true)
90
+ expect(JSON.parse(File.read(report[:transaction_ledger]))['state']).to eq('interrupted')
91
+ end
92
+
93
+ it 'keeps centralized copies out of the legacy compactor and retains learning rollback bytes' do
94
+ described_class.install
95
+ allow(described_class).to receive(:legacy_owners).and_return([])
96
+ original = File.binread(path)
97
+ results = 2.times.map do
98
+ File.binwrite(path, original)
99
+ described_class.sweep(dry_run: false, proc_root: File.join(@tmp, 'no-proc'))
100
+ end
101
+ backups = results.map { |row| File.join(row[:archive_dir], "#{id}.jsonl") }
102
+ PWN::Sessions.create(id: id)
103
+ PWN::Sessions.append(session_id: id, role: 'user', content: 'evidence')
104
+ File.write(PWN::AI::Agent::Learning::LEARNING_FILE, JSON.generate(session_id: id, outcome: 'fixture'))
105
+ migration = PWN::AI::Agent::Learning.migrate_evidence(dry_run: false)
106
+ learning_backup = JSON.parse(File.read(migration[:ledger])).fetch('backup')
107
+ inventory = described_class.legacy_backups.map { |row| row[:path] }
108
+ expect(inventory & (backups + [learning_backup])).to be_empty
109
+ config = described_class.policy
110
+ config['retention_days']['maintenance'] = 0
111
+ File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
112
+ result = described_class.prune_legacy_backups(dry_run: false)
113
+ expect(result[:compacted] & backups).to be_empty
114
+ expect(backups.map { |file| File.binread(file) }).to eq([original, original])
115
+ expect(File.file?(learning_backup)).to be(true)
116
+ record = JSON.parse(File.read(migration[:ledger])).merge('state' => 'prepared')
117
+ File.write(migration[:ledger], JSON.generate(record))
118
+ expect(described_class.legacy_backups.map { |row| row[:path] }).not_to include(learning_backup)
119
+ end
120
+
121
+ it 'upgrades the inventory v1 without changing operator policy or unknown layouts' do
122
+ described_class.install
123
+ directory = File.join(Dir.home, '.pwn', 'maintenance')
124
+ config = File.binread(File.join(directory, 'policy.json'))
125
+ layout = File.join(directory, 'layout.json')
126
+ File.write(layout, JSON.generate(version: 1, categories: { learning: ['learning_evidence'] }))
127
+ described_class.install
128
+ expect(JSON.parse(File.read(layout))).to include('version' => 3, 'categories' => described_class::STORAGE)
129
+ expect(File.binread(File.join(directory, 'policy.json'))).to eq(config)
130
+ File.write(layout, JSON.generate(version: 2, categories: { 'tmp' => ['tmp'] }))
131
+ described_class.install
132
+ expect(JSON.parse(File.read(layout))).to include('version' => 3, 'categories' => described_class::STORAGE)
133
+ File.write(layout, JSON.generate(version: 99, categories: {}))
134
+ expect { described_class.install }.to raise_error(IOError, /inventory/)
135
+ end
136
+
137
+ it 'halts admission on a partial dependency index without deleting unique or active evidence' do
138
+ described_class.install
139
+ config = described_class.policy.merge('work_items' => 1, 'work_seconds' => 60)
140
+ File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
141
+ bytes = File.binread(path)
142
+ lease = PWN::Sessions.activate(session_id: id)
143
+ report = described_class.maintain(dry_run: false, proc_root: File.join(@tmp, 'no-proc'))
144
+ expect(report[:state]).to eq('interrupted')
145
+ expect(report[:incomplete]).to be(true)
146
+ expect(File.binread(path)).to eq(bytes)
147
+ expect(Thread.current[:pwn_maintenance_admission]).to be_nil
148
+ ensure
149
+ lease&.close
150
+ end
151
+
152
+ it 'preserves centralized continuation recovery and does not run a competing compactor' do
153
+ described_class.install
154
+ root = File.join(Dir.home, '.pwn', 'swarm')
155
+ swarm = '20200101_010203_abcdef'
156
+ map = File.join(root, swarm, 'personas.json')
157
+ FileUtils.mkdir_p(File.dirname(map))
158
+ backups = 2.times.map do
159
+ File.write(map, JSON.generate('analyst' => id))
160
+ described_class.retire_continuation(root: root, swarm_id: swarm, name: 'analyst').fetch(:backup)
161
+ end
162
+ config = described_class.policy
163
+ config['retention_days']['maintenance'] = 0
164
+ File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
165
+ original = File.binread(backups.first)
166
+ config['retention_days']['maintenance'] = 90
167
+ config['budget_bytes']['maintenance'] = 0
168
+ File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
169
+ expect(File.identical?(*backups)).to be(false)
170
+ report = described_class.prune_legacy_backups(dry_run: false)
171
+ expect(report[:compacted]).to be_empty
172
+ expect(File.identical?(*backups)).to be(false)
173
+ expect(backups.map { |file| File.binread(file) }).to eq([original, original])
174
+ ledger = File.join(File.dirname(backups.last), 'ledger.json')
175
+ expect(described_class.recover(ledger: ledger, action: 'rollback', dry_run: false)[:restored]).to eq([map])
176
+ expect(File.binread(map)).to eq(original)
177
+ end
178
+
179
+ it 'owns actual festival input through use and removes it without a global tmp sweep' do
180
+ observed = nil
181
+ allow(PWN::Plugins::Voice).to receive(:text_to_speech) do |opts|
182
+ observed = opts[:text_path]
183
+ expect(observed).to start_with("#{described_class.disposable_directory('tmp')}/")
184
+ expect(File.read(observed)).to eq('fixture speech')
185
+ expect(described_class.clean_disposables(dry_run: false)[:deleted]).to be_empty
186
+ end
187
+ result = PWN::AI::Agent::Extrospection.send(:voice_tts, text: 'fixture speech', engine: :festival, out: File.join(@tmp, 'speech.wav'))
188
+ expect(result.first).to be(true)
189
+ expect(File.exist?(observed)).to be(false)
190
+ end
191
+
192
+ it 'recovers a killed atomic cache producer using its ownership journal, never its filename' do
193
+ described_class.install
194
+ cache = File.join(Dir.home, '.pwn', 'cache', 'killed')
195
+ described_class.cache(path: cache, bytes: 'old')
196
+ child = fork do
197
+ allow(File).to receive(:rename).and_call_original
198
+ allow(File).to receive(:rename).with(anything, cache) { exit! 0 }
199
+ described_class.cache(path: cache, bytes: 'new')
200
+ exit! 1
201
+ end
202
+ _, child_status = Process.wait2(child)
203
+ expect(child_status.exitstatus).to eq(0)
204
+ unknown = "#{cache}.unknown.tmp"
205
+ File.write(unknown, 'unknown')
206
+ atomic = Dir.glob(File.join(Dir.home, '.pwn', 'maintenance', 'atomic', '*.json')).fetch(0)
207
+ owned = JSON.parse(File.read(atomic)).fetch('path')
208
+ File.write(owned, 'changed')
209
+ expect(described_class.recover_atomic_temps(dry_run: false)[:deleted]).to be_empty
210
+ File.write(owned, 'new')
211
+ report = described_class.recover_atomic_temps(dry_run: false)
212
+ expect(report[:deleted].length).to eq(1)
213
+ expect(File.read(cache)).to eq('old')
214
+ expect(File.read(unknown)).to eq('unknown')
215
+ expect(described_class.recover_atomic_temps(dry_run: false)[:deleted]).to be_empty
216
+ end
217
+
218
+ it 'resumes expiry_prepared after an interruption between backup unlinks' do
219
+ described_class.install
220
+ config = described_class.policy
221
+ config['retention_days']['maintenance'] = 0
222
+ File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
223
+ 2.times do |index|
224
+ file = File.join(described_class.disposable_directory('tmp'), "expiry-#{index}")
225
+ File.write(file, "bytes-#{index}")
226
+ described_class.register_disposable(path: file, category: 'tmp', expires_at: 0)
227
+ end
228
+ result = described_class.clean_disposables(dry_run: false)
229
+ # Exercise the retained legacy recovery reader with a genuine old layout.
230
+ old_directory = File.dirname(result[:ledger])
231
+ legacy_directory = File.join(Dir.home, '.pwn', 'maintenance', 'quarantine', 'legacy-expiry')
232
+ FileUtils.mkdir_p(File.dirname(legacy_directory))
233
+ FileUtils.mv(old_directory, legacy_directory)
234
+ result = JSON.parse(JSON.generate(result).gsub(old_directory, legacy_directory), symbolize_names: true)
235
+ data = JSON.parse(File.read(result[:ledger]).gsub(old_directory, legacy_directory))
236
+ File.write(result[:ledger], JSON.generate(data))
237
+ result[:records] = data.fetch('records')
238
+ backups = result[:records].map { |row| row['backup'] }
239
+ allow(File).to receive(:unlink).and_call_original
240
+ allow(File).to receive(:unlink).with(backups.last).and_raise(Interrupt)
241
+ expect { described_class.prune_quarantine(dry_run: false) }.to raise_error(Interrupt)
242
+ expect(JSON.parse(File.read(result[:ledger]))['state']).to eq('expiry_prepared')
243
+ expect(File.exist?(backups.first)).to be(false)
244
+ allow(File).to receive(:unlink).with(backups.last).and_call_original
245
+ expect(described_class.prune_quarantine(dry_run: false)[:deleted]).to eq([backups.last])
246
+ expect(JSON.parse(File.read(result[:ledger]))['state']).to eq('expired')
247
+ end
248
+
249
+ it 'explicitly delegates learning recovery to validated store rollback without pretending broad rollback' do
250
+ PWN::Sessions.append(session_id: id, role: 'user', content: '証拠')
251
+ file = PWN::AI::Agent::Learning::LEARNING_FILE
252
+ original = JSON.generate(session_id: id, outcome: 'fixture')
253
+ File.write(file, original)
254
+ migration = PWN::AI::Agent::Learning.migrate_evidence(dry_run: false)
255
+ expect(described_class.recovery_status(ledger: migration[:ledger])[:actions]).to include('rollback')
256
+ expect(described_class.recover(ledger: migration[:ledger], action: 'rollback', dry_run: true)[:dry_run]).to be(true)
257
+ expect(File.read(file)).not_to eq(original)
258
+ expect(described_class.recover(ledger: migration[:ledger], action: 'rollback', dry_run: false)[:restored]).to be(true)
259
+ expect(File.binread(file)).to eq(original.b)
260
+ end
261
+ end
262
+ end
263
+
264
+ RSpec.describe PWN::SessionCleanup, 'disposable admission and scan progress' do
265
+ include_context 'pwn tmp sandbox'
266
+
267
+ before do
268
+ allow(Dir).to receive(:home).and_return(File.join(@tmp, 'home'))
269
+ FileUtils.mkdir_p(File.join(Dir.home, '.pwn'))
270
+ stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(Dir.home, '.pwn', 'learning.jsonl'))
271
+ end
272
+
273
+ it 'admits disposable payload IO before hashing or backing up through maintain' do
274
+ described_class.install
275
+ allow(described_class).to receive(:legacy_owners).and_return([])
276
+ cache = File.join(Dir.home, '.pwn', 'cache', 'large')
277
+ described_class.cache(path: cache, bytes: 'x' * 1_048_576)
278
+ described_class.register_disposable(path: cache, category: 'cache', expires_at: 0)
279
+ allow(Digest::SHA256).to receive(:file).and_call_original
280
+ allow(File).to receive(:binread).and_call_original
281
+ expect(Digest::SHA256).not_to receive(:file).with(cache)
282
+ expect(File).not_to receive(:binread).with(cache, any_args)
283
+ config = described_class.policy.merge('work_bytes' => 1024)
284
+ File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
285
+ report = described_class.maintain(dry_run: false, proc_root: File.join(@tmp, 'no-proc'))
286
+ expect(report).to include(state: 'interrupted', incomplete: true)
287
+ expect(File.size(cache)).to eq(1_048_576)
288
+ expect(report[:storage][:deleted]).to be_empty
289
+ expect(Dir.glob(File.join(Dir.home, '.pwn', 'maintenance', 'quarantine', '*', '*.bin'))).to be_empty
290
+ end
291
+
292
+ it 'advances bounded scans past completed and normally removed temporary manifests' do
293
+ described_class.install
294
+ allow(described_class).to receive(:legacy_owners).and_return([])
295
+ described_class.temporary(bytes: 'normal producer') { |_file| nil }
296
+ files = 2.times.map do |index|
297
+ file = File.join(Dir.home, '.pwn', 'cache', "expire-#{index}")
298
+ described_class.cache(path: file, bytes: "cache-#{index}")
299
+ described_class.register_disposable(path: file, category: 'cache', expires_at: 0)
300
+ file
301
+ end
302
+ config = described_class.policy.merge('scan_limit' => 1)
303
+ File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
304
+ reports = 6.times.map { described_class.maintain(dry_run: false, proc_root: File.join(@tmp, 'no-proc')) }
305
+ expect(reports.flat_map { |report| report[:storage][:deleted] }).to contain_exactly(*files)
306
+ expect(files.none? { |file| File.exist?(file) }).to be(true)
307
+ expect(Dir.glob(File.join(Dir.home, '.pwn', 'maintenance', 'disposable', '*.json')).length).to eq(3)
308
+ expect(reports.all? { |report| report[:storage][:deleted].length <= 1 }).to be(true)
309
+ end
310
+
311
+ it 'does not hash or expire oversized quarantine backups through maintain' do
312
+ described_class.install
313
+ allow(described_class).to receive(:legacy_owners).and_return([])
314
+ cache = File.join(Dir.home, '.pwn', 'cache', 'quarantined')
315
+ described_class.cache(path: cache, bytes: 'q' * 1_048_576)
316
+ described_class.register_disposable(path: cache, category: 'cache', expires_at: 0)
317
+ removed = described_class.clean_disposables(dry_run: false)
318
+ old_directory = File.dirname(removed[:ledger])
319
+ legacy_directory = File.join(Dir.home, '.pwn', 'maintenance', 'quarantine', 'legacy-budget')
320
+ FileUtils.mkdir_p(File.dirname(legacy_directory))
321
+ FileUtils.mv(old_directory, legacy_directory)
322
+ removed = JSON.parse(JSON.generate(removed).gsub(old_directory, legacy_directory), symbolize_names: true)
323
+ data = JSON.parse(File.read(removed[:ledger]).gsub(old_directory, legacy_directory))
324
+ File.write(removed[:ledger], JSON.generate(data))
325
+ removed[:records] = data.fetch('records')
326
+ backup = removed[:records].first.fetch('backup')
327
+ config = described_class.policy.merge('work_bytes' => 8192)
328
+ config['retention_days']['maintenance'] = 0
329
+ File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
330
+ report = described_class.maintain(dry_run: false, proc_root: File.join(@tmp, 'no-proc'))
331
+ expect(report).to include(state: 'interrupted', incomplete: true)
332
+ expect(File.size(backup)).to eq(1_048_576)
333
+ expect(JSON.parse(File.read(removed[:ledger]))['state']).to eq('committed')
334
+ end
335
+
336
+ it 'revisits active and mutated entries without losing a later re-registration' do
337
+ described_class.install
338
+ allow(described_class).to receive(:legacy_owners).and_return([])
339
+ files = %w[active mutated ready].map do |name|
340
+ file = File.join(Dir.home, '.pwn', 'cache', name)
341
+ described_class.cache(path: file, bytes: name)
342
+ described_class.register_disposable(path: file, category: 'cache', expires_at: 0)
343
+ file
344
+ end
345
+ File.write(files[1], 'mutated payload')
346
+ lease = File.open("#{files[0]}.lease", File::RDWR) # rubocop:disable Style/FileOpen -- lifetime spans repeated maintenance calls
347
+ lease.flock(File::LOCK_SH)
348
+ config = described_class.policy.merge('scan_limit' => 1)
349
+ File.write(File.join(Dir.home, '.pwn', 'maintenance', 'policy.json'), JSON.generate(config))
350
+ reports = 3.times.map { described_class.maintain(dry_run: false, proc_root: File.join(@tmp, 'no-proc')) }
351
+ expect(reports.flat_map { |report| report[:storage][:deleted] }).to eq([files[2]])
352
+ expect(File.read(files[0])).to eq('active')
353
+ expect(File.read(files[1])).to eq('mutated payload')
354
+ lease.close
355
+ described_class.cache(path: files[2], bytes: 'new registration')
356
+ 3.times { described_class.maintain(dry_run: false, proc_root: File.join(@tmp, 'no-proc')) }
357
+ expect(File.exist?(files[0])).to be(false)
358
+ expect(File.read(files[1])).to eq('mutated payload')
359
+ expect(File.read(files[2])).to eq('new registration')
360
+ ensure
361
+ lease&.close unless lease&.closed?
362
+ end
363
+
364
+ it 'finishes an admitted disposable commit when the deadline expires during backup' do
365
+ described_class.install
366
+ allow(described_class).to receive(:legacy_owners).and_return([])
367
+ cache = File.join(Dir.home, '.pwn', 'cache', 'admitted')
368
+ described_class.cache(path: cache, bytes: 'exact recoverable payload')
369
+ described_class.register_disposable(path: cache, category: 'cache', expires_at: 0)
370
+ allow(described_class).to receive(:publish).and_wrap_original do |method, target, bytes|
371
+ result = method.call(target, bytes)
372
+ Thread.current[:pwn_maintenance_budget][:deadline] = 0 if target.end_with?('.bin')
373
+ result
374
+ end
375
+ report = described_class.maintain(dry_run: false, proc_root: File.join(@tmp, 'no-proc'))
376
+ expect(report).to include(state: 'interrupted', incomplete: true)
377
+ expect(report[:storage][:deleted]).to eq([cache])
378
+ record = report[:storage][:records].first
379
+ expect(record['state']).to eq('committed')
380
+ expect(File.binread(record['backup'])).to eq('exact recoverable payload')
381
+ expect(JSON.parse(File.read(report[:storage][:ledger]))['records'].first['state']).to eq('committed')
382
+ end
383
+
384
+ it 'retains a manifest re-registered after discovery but before its lease' do
385
+ described_class.install
386
+ allow(described_class).to receive(:legacy_owners).and_return([])
387
+ cache = File.join(Dir.home, '.pwn', 'cache', 'renewed')
388
+ described_class.cache(path: cache, bytes: 'same payload')
389
+ described_class.register_disposable(path: cache, category: 'cache', expires_at: 0)
390
+ renewed = false
391
+ allow(described_class).to receive(:disposable_reason).and_wrap_original do |method, row, **options|
392
+ reason = method.call(row, **options)
393
+ if !options[:verify] && !renewed
394
+ renewed = true
395
+ described_class.register_disposable(path: cache, category: 'cache', expires_at: Time.now.to_i + 3600)
396
+ end
397
+ reason
398
+ end
399
+ report = described_class.maintain(dry_run: false, proc_root: File.join(@tmp, 'no-proc'))
400
+ expect(report[:storage][:deleted]).to be_empty
401
+ expect(File.read(cache)).to eq('same payload')
402
+ end
403
+ end
@@ -7,6 +7,19 @@ require 'fileutils'
7
7
  describe PWN::Sessions do
8
8
  include_context 'pwn tmp sandbox'
9
9
 
10
+ it 'probes absent and compact-layout leases without filesystem writes' do
11
+ sid = '20261025_120000_12345678'
12
+ root = File.join(@tmp, 'absent-sessions')
13
+ stub_const('PWN::Sessions::SESSIONS_DIR', root)
14
+ expect(described_class.active?(session_id: sid)).to be(false)
15
+ expect(File.exist?(root)).to be(false)
16
+ FileUtils.mkdir_p(root)
17
+ File.write(File.join(root, '.layout-v2'), '2')
18
+ before = Dir.glob("#{root}/**/*", File::FNM_DOTMATCH).sort
19
+ expect(described_class.active?(session_id: sid)).to be(false)
20
+ expect(Dir.glob("#{root}/**/*", File::FNM_DOTMATCH).sort).to eq(before)
21
+ end
22
+
10
23
  it 'reconciles page size, filtered inventory and physical support entries' do
11
24
  allow(Dir).to receive(:home).and_return(@tmp)
12
25
  3.times { described_class.create }
@@ -104,6 +117,105 @@ describe PWN::Sessions do
104
117
  ensure
105
118
  Thread.current[:pwn_request_runtime] = previous
106
119
  end
120
+ end
121
+
122
+ describe PWN::Sessions, 'title refresh and persistence' do
123
+ include_context 'pwn tmp sandbox'
124
+
125
+ it 'summarizes Anthropic text blocks through the real adapter without stringifying Ruby containers' do
126
+ allow(Dir).to receive(:home).and_return(@tmp)
127
+ sid = described_class.create[:id]
128
+ described_class.append(session_id: sid, role: 'user', content: 'Repair login certificate')
129
+ config = { active: :anthropic, anthropic: { model: 'fixture-model', key: 'fixture-key' } }
130
+ allow(PWN::AI::Router).to receive(:summarize).and_call_original
131
+ allow(PWN::AI::Anthropic).to receive(:anthropic_rest_call).and_return(JSON.generate(
132
+ content: [{ type: 'thinking', thinking: 'private fixture reasoning' },
133
+ { type: 'text', text: JSON.generate(title: 'Certificate recovery', summary: 'Repaired login certificates.') }]
134
+ ))
135
+ failures = []
136
+ result = described_class.refresh_title(session_id: sid, ai_config: config, on_error: failures.method(:push))
137
+ expect(failures).to be_empty
138
+ expect(result).to include(title: 'Certificate recovery', title_source: 'summary', summary_turns: 1)
139
+ end
140
+
141
+ it 'reports the original provider failure class and preserves exact metadata bytes' do
142
+ allow(Dir).to receive(:home).and_return(@tmp)
143
+ sid = described_class.create[:id]
144
+ described_class.append(session_id: sid, role: 'user', content: 'Fixture request')
145
+ config = { active: :openai, openai: { model: 'fixture', key: 'fixture-key' } }
146
+ path = described_class.support_path(kind: :metadata, session_id: sid)
147
+ before = File.binread(path)
148
+ allow(PWN::AI::Router).to receive(:summarize).and_call_original
149
+ allow(PWN::AI::OpenAI).to receive(:chat).and_raise(Errno::EACCES, 'private fixture path')
150
+ failures = []
151
+ described_class.refresh_title(session_id: sid, ai_config: config, on_error: failures.method(:push))
152
+ expect(failures).to eq(['Errno::EACCES'])
153
+ expect(File.binread(path)).to eq(before)
154
+ end
155
+
156
+ it 'accepts whitespace around a complete fenced JSON title response' do
157
+ sid = described_class.create[:id]
158
+ described_class.append(session_id: sid, role: 'user', content: 'Fixture request')
159
+ allow(PWN::AI::Router).to receive(:summarize).and_return(" \n```json\n#{JSON.generate(title: 'Certificate recovery', summary: 'Repaired certificates.')}\n```\n ")
160
+ failures = []
161
+ result = described_class.refresh_title(session_id: sid, on_error: failures.method(:push))
162
+ expect(failures).to be_empty
163
+ expect(result[:title_source]).to eq('summary')
164
+ end
165
+
166
+ it 'keeps the loaded session store independent of a later mismatched inaccessible home' do
167
+ sid = described_class.create[:id]
168
+ described_class.append(session_id: sid, role: 'user', content: 'Fixture request')
169
+ home = File.join(@tmp, 'inaccessible-home')
170
+ Dir.mkdir(home, 0o000)
171
+ allow(Dir).to receive(:home).and_return(home)
172
+ # Model a changed HOME after store constants loaded, without touching any
173
+ # real account home or relying on the runner being unprivileged.
174
+ allow(File).to receive(:open).and_call_original
175
+ allow(File).to receive(:open).with(home, any_args).and_raise(Errno::EACCES, home)
176
+ config = { active: :openai, openai: { model: 'fixture', key: 'fixture-key' } }
177
+ allow(PWN::AI::Router).to receive(:summarize).and_call_original
178
+ allow(PWN::AI::OpenAI).to receive(:chat).and_return(choices: [{ content: JSON.generate(title: 'Fixture recovery', summary: 'Fixture repaired.') }])
179
+ failures = []
180
+ result = described_class.refresh_title(session_id: sid, ai_config: config, on_error: failures.method(:push))
181
+ expect(failures).to be_empty
182
+ expect(result[:title_source]).to eq('summary')
183
+ expect(File).not_to have_received(:open).with(home, any_args)
184
+ ensure
185
+ File.chmod(0o700, home) if home && File.directory?(home)
186
+ end
187
+
188
+ it 'retains provenance and removes its exact temporary artifact on denied metadata replacement' do
189
+ sid = described_class.create[:id]
190
+ described_class.append(session_id: sid, role: 'user', content: 'Fixture request')
191
+ path = described_class.support_path(kind: :metadata, session_id: sid)
192
+ before = File.binread(path)
193
+ allow(PWN::AI::Router).to receive(:summarize).and_return(JSON.generate(title: 'Fixture repair', summary: 'Repaired fixture.'))
194
+ temporary = nil
195
+ allow(File).to receive(:rename).and_call_original
196
+ allow(File).to receive(:rename).with(anything, path) do |source, _target|
197
+ temporary = source
198
+ expect(File.file?(source)).to be(true)
199
+ raise Errno::EACCES, path
200
+ end
201
+ failures = []
202
+ described_class.refresh_title(session_id: sid, on_error: failures.method(:push))
203
+ expect(failures).to eq(['Errno::EACCES'])
204
+ expect(File.binread(path)).to eq(before)
205
+ expect(temporary).not_to be_nil
206
+ expect(File.exist?(temporary)).to be(false)
207
+ end
208
+
209
+ it 'does not mislabel an unavailable provider response as malformed JSON' do
210
+ sid = described_class.create[:id]
211
+ described_class.append(session_id: sid, role: 'user', content: 'Fixture request')
212
+ original = described_class.metadata(session_id: sid)
213
+ allow(PWN::AI::Router).to receive(:summarize).and_return('')
214
+ failures = []
215
+ expect(described_class.refresh_title(session_id: sid, on_error: failures.method(:push))).to eq(original)
216
+ expect(failures).to eq(['IOError'])
217
+ expect(described_class.metadata(session_id: sid)).to eq(original)
218
+ end
107
219
 
108
220
  it 'rejects provider echoed placeholder titles without advancing summary provenance' do
109
221
  session = described_class.create
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'ffi'
5
+
6
+ RSpec.describe PWN::Setup, 'native library doctor' do
7
+ around do |example|
8
+ Dir.mktmpdir('native-library-doctor-') 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
+ allow(PWN::Migrate).to receive(:check).and_return(incompatible: [])
18
+ allow(described_class).to receive(:pkg_manager).and_return(key: :apt, install: 'install-package')
19
+ allow(described_class).to receive(:which).and_return('/fixture/bin')
20
+ allow(described_class).to receive(:gem_loadable?).and_return(true)
21
+ allow(described_class).to receive(:detect_os_attr).with(attr: :type, fallback: anything).and_return(:linux)
22
+ allow(described_class).to receive(:detect_os_attr).with(attr: :arch, fallback: anything).and_return('fixture')
23
+ allow(FFI::DynamicLibrary).to receive(:open).and_raise(LoadError, 'fixture library missing')
24
+ device = double('untouched native device')
25
+ stub_const('PWN::Plugins::YubiKey', device)
26
+ %i[devices info command challenge_response oath_code].each { |method| expect(device).not_to receive(method) }
27
+ end
28
+
29
+ it 'reports missing optional libraries but gates only hardware and full profiles on them' do
30
+ %i[core hardware full].each do |profile|
31
+ io = StringIO.new
32
+ report = described_class.check(profile: profile, io: io)
33
+ expect(report[:ok]).to eq(profile == :core)
34
+ expect(report[:native_libraries_missing]).to eq(%w[hidapi pcsc])
35
+ expect(report[:native_gems_missing]).to eq([])
36
+ expect(report[:toolchain_missing]).to eq([])
37
+ expect(io.string).to include('Native libraries', 'hidapi', 'pcsc', 'no device discovery')
38
+ end
39
+ end
40
+
41
+ it 'loads only library handles and accepts the alternative HIDAPI libusb backend' do
42
+ flags = FFI::DynamicLibrary::RTLD_LAZY | FFI::DynamicLibrary::RTLD_LOCAL
43
+ allow(FFI::DynamicLibrary).to receive(:open).with('libhidapi-libusb.so.0', flags).and_return(double('HID library'))
44
+ allow(FFI::DynamicLibrary).to receive(:open).with('libpcsclite.so.1', flags).and_return(double('PCSC library'))
45
+ report = described_class.check(profile: :hardware, io: StringIO.new)
46
+ expect(report[:ok]).to be(true)
47
+ expect(report[:native_libraries_missing]).to eq([])
48
+ expect(FFI::DynamicLibrary).to have_received(:open).with('libhidapi-hidraw.so.0', flags)
49
+ end
50
+
51
+ it 'recognizes the built-in macOS PCSC framework and Homebrew HIDAPI path' do
52
+ allow(described_class).to receive(:detect_os_attr).with(attr: :type, fallback: anything).and_return(:osx)
53
+ flags = FFI::DynamicLibrary::RTLD_LAZY | FFI::DynamicLibrary::RTLD_LOCAL
54
+ allow(FFI::DynamicLibrary).to receive(:open).with('/System/Library/Frameworks/PCSC.framework/PCSC', flags).and_return(double('system PCSC framework'))
55
+ allow(FFI::DynamicLibrary).to receive(:open).with('/opt/homebrew/lib/libhidapi.dylib', flags).and_return(double('HID library'))
56
+ report = described_class.check(profile: :hardware, io: StringIO.new)
57
+ expect(report[:ok]).to be(true)
58
+ expect(report[:native_libraries_missing]).to eq([])
59
+ end
60
+
61
+ it 'lists optional native libraries in profile descriptions' do
62
+ io = StringIO.new
63
+ described_class.list_profiles(io: io)
64
+ expect(io.string).to include('libs: hidapi pcsc')
65
+ end
66
+ end
@@ -68,7 +68,7 @@ describe PWN::Setup do
68
68
  expect(res[:dry_run]).to be true
69
69
  expect(res[:ok]).to be true
70
70
  end
71
- it 'ensure_cron enabled:false uninstalls the OS scheduler without spawning' do
71
+ it 'ensure_cron enabled:false disables only the internal worker without native changes' do
72
72
  tmp = Dir.mktmpdir('pwn-setup-nocron-')
73
73
  stub_const('PWN::Cron::CRON_DIR', tmp)
74
74
  stub_const('PWN::Cron::JOBS_FILE', File.join(tmp, 'jobs.yml'))
@@ -76,6 +76,8 @@ describe PWN::Setup do
76
76
  stub_const('PWN::Cron::WORKER_LOG', File.join(tmp, 'worker.log'))
77
77
  ENV['PWN_CRON_DIR'] = tmp
78
78
  io = StringIO.new
79
+ expect(PWN::Cron).not_to receive(:install_scheduler)
80
+ expect(PWN::Cron).not_to receive(:uninstall_scheduler)
79
81
  res = PWN::Setup.ensure_cron(enabled: false, apply: false, io: io)
80
82
  expect(res[:enabled]).to be false
81
83
  expect(res[:ok]).to be true