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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/Gemfile +8 -8
- data/README.md +37 -0
- data/Rakefile +19 -1
- data/bin/pwn +2 -1
- data/bin/pwn_setup +1 -1
- data/bin/pwn_web_cache_deception +35 -7
- data/build_gem.sh +1 -1
- data/documentation/AI-Integration.md +7 -5
- data/documentation/Agent-Tool-Registry.md +2 -2
- data/documentation/Backup-Retention.md +82 -0
- data/documentation/Configuration.md +10 -6
- data/documentation/Contributing.md +1 -1
- data/documentation/Cron.md +64 -35
- data/documentation/Diagrams.md +2 -2
- data/documentation/General-PWN-Usage.md +4 -3
- data/documentation/Home.md +6 -5
- data/documentation/How-PWN-Works.md +7 -6
- data/documentation/Installation.md +254 -10
- data/documentation/Mission-Cancellation.md +134 -0
- data/documentation/Operability.md +17 -0
- data/documentation/Persistence.md +8 -6
- data/documentation/Plugin-Degradation.md +12 -0
- data/documentation/Plugins.md +3 -2
- data/documentation/Reinforcement-Learning.md +5 -6
- data/documentation/Runtime-Source-Protection.md +162 -0
- data/documentation/Transparent-Browser.md +11 -0
- data/documentation/Troubleshooting.md +3 -2
- data/documentation/What-is-PWN.md +2 -2
- data/documentation/YubiKey-Vaults.md +183 -0
- data/documentation/YubiKey.md +339 -0
- data/documentation/diagrams/cron-scheduling.svg +130 -128
- data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
- data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
- data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
- data/documentation/diagrams/persistence-filesystem.svg +172 -142
- data/documentation/diagrams/plugin-ecosystem.svg +64 -64
- data/documentation/pwn-ai-Agent.md +10 -4
- data/etc/default_cron/session_cleanup.rb +1245 -22
- data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
- data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
- data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
- data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
- data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
- data/etc/default_skills/pwn/backups/SKILL.md +54 -0
- data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
- data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
- data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
- data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
- data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
- data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
- data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
- data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
- data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
- data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
- data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
- data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
- data/lib/pwn/ai/agent/dispatch.rb +26 -15
- data/lib/pwn/ai/agent/extrospection.rb +6 -7
- data/lib/pwn/ai/agent/learning.rb +62 -25
- data/lib/pwn/ai/agent/loop.rb +17 -1
- data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
- data/lib/pwn/ai/agent/policy.rb +5 -0
- data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
- data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
- data/lib/pwn/ai/agent/request_runtime.rb +122 -18
- data/lib/pwn/ai/agent/reward.rb +5 -3
- data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
- data/lib/pwn/ai/agent/skill_review.rb +9 -5
- data/lib/pwn/ai/agent/swarm.rb +35 -16
- data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
- data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
- data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
- data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
- data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/ai/anthropic.rb +8 -33
- data/lib/pwn/ai/cli.rb +34 -5
- data/lib/pwn/ai/gemini.rb +1 -0
- data/lib/pwn/ai/grok.rb +39 -28
- data/lib/pwn/ai/ollama.rb +4 -2
- data/lib/pwn/ai/open_ai.rb +11 -32
- data/lib/pwn/ai/open_web_ui.rb +62 -4
- data/lib/pwn/ai/router.rb +53 -3
- data/lib/pwn/ai/server_tools.rb +231 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/backups.rb +347 -0
- data/lib/pwn/config.rb +70 -39
- data/lib/pwn/cron.rb +120 -49
- data/lib/pwn/migrate.rb +150 -68
- data/lib/pwn/module_skills.rb +7 -0
- data/lib/pwn/plugins/ai_sandbox.rb +538 -1
- data/lib/pwn/plugins/authentication_helper.rb +74 -4
- data/lib/pwn/plugins/basic_auth.rb +2 -1
- data/lib/pwn/plugins/binary_parser.rb +5 -4
- data/lib/pwn/plugins/ghidra.rb +4 -3
- data/lib/pwn/plugins/google_workspace.rb +4 -21
- data/lib/pwn/plugins/jobs.rb +1 -1
- data/lib/pwn/plugins/log.rb +21 -3
- data/lib/pwn/plugins/preflight_checker.rb +34 -9
- data/lib/pwn/plugins/recon.rb +4 -1
- data/lib/pwn/plugins/repl/ai.rb +305 -27
- data/lib/pwn/plugins/repl/ai_console.rb +273 -89
- data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
- data/lib/pwn/plugins/repl/mesh.rb +3 -16
- data/lib/pwn/plugins/repl/vault.rb +4 -15
- data/lib/pwn/plugins/repl.rb +1 -216
- data/lib/pwn/plugins/sandbox/driver.rb +4 -0
- data/lib/pwn/plugins/transparent_browser.rb +215 -0
- data/lib/pwn/plugins/vault.rb +412 -135
- data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
- data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
- data/lib/pwn/plugins/yubikey/management.rb +686 -0
- data/lib/pwn/plugins/yubikey/oath.rb +766 -0
- data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
- data/lib/pwn/plugins/yubikey/otp.rb +897 -0
- data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
- data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
- data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
- data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
- data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
- data/lib/pwn/plugins/yubikey.rb +844 -0
- data/lib/pwn/plugins.rb +1 -0
- data/lib/pwn/redaction.rb +69 -12
- data/lib/pwn/session_trace.rb +4 -0
- data/lib/pwn/sessions.rb +58 -16
- data/lib/pwn/setup.rb +123 -41
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn/www/app_cobalt_io.rb +16 -5
- data/lib/pwn/www/bug_crowd.rb +16 -5
- data/lib/pwn/www/coinbase_pro.rb +16 -5
- data/lib/pwn/www/github.rb +30 -14
- data/lib/pwn/www/paypal.rb +15 -4
- data/lib/pwn/www/synack.rb +16 -5
- data/lib/pwn/www/twitter.rb +17 -6
- data/lib/pwn.rb +1 -0
- data/scripts/build_gate.rb +58 -0
- data/spec/documentation/installation_md_spec.rb +1 -1
- data/spec/fixtures/yubikey/apdu_frames.json +162 -0
- data/spec/integration/authentication_helper_oath_spec.rb +117 -0
- data/spec/integration/build_gate_spec.rb +73 -0
- data/spec/integration/controller_spill_spec.rb +48 -0
- data/spec/integration/harness_process_spec.rb +97 -0
- data/spec/integration/mission_cancel_spec.rb +79 -0
- data/spec/integration/mission_curses_pty_spec.rb +44 -0
- data/spec/integration/persistence_roundtrip_spec.rb +1 -7
- data/spec/integration/readme_security_spec.rb +47 -0
- data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
- data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
- data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
- data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
- data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
- data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
- data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
- data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
- data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
- data/spec/lib/pwn/ai/cli_spec.rb +6 -9
- data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
- data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
- data/spec/lib/pwn/ai/router_spec.rb +31 -3
- data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
- data/spec/lib/pwn/backups_spec.rb +114 -0
- data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
- data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
- data/spec/lib/pwn/cron_spec.rb +113 -6
- data/spec/lib/pwn/migrate_spec.rb +108 -5
- data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/module_skills_spec.rb +8 -0
- data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
- data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
- data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
- data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
- data/spec/lib/pwn/plugins/log_spec.rb +7 -0
- data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
- data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
- data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +125 -78
- data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -16
- data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
- data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
- data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
- data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
- data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
- data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
- data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
- data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
- data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
- data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
- data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
- data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
- data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
- data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
- data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
- data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
- data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
- data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
- data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
- data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
- data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
- data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
- data/spec/lib/pwn/redaction_spec.rb +13 -10
- data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
- data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
- data/spec/lib/pwn/sessions_spec.rb +112 -0
- data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
- data/spec/lib/pwn/setup_spec.rb +3 -1
- data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/gate_failure_formatter.rb +14 -0
- data/spec/support/harness/cli_faults.rb +13 -9
- data/spec/support/harness/controller_spill.rb +26 -0
- data/spec/support/harness/controller_state.rb +54 -0
- data/spec/support/harness/evaluate.rb +12 -1
- data/spec/support/harness/experiments.rb +1 -1
- data/spec/support/harness/fake_provider.rb +22 -6
- data/spec/support/harness/fault_preload.rb +3 -1
- data/spec/support/harness/mission_console.rb +54 -0
- data/spec/support/harness/mission_curses_pty.rb +41 -0
- data/spec/support/harness/mission_dispatch.rb +30 -0
- data/spec/support/harness/mission_fixture_preload.rb +24 -0
- data/spec/support/harness/mission_job_lifetime.rb +25 -0
- data/spec/support/harness/mission_legacy.rb +25 -0
- data/spec/support/harness/mission_normal_exit.rb +34 -0
- data/spec/support/harness/mission_swarm.rb +23 -0
- data/spec/support/harness/mission_unsupported.rb +29 -0
- data/spec/support/harness/package_acceptance.rb +33 -5
- data/spec/support/harness/session_acceptance.rb +4 -4
- data/spec/support/harness/swarm_acceptance.rb +1 -1
- data/spec/support/harness/swarm_control.rb +44 -0
- data/spec/support/mission_namespace.rb +14 -0
- data/spec/support/mission_pipe_queue.rb +33 -0
- data/third_party/pwn_rdoc.jsonl +599 -15
- 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
|
data/spec/lib/pwn/setup_spec.rb
CHANGED
|
@@ -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
|
|
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
|