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