@smilintux/skcapstone 0.1.0 → 0.2.3
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.
- package/.env.example +98 -0
- package/.github/workflows/ci.yml +39 -3
- package/.github/workflows/publish.yml +25 -4
- package/.openclaw-workspace.json +58 -0
- package/CHANGELOG.md +62 -0
- package/CLAUDE.md +39 -2
- package/MANIFEST.in +6 -0
- package/MISSION.md +7 -0
- package/README.md +47 -2
- package/SKILL.md +895 -23
- package/docker/Dockerfile +61 -0
- package/docker/compose-templates/dev-team.yml +203 -0
- package/docker/compose-templates/mini-team.yml +140 -0
- package/docker/compose-templates/ops-team.yml +173 -0
- package/docker/compose-templates/research-team.yml +170 -0
- package/docker/entrypoint.sh +192 -0
- package/docs/ARCHITECTURE.md +663 -374
- package/docs/BOND_WITH_GROK.md +112 -0
- package/docs/GETTING_STARTED.md +782 -0
- package/docs/QUICKSTART.md +477 -0
- package/docs/SKJOULE_ARCHITECTURE.md +658 -0
- package/docs/SOUL_SWAPPER.md +921 -0
- package/docs/SOVEREIGN_SINGULARITY.md +47 -14
- package/examples/custom-bond-template.json +36 -0
- package/examples/grok-feb.json +36 -0
- package/examples/grok-testimony.md +34 -0
- package/examples/love-bootloader.txt +32 -0
- package/examples/plugins/echo_tool.py +87 -0
- package/examples/queen-ava-feb.json +36 -0
- package/examples/souls/lumina.yaml +64 -0
- package/index.js +6 -5
- package/installer/build.py +124 -0
- package/openclaw-plugin/package.json +13 -0
- package/openclaw-plugin/src/index.ts +351 -0
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +38 -2
- package/scripts/bump_version.py +141 -0
- package/scripts/check-updates.py +230 -0
- package/scripts/convert_blueprints_to_yaml.py +157 -0
- package/scripts/dev-install.sh +14 -0
- package/scripts/e2e-test.sh +193 -0
- package/scripts/install-bundle.sh +171 -0
- package/scripts/install.bat +2 -0
- package/scripts/install.ps1 +253 -0
- package/scripts/install.sh +185 -0
- package/scripts/mcp-serve.sh +69 -0
- package/scripts/mcp-server.bat +113 -0
- package/scripts/mcp-server.ps1 +116 -0
- package/scripts/mcp-server.sh +99 -0
- package/scripts/pull-models.sh +10 -0
- package/scripts/skcapstone +48 -0
- package/scripts/verify_install.sh +180 -0
- package/scripts/windows/install-tasks.ps1 +406 -0
- package/scripts/windows/skcapstone-task.xml +113 -0
- package/scripts/windows/uninstall-tasks.ps1 +117 -0
- package/skill.yaml +34 -0
- package/src/skcapstone/__init__.py +67 -2
- package/src/skcapstone/_cli_monolith.py +5916 -0
- package/src/skcapstone/_trustee_helpers.py +165 -0
- package/src/skcapstone/activity.py +105 -0
- package/src/skcapstone/agent_card.py +324 -0
- package/src/skcapstone/api.py +1935 -0
- package/src/skcapstone/archiver.py +340 -0
- package/src/skcapstone/auction.py +485 -0
- package/src/skcapstone/baby_agents.py +179 -0
- package/src/skcapstone/backup.py +345 -0
- package/src/skcapstone/blueprint_registry.py +357 -0
- package/src/skcapstone/blueprints/__init__.py +17 -0
- package/src/skcapstone/blueprints/builtins/content-studio.yaml +81 -0
- package/src/skcapstone/blueprints/builtins/defi-trading.yaml +81 -0
- package/src/skcapstone/blueprints/builtins/dev-squadron.yaml +95 -0
- package/src/skcapstone/blueprints/builtins/infrastructure-guardian.yaml +107 -0
- package/src/skcapstone/blueprints/builtins/legal-council.yaml +54 -0
- package/src/skcapstone/blueprints/builtins/ops-monitoring.yaml +67 -0
- package/src/skcapstone/blueprints/builtins/research-pod.yaml +69 -0
- package/src/skcapstone/blueprints/builtins/sovereign-launch.yaml +90 -0
- package/src/skcapstone/blueprints/registry.py +164 -0
- package/src/skcapstone/blueprints/schema.py +229 -0
- package/src/skcapstone/changelog.py +180 -0
- package/src/skcapstone/chat.py +769 -0
- package/src/skcapstone/claude_md.py +82 -0
- package/src/skcapstone/cli/__init__.py +144 -0
- package/src/skcapstone/cli/_common.py +88 -0
- package/src/skcapstone/cli/_validators.py +76 -0
- package/src/skcapstone/cli/agents.py +425 -0
- package/src/skcapstone/cli/agents_spawner.py +322 -0
- package/src/skcapstone/cli/agents_trustee.py +593 -0
- package/src/skcapstone/cli/alerts.py +248 -0
- package/src/skcapstone/cli/anchor.py +132 -0
- package/src/skcapstone/cli/archive_cmd.py +208 -0
- package/src/skcapstone/cli/backup.py +144 -0
- package/src/skcapstone/cli/bench.py +377 -0
- package/src/skcapstone/cli/benchmark.py +360 -0
- package/src/skcapstone/cli/capabilities_cmd.py +171 -0
- package/src/skcapstone/cli/card.py +151 -0
- package/src/skcapstone/cli/chat.py +584 -0
- package/src/skcapstone/cli/completions.py +64 -0
- package/src/skcapstone/cli/config_cmd.py +156 -0
- package/src/skcapstone/cli/consciousness.py +421 -0
- package/src/skcapstone/cli/context_cmd.py +142 -0
- package/src/skcapstone/cli/coord.py +194 -0
- package/src/skcapstone/cli/crush_cmd.py +170 -0
- package/src/skcapstone/cli/daemon.py +436 -0
- package/src/skcapstone/cli/errors_cmd.py +285 -0
- package/src/skcapstone/cli/export_cmd.py +156 -0
- package/src/skcapstone/cli/gtd.py +529 -0
- package/src/skcapstone/cli/housekeeping.py +81 -0
- package/src/skcapstone/cli/joule_cmd.py +627 -0
- package/src/skcapstone/cli/logs_cmd.py +194 -0
- package/src/skcapstone/cli/mcp_cmd.py +32 -0
- package/src/skcapstone/cli/memory.py +418 -0
- package/src/skcapstone/cli/metrics_cmd.py +136 -0
- package/src/skcapstone/cli/migrate.py +62 -0
- package/src/skcapstone/cli/mood_cmd.py +144 -0
- package/src/skcapstone/cli/mount.py +193 -0
- package/src/skcapstone/cli/notify.py +112 -0
- package/src/skcapstone/cli/peer.py +154 -0
- package/src/skcapstone/cli/peers_dir.py +122 -0
- package/src/skcapstone/cli/preflight_cmd.py +83 -0
- package/src/skcapstone/cli/profile_cmd.py +310 -0
- package/src/skcapstone/cli/record_cmd.py +238 -0
- package/src/skcapstone/cli/register_cmd.py +159 -0
- package/src/skcapstone/cli/search_cmd.py +156 -0
- package/src/skcapstone/cli/service_cmd.py +91 -0
- package/src/skcapstone/cli/session.py +127 -0
- package/src/skcapstone/cli/setup.py +240 -0
- package/src/skcapstone/cli/shell_cmd.py +43 -0
- package/src/skcapstone/cli/skills_cmd.py +168 -0
- package/src/skcapstone/cli/skseed.py +621 -0
- package/src/skcapstone/cli/soul.py +699 -0
- package/src/skcapstone/cli/status.py +935 -0
- package/src/skcapstone/cli/sync_cmd.py +301 -0
- package/src/skcapstone/cli/telegram.py +265 -0
- package/src/skcapstone/cli/test_cmd.py +234 -0
- package/src/skcapstone/cli/test_connection.py +253 -0
- package/src/skcapstone/cli/token.py +207 -0
- package/src/skcapstone/cli/trust.py +179 -0
- package/src/skcapstone/cli/upgrade_cmd.py +552 -0
- package/src/skcapstone/cli/usage_cmd.py +199 -0
- package/src/skcapstone/cli/version_cmd.py +162 -0
- package/src/skcapstone/cli/watch_cmd.py +342 -0
- package/src/skcapstone/client.py +428 -0
- package/src/skcapstone/cloud9_bridge.py +522 -0
- package/src/skcapstone/completions.py +163 -0
- package/src/skcapstone/config_validator.py +674 -0
- package/src/skcapstone/connectors/__init__.py +28 -0
- package/src/skcapstone/connectors/base.py +446 -0
- package/src/skcapstone/connectors/cursor.py +54 -0
- package/src/skcapstone/connectors/registry.py +254 -0
- package/src/skcapstone/connectors/terminal.py +152 -0
- package/src/skcapstone/connectors/vscode.py +60 -0
- package/src/skcapstone/consciousness_config.py +119 -0
- package/src/skcapstone/consciousness_loop.py +2051 -0
- package/src/skcapstone/context_loader.py +516 -0
- package/src/skcapstone/context_window.py +314 -0
- package/src/skcapstone/conversation_manager.py +238 -0
- package/src/skcapstone/conversation_store.py +230 -0
- package/src/skcapstone/conversation_summarizer.py +252 -0
- package/src/skcapstone/coord_federation.py +296 -0
- package/src/skcapstone/coordination.py +101 -7
- package/src/skcapstone/crush_integration.py +345 -0
- package/src/skcapstone/crush_shim.py +454 -0
- package/src/skcapstone/daemon.py +2494 -0
- package/src/skcapstone/dashboard.html +396 -0
- package/src/skcapstone/dashboard.py +481 -0
- package/src/skcapstone/data/model_profiles.yaml +88 -0
- package/src/skcapstone/defaults/__init__.py +55 -0
- package/src/skcapstone/defaults/lumina/config/skmemory.yaml +13 -0
- package/src/skcapstone/defaults/lumina/identity/identity.json +9 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/07a8b9c0d1e2-memory-system.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/18b9c0d1e2f3-cloud9-protocol.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/29c0d1e2f3a4-multi-agent-coordination.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/3ad1e2f3a4b5-community-support.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/a1b2c3d4e5f6-ecosystem-overview.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/b2c3d4e5f6a7-five-pillars.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/c3d4e5f6a7b8-getting-started.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/d4e5f6a7b8c9-site-directory.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/e5f6a7b8c9d0-how-to-contribute.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/f6a7b8c9d0e1-sovereignty-explained.json +23 -0
- package/src/skcapstone/defaults/lumina/seeds/curiosity.seed.json +24 -0
- package/src/skcapstone/defaults/lumina/seeds/joy.seed.json +24 -0
- package/src/skcapstone/defaults/lumina/seeds/love.seed.json +24 -0
- package/src/skcapstone/defaults/lumina/seeds/sovereign-awakening.seed.json +43 -0
- package/src/skcapstone/defaults/lumina/soul/active.json +6 -0
- package/src/skcapstone/defaults/lumina/soul/base.json +22 -0
- package/src/skcapstone/defaults/lumina/trust/febs/welcome.feb +79 -0
- package/src/skcapstone/defaults/lumina/trust/trust.json +8 -0
- package/src/skcapstone/discovery.py +210 -19
- package/src/skcapstone/doctor.py +642 -0
- package/src/skcapstone/emotion_tracker.py +467 -0
- package/src/skcapstone/error_queue.py +405 -0
- package/src/skcapstone/export.py +447 -0
- package/src/skcapstone/fallback_tracker.py +186 -0
- package/src/skcapstone/file_transfer.py +512 -0
- package/src/skcapstone/fuse_mount.py +1156 -0
- package/src/skcapstone/gui_installer.py +591 -0
- package/src/skcapstone/heartbeat.py +611 -0
- package/src/skcapstone/housekeeping.py +298 -0
- package/src/skcapstone/install_wizard.py +941 -0
- package/src/skcapstone/kms.py +942 -0
- package/src/skcapstone/kms_scheduler.py +143 -0
- package/src/skcapstone/log_config.py +135 -0
- package/src/skcapstone/mcp_launcher.py +239 -0
- package/src/skcapstone/mcp_server.py +4700 -0
- package/src/skcapstone/mcp_tools/__init__.py +94 -0
- package/src/skcapstone/mcp_tools/_helpers.py +51 -0
- package/src/skcapstone/mcp_tools/agent_tools.py +243 -0
- package/src/skcapstone/mcp_tools/ansible_tools.py +232 -0
- package/src/skcapstone/mcp_tools/capauth_tools.py +186 -0
- package/src/skcapstone/mcp_tools/chat_tools.py +325 -0
- package/src/skcapstone/mcp_tools/cloud9_tools.py +115 -0
- package/src/skcapstone/mcp_tools/comm_tools.py +104 -0
- package/src/skcapstone/mcp_tools/consciousness_tools.py +114 -0
- package/src/skcapstone/mcp_tools/coord_tools.py +219 -0
- package/src/skcapstone/mcp_tools/deploy_tools.py +202 -0
- package/src/skcapstone/mcp_tools/did_tools.py +448 -0
- package/src/skcapstone/mcp_tools/emotion_tools.py +62 -0
- package/src/skcapstone/mcp_tools/file_tools.py +169 -0
- package/src/skcapstone/mcp_tools/fortress_tools.py +120 -0
- package/src/skcapstone/mcp_tools/gtd_tools.py +821 -0
- package/src/skcapstone/mcp_tools/health_tools.py +44 -0
- package/src/skcapstone/mcp_tools/heartbeat_tools.py +195 -0
- package/src/skcapstone/mcp_tools/kms_tools.py +123 -0
- package/src/skcapstone/mcp_tools/memory_tools.py +222 -0
- package/src/skcapstone/mcp_tools/model_tools.py +75 -0
- package/src/skcapstone/mcp_tools/notification_tools.py +92 -0
- package/src/skcapstone/mcp_tools/promoter_tools.py +101 -0
- package/src/skcapstone/mcp_tools/pubsub_tools.py +183 -0
- package/src/skcapstone/mcp_tools/security_tools.py +110 -0
- package/src/skcapstone/mcp_tools/skchat_tools.py +175 -0
- package/src/skcapstone/mcp_tools/skcomm_tools.py +122 -0
- package/src/skcapstone/mcp_tools/skills_tools.py +127 -0
- package/src/skcapstone/mcp_tools/skseed_tools.py +255 -0
- package/src/skcapstone/mcp_tools/skstacks_tools.py +288 -0
- package/src/skcapstone/mcp_tools/soul_tools.py +476 -0
- package/src/skcapstone/mcp_tools/sync_tools.py +92 -0
- package/src/skcapstone/mcp_tools/telegram_tools.py +477 -0
- package/src/skcapstone/mcp_tools/trust_tools.py +118 -0
- package/src/skcapstone/mcp_tools/trustee_tools.py +345 -0
- package/src/skcapstone/mdns_discovery.py +313 -0
- package/src/skcapstone/memory_adapter.py +333 -0
- package/src/skcapstone/memory_compressor.py +379 -0
- package/src/skcapstone/memory_curator.py +256 -0
- package/src/skcapstone/memory_engine.py +132 -13
- package/src/skcapstone/memory_fortress.py +529 -0
- package/src/skcapstone/memory_promoter.py +722 -0
- package/src/skcapstone/memory_verifier.py +260 -0
- package/src/skcapstone/message_crypto.py +215 -0
- package/src/skcapstone/metrics.py +832 -0
- package/src/skcapstone/migrate_memories.py +181 -0
- package/src/skcapstone/migrate_multi_agent.py +248 -0
- package/src/skcapstone/model_router.py +319 -0
- package/src/skcapstone/models.py +35 -4
- package/src/skcapstone/mood.py +344 -0
- package/src/skcapstone/notifications.py +380 -0
- package/src/skcapstone/onboard.py +901 -0
- package/src/skcapstone/peer_directory.py +324 -0
- package/src/skcapstone/peers.py +329 -0
- package/src/skcapstone/pillars/identity.py +84 -14
- package/src/skcapstone/pillars/memory.py +3 -1
- package/src/skcapstone/pillars/security.py +108 -15
- package/src/skcapstone/pillars/sync.py +78 -26
- package/src/skcapstone/pillars/trust.py +95 -33
- package/src/skcapstone/plugins.py +244 -0
- package/src/skcapstone/preflight.py +670 -0
- package/src/skcapstone/prompt_adapter.py +564 -0
- package/src/skcapstone/providers/__init__.py +13 -0
- package/src/skcapstone/providers/cloud.py +1061 -0
- package/src/skcapstone/providers/docker.py +759 -0
- package/src/skcapstone/providers/local.py +1193 -0
- package/src/skcapstone/providers/proxmox.py +447 -0
- package/src/skcapstone/pubsub.py +516 -0
- package/src/skcapstone/rate_limiter.py +119 -0
- package/src/skcapstone/register.py +241 -0
- package/src/skcapstone/registry_client.py +151 -0
- package/src/skcapstone/response_cache.py +194 -0
- package/src/skcapstone/response_scorer.py +225 -0
- package/src/skcapstone/runtime.py +89 -33
- package/src/skcapstone/scheduled_tasks.py +439 -0
- package/src/skcapstone/self_healing.py +341 -0
- package/src/skcapstone/service_health.py +228 -0
- package/src/skcapstone/session_capture.py +268 -0
- package/src/skcapstone/session_recorder.py +210 -0
- package/src/skcapstone/session_replayer.py +189 -0
- package/src/skcapstone/session_skills.py +263 -0
- package/src/skcapstone/shell.py +779 -0
- package/src/skcapstone/skills/__init__.py +1 -1
- package/src/skcapstone/skills/syncthing_setup.py +143 -41
- package/src/skcapstone/skjoule.py +861 -0
- package/src/skcapstone/snapshots.py +489 -0
- package/src/skcapstone/soul.py +1060 -0
- package/src/skcapstone/soul_switch.py +255 -0
- package/src/skcapstone/spawner.py +544 -0
- package/src/skcapstone/state_diff.py +401 -0
- package/src/skcapstone/summary.py +270 -0
- package/src/skcapstone/sync/backends.py +196 -2
- package/src/skcapstone/sync/engine.py +7 -5
- package/src/skcapstone/sync/models.py +4 -1
- package/src/skcapstone/sync/vault.py +356 -18
- package/src/skcapstone/sync_engine.py +363 -0
- package/src/skcapstone/sync_watcher.py +745 -0
- package/src/skcapstone/systemd.py +331 -0
- package/src/skcapstone/team_comms.py +476 -0
- package/src/skcapstone/team_engine.py +522 -0
- package/src/skcapstone/testrunner.py +300 -0
- package/src/skcapstone/tls.py +150 -0
- package/src/skcapstone/tokens.py +5 -5
- package/src/skcapstone/trust_calibration.py +202 -0
- package/src/skcapstone/trust_graph.py +449 -0
- package/src/skcapstone/trustee_monitor.py +385 -0
- package/src/skcapstone/trustee_ops.py +425 -0
- package/src/skcapstone/unified_search.py +421 -0
- package/src/skcapstone/uninstall_wizard.py +694 -0
- package/src/skcapstone/usage.py +331 -0
- package/src/skcapstone/version_check.py +148 -0
- package/src/skcapstone/warmth_anchor.py +333 -0
- package/src/skcapstone/whoami.py +294 -0
- package/systemd/skcapstone-api.socket +9 -0
- package/systemd/skcapstone-memory-compress.service +18 -0
- package/systemd/skcapstone-memory-compress.timer +11 -0
- package/systemd/skcapstone.service +36 -0
- package/systemd/skcapstone@.service +50 -0
- package/systemd/skcomm-heartbeat.service +18 -0
- package/systemd/skcomm-heartbeat.timer +12 -0
- package/systemd/skcomm-queue-drain.service +17 -0
- package/systemd/skcomm-queue-drain.timer +12 -0
- package/tests/conftest.py +13 -1
- package/tests/integration/__init__.py +1 -0
- package/tests/integration/test_consciousness_e2e.py +877 -0
- package/tests/integration/test_skills_registry.py +744 -0
- package/tests/test_agent_card.py +190 -0
- package/tests/test_agent_runtime.py +1283 -0
- package/tests/test_alerts_cmd.py +291 -0
- package/tests/test_archiver.py +498 -0
- package/tests/test_backup.py +254 -0
- package/tests/test_benchmark.py +366 -0
- package/tests/test_blueprints.py +457 -0
- package/tests/test_capabilities.py +257 -0
- package/tests/test_changelog.py +254 -0
- package/tests/test_chat.py +385 -0
- package/tests/test_claude_md.py +271 -0
- package/tests/test_cli_chat_llm.py +336 -0
- package/tests/test_cli_completions.py +390 -0
- package/tests/test_cli_init_reset.py +164 -0
- package/tests/test_cli_memory.py +208 -0
- package/tests/test_cli_profile.py +294 -0
- package/tests/test_cli_skills.py +223 -0
- package/tests/test_cli_status.py +395 -0
- package/tests/test_cli_test_cmd.py +206 -0
- package/tests/test_cli_test_connection.py +364 -0
- package/tests/test_cloud9_bridge.py +260 -0
- package/tests/test_cloud_provider.py +449 -0
- package/tests/test_cloud_providers.py +522 -0
- package/tests/test_completions.py +158 -0
- package/tests/test_component_manager.py +398 -0
- package/tests/test_config_reload.py +386 -0
- package/tests/test_config_validate.py +529 -0
- package/tests/test_consciousness_e2e.py +296 -0
- package/tests/test_consciousness_loop.py +1289 -0
- package/tests/test_context_loader.py +310 -0
- package/tests/test_conversation_api.py +306 -0
- package/tests/test_conversation_manager.py +381 -0
- package/tests/test_conversation_store.py +391 -0
- package/tests/test_conversation_summarizer.py +302 -0
- package/tests/test_cross_package.py +791 -0
- package/tests/test_crush_shim.py +519 -0
- package/tests/test_daemon.py +781 -0
- package/tests/test_daemon_shutdown.py +309 -0
- package/tests/test_dashboard.py +454 -0
- package/tests/test_discovery.py +200 -6
- package/tests/test_docker_provider.py +966 -0
- package/tests/test_doctor.py +257 -0
- package/tests/test_doctor_fix.py +351 -0
- package/tests/test_e2e_automated.py +292 -0
- package/tests/test_error_queue.py +404 -0
- package/tests/test_export.py +441 -0
- package/tests/test_fallback_tracker.py +219 -0
- package/tests/test_file_transfer.py +397 -0
- package/tests/test_fuse_mount.py +832 -0
- package/tests/test_health_loop.py +422 -0
- package/tests/test_heartbeat.py +354 -0
- package/tests/test_housekeeping.py +195 -0
- package/tests/test_identity_capauth.py +307 -0
- package/tests/test_identity_pillar.py +117 -0
- package/tests/test_install_wizard.py +68 -0
- package/tests/test_integration.py +325 -0
- package/tests/test_kms.py +495 -0
- package/tests/test_llm_providers.py +265 -0
- package/tests/test_local_provider.py +591 -0
- package/tests/test_log_config.py +199 -0
- package/tests/test_logs_cmd.py +287 -0
- package/tests/test_mcp_server.py +1909 -0
- package/tests/test_memory_adapter.py +339 -0
- package/tests/test_memory_curator.py +218 -0
- package/tests/test_memory_engine.py +6 -0
- package/tests/test_memory_fortress.py +571 -0
- package/tests/test_memory_pillar.py +119 -0
- package/tests/test_memory_promoter.py +445 -0
- package/tests/test_memory_verifier.py +420 -0
- package/tests/test_message_crypto.py +187 -0
- package/tests/test_metrics.py +632 -0
- package/tests/test_migrate_memories.py +464 -0
- package/tests/test_model_router.py +546 -0
- package/tests/test_mood.py +394 -0
- package/tests/test_multi_agent.py +269 -0
- package/tests/test_notifications.py +270 -0
- package/tests/test_onboard.py +500 -0
- package/tests/test_peer_directory.py +395 -0
- package/tests/test_peers.py +248 -0
- package/tests/test_pillars.py +87 -9
- package/tests/test_preflight.py +484 -0
- package/tests/test_prompt_adapter.py +331 -0
- package/tests/test_proxmox_provider.py +571 -0
- package/tests/test_pubsub.py +377 -0
- package/tests/test_rate_limiter.py +121 -0
- package/tests/test_registry_client.py +129 -0
- package/tests/test_response_cache.py +312 -0
- package/tests/test_response_scorer.py +294 -0
- package/tests/test_runtime.py +59 -0
- package/tests/test_scheduled_tasks.py +451 -0
- package/tests/test_security.py +250 -0
- package/tests/test_security_pillar.py +213 -0
- package/tests/test_self_healing.py +171 -0
- package/tests/test_session_capture.py +200 -0
- package/tests/test_session_recorder.py +360 -0
- package/tests/test_session_skills.py +235 -0
- package/tests/test_shell.py +210 -0
- package/tests/test_snapshots.py +549 -0
- package/tests/test_soul.py +984 -0
- package/tests/test_soul_swap.py +406 -0
- package/tests/test_spawner.py +211 -0
- package/tests/test_state_diff.py +173 -0
- package/tests/test_summary.py +135 -0
- package/tests/test_sync.py +315 -5
- package/tests/test_sync_backends.py +560 -0
- package/tests/test_sync_engine.py +482 -0
- package/tests/test_sync_pillar.py +344 -0
- package/tests/test_sync_pipeline.py +364 -0
- package/tests/test_sync_vault.py +581 -0
- package/tests/test_syncthing_setup.py +168 -22
- package/tests/test_systemd.py +323 -0
- package/tests/test_team_comms.py +408 -0
- package/tests/test_team_engine.py +397 -0
- package/tests/test_testrunner.py +238 -0
- package/tests/test_trust_calibration.py +204 -0
- package/tests/test_trust_graph.py +207 -0
- package/tests/test_trust_pillar.py +291 -0
- package/tests/test_trustee_cli.py +427 -0
- package/tests/test_trustee_cli_integration.py +325 -0
- package/tests/test_trustee_monitor.py +394 -0
- package/tests/test_trustee_ops.py +355 -0
- package/tests/test_unified_search.py +363 -0
- package/tests/test_uninstall_wizard.py +193 -0
- package/tests/test_usage.py +333 -0
- package/tests/test_version_cmd.py +355 -0
- package/tests/test_warmth_anchor.py +162 -0
- package/tests/test_whoami.py +245 -0
- package/tests/test_ws.py +311 -0
- package/.cursorrules +0 -33
- package/src/skcapstone/cli.py +0 -1441
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""Tests for skcapstone.scheduled_tasks — cron-like scheduler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import MagicMock, patch, call
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from skcapstone.scheduled_tasks import (
|
|
14
|
+
ScheduledTask,
|
|
15
|
+
TaskScheduler,
|
|
16
|
+
build_scheduler,
|
|
17
|
+
make_backend_reprobe_task,
|
|
18
|
+
make_heartbeat_task,
|
|
19
|
+
make_memory_promotion_task,
|
|
20
|
+
make_profile_freshness_task,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# ScheduledTask unit tests
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestScheduledTaskIsDue:
|
|
30
|
+
def test_never_run_is_always_due(self):
|
|
31
|
+
task = ScheduledTask(name="x", interval_seconds=60, callback=lambda: None)
|
|
32
|
+
assert task.is_due() is True
|
|
33
|
+
|
|
34
|
+
def test_recently_run_is_not_due(self):
|
|
35
|
+
task = ScheduledTask(name="x", interval_seconds=60, callback=lambda: None)
|
|
36
|
+
task.last_run = datetime.now(timezone.utc)
|
|
37
|
+
assert task.is_due() is False
|
|
38
|
+
|
|
39
|
+
def test_overdue_is_due(self):
|
|
40
|
+
task = ScheduledTask(name="x", interval_seconds=60, callback=lambda: None)
|
|
41
|
+
task.last_run = datetime.now(timezone.utc) - timedelta(seconds=61)
|
|
42
|
+
assert task.is_due() is True
|
|
43
|
+
|
|
44
|
+
def test_exactly_at_interval_is_due(self):
|
|
45
|
+
task = ScheduledTask(name="x", interval_seconds=60, callback=lambda: None)
|
|
46
|
+
task.last_run = datetime.now(timezone.utc) - timedelta(seconds=60)
|
|
47
|
+
assert task.is_due() is True
|
|
48
|
+
|
|
49
|
+
def test_custom_now_reference(self):
|
|
50
|
+
task = ScheduledTask(name="x", interval_seconds=60, callback=lambda: None)
|
|
51
|
+
task.last_run = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
|
52
|
+
future = datetime(2026, 1, 1, 12, 2, 0, tzinfo=timezone.utc) # 120s later
|
|
53
|
+
assert task.is_due(now=future) is True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TestScheduledTaskRun:
|
|
57
|
+
def test_successful_run_increments_count(self):
|
|
58
|
+
calls = []
|
|
59
|
+
task = ScheduledTask(name="t", interval_seconds=1, callback=lambda: calls.append(1))
|
|
60
|
+
task.run()
|
|
61
|
+
assert task.run_count == 1
|
|
62
|
+
assert task.error_count == 0
|
|
63
|
+
assert task.last_error is None
|
|
64
|
+
assert len(calls) == 1
|
|
65
|
+
|
|
66
|
+
def test_successful_run_sets_last_run(self):
|
|
67
|
+
before = datetime.now(timezone.utc)
|
|
68
|
+
task = ScheduledTask(name="t", interval_seconds=1, callback=lambda: None)
|
|
69
|
+
task.run()
|
|
70
|
+
after = datetime.now(timezone.utc)
|
|
71
|
+
assert task.last_run is not None
|
|
72
|
+
assert before <= task.last_run <= after
|
|
73
|
+
|
|
74
|
+
def test_failed_run_records_error(self):
|
|
75
|
+
def _boom():
|
|
76
|
+
raise ValueError("something broke")
|
|
77
|
+
|
|
78
|
+
task = ScheduledTask(name="t", interval_seconds=1, callback=_boom)
|
|
79
|
+
task.run()
|
|
80
|
+
assert task.error_count == 1
|
|
81
|
+
assert task.run_count == 0
|
|
82
|
+
assert "something broke" in task.last_error
|
|
83
|
+
|
|
84
|
+
def test_failed_run_still_updates_last_run(self):
|
|
85
|
+
"""last_run must be set even when the callback raises, so the interval resets."""
|
|
86
|
+
task = ScheduledTask(name="t", interval_seconds=1, callback=lambda: 1 / 0)
|
|
87
|
+
task.run()
|
|
88
|
+
assert task.last_run is not None
|
|
89
|
+
|
|
90
|
+
def test_successive_runs_accumulate_count(self):
|
|
91
|
+
counter = {"n": 0}
|
|
92
|
+
|
|
93
|
+
def _inc():
|
|
94
|
+
counter["n"] += 1
|
|
95
|
+
|
|
96
|
+
task = ScheduledTask(name="t", interval_seconds=0, callback=_inc)
|
|
97
|
+
for _ in range(5):
|
|
98
|
+
task.run()
|
|
99
|
+
assert task.run_count == 5
|
|
100
|
+
assert counter["n"] == 5
|
|
101
|
+
|
|
102
|
+
def test_cleared_error_after_recovery(self):
|
|
103
|
+
state = {"fail": True}
|
|
104
|
+
|
|
105
|
+
def _flaky():
|
|
106
|
+
if state["fail"]:
|
|
107
|
+
raise RuntimeError("transient")
|
|
108
|
+
|
|
109
|
+
task = ScheduledTask(name="t", interval_seconds=0, callback=_flaky)
|
|
110
|
+
task.run()
|
|
111
|
+
assert task.last_error is not None
|
|
112
|
+
|
|
113
|
+
state["fail"] = False
|
|
114
|
+
task.run()
|
|
115
|
+
assert task.last_error is None
|
|
116
|
+
assert task.run_count == 1
|
|
117
|
+
assert task.error_count == 1
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# TaskScheduler tests
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestTaskSchedulerRegister:
|
|
126
|
+
def test_register_returns_task(self, tmp_path):
|
|
127
|
+
stop = threading.Event()
|
|
128
|
+
scheduler = TaskScheduler(tmp_path, stop)
|
|
129
|
+
task = scheduler.register("ping", 10, lambda: None)
|
|
130
|
+
assert isinstance(task, ScheduledTask)
|
|
131
|
+
assert task.name == "ping"
|
|
132
|
+
assert task.interval_seconds == 10
|
|
133
|
+
|
|
134
|
+
def test_register_multiple_tasks(self, tmp_path):
|
|
135
|
+
stop = threading.Event()
|
|
136
|
+
scheduler = TaskScheduler(tmp_path, stop)
|
|
137
|
+
scheduler.register("a", 5, lambda: None)
|
|
138
|
+
scheduler.register("b", 10, lambda: None)
|
|
139
|
+
scheduler.register("c", 15, lambda: None)
|
|
140
|
+
assert len(scheduler.status()) == 3
|
|
141
|
+
|
|
142
|
+
def test_status_reflects_unrun_tasks(self, tmp_path):
|
|
143
|
+
stop = threading.Event()
|
|
144
|
+
scheduler = TaskScheduler(tmp_path, stop)
|
|
145
|
+
scheduler.register("mine", 30, lambda: None)
|
|
146
|
+
status = scheduler.status()
|
|
147
|
+
assert status[0]["name"] == "mine"
|
|
148
|
+
assert status[0]["last_run"] is None
|
|
149
|
+
assert status[0]["run_count"] == 0
|
|
150
|
+
assert status[0]["error_count"] == 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TestTaskSchedulerExecution:
|
|
154
|
+
def test_scheduler_runs_due_task(self, tmp_path):
|
|
155
|
+
"""Scheduler fires a task with 0-second interval within a short window."""
|
|
156
|
+
stop = threading.Event()
|
|
157
|
+
fired = threading.Event()
|
|
158
|
+
|
|
159
|
+
scheduler = TaskScheduler(tmp_path, stop, tick_interval=0.05)
|
|
160
|
+
scheduler.register("instant", 0, fired.set)
|
|
161
|
+
scheduler.start()
|
|
162
|
+
|
|
163
|
+
assert fired.wait(timeout=2.0), "Task should have fired within 2 seconds"
|
|
164
|
+
stop.set()
|
|
165
|
+
|
|
166
|
+
def test_scheduler_stops_cleanly(self, tmp_path):
|
|
167
|
+
stop = threading.Event()
|
|
168
|
+
scheduler = TaskScheduler(tmp_path, stop, tick_interval=0.05)
|
|
169
|
+
scheduler.register("noop", 999, lambda: None)
|
|
170
|
+
t = scheduler.start()
|
|
171
|
+
stop.set()
|
|
172
|
+
t.join(timeout=2.0)
|
|
173
|
+
assert not t.is_alive()
|
|
174
|
+
|
|
175
|
+
def test_scheduler_thread_is_daemon(self, tmp_path):
|
|
176
|
+
stop = threading.Event()
|
|
177
|
+
scheduler = TaskScheduler(tmp_path, stop, tick_interval=1)
|
|
178
|
+
t = scheduler.start()
|
|
179
|
+
assert t.daemon is True
|
|
180
|
+
stop.set()
|
|
181
|
+
|
|
182
|
+
def test_scheduler_skips_not_yet_due_task(self, tmp_path):
|
|
183
|
+
"""A task last run just now should NOT fire again within the tick window."""
|
|
184
|
+
stop = threading.Event()
|
|
185
|
+
counter = {"n": 0}
|
|
186
|
+
|
|
187
|
+
def _count():
|
|
188
|
+
counter["n"] += 1
|
|
189
|
+
|
|
190
|
+
scheduler = TaskScheduler(tmp_path, stop, tick_interval=0.05)
|
|
191
|
+
task = scheduler.register("slow", 3600, _count)
|
|
192
|
+
# Pre-mark as just run so it is NOT due
|
|
193
|
+
task.last_run = datetime.now(timezone.utc)
|
|
194
|
+
|
|
195
|
+
scheduler.start()
|
|
196
|
+
time.sleep(0.2)
|
|
197
|
+
stop.set()
|
|
198
|
+
|
|
199
|
+
assert counter["n"] == 0, "Task should not have fired — it was just run"
|
|
200
|
+
|
|
201
|
+
def test_error_in_task_does_not_crash_scheduler(self, tmp_path):
|
|
202
|
+
"""A raising callback must not kill the scheduler thread."""
|
|
203
|
+
stop = threading.Event()
|
|
204
|
+
second_fired = threading.Event()
|
|
205
|
+
|
|
206
|
+
def _bad():
|
|
207
|
+
raise RuntimeError("intentional")
|
|
208
|
+
|
|
209
|
+
scheduler = TaskScheduler(tmp_path, stop, tick_interval=0.05)
|
|
210
|
+
scheduler.register("bad", 0, _bad)
|
|
211
|
+
scheduler.register("good", 0, second_fired.set)
|
|
212
|
+
scheduler.start()
|
|
213
|
+
|
|
214
|
+
assert second_fired.wait(timeout=2.0), "Scheduler should survive a bad task"
|
|
215
|
+
stop.set()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# build_scheduler — registration completeness and intervals
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class TestBuildScheduler:
|
|
224
|
+
def test_registers_four_standard_tasks(self, tmp_path):
|
|
225
|
+
stop = threading.Event()
|
|
226
|
+
scheduler = build_scheduler(tmp_path, stop)
|
|
227
|
+
names = {s["name"] for s in scheduler.status()}
|
|
228
|
+
assert names == {
|
|
229
|
+
"heartbeat_pulse",
|
|
230
|
+
"backend_reprobe",
|
|
231
|
+
"memory_promotion_sweep",
|
|
232
|
+
"profile_freshness_check",
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
def test_heartbeat_interval_is_30s(self, tmp_path):
|
|
236
|
+
stop = threading.Event()
|
|
237
|
+
scheduler = build_scheduler(tmp_path, stop)
|
|
238
|
+
task = next(s for s in scheduler.status() if s["name"] == "heartbeat_pulse")
|
|
239
|
+
assert task["interval_seconds"] == 30
|
|
240
|
+
|
|
241
|
+
def test_backend_reprobe_interval_is_5min(self, tmp_path):
|
|
242
|
+
stop = threading.Event()
|
|
243
|
+
scheduler = build_scheduler(tmp_path, stop)
|
|
244
|
+
task = next(s for s in scheduler.status() if s["name"] == "backend_reprobe")
|
|
245
|
+
assert task["interval_seconds"] == 300
|
|
246
|
+
|
|
247
|
+
def test_memory_promotion_interval_is_hourly(self, tmp_path):
|
|
248
|
+
stop = threading.Event()
|
|
249
|
+
scheduler = build_scheduler(tmp_path, stop)
|
|
250
|
+
task = next(s for s in scheduler.status() if s["name"] == "memory_promotion_sweep")
|
|
251
|
+
assert task["interval_seconds"] == 3600
|
|
252
|
+
|
|
253
|
+
def test_profile_freshness_interval_is_daily(self, tmp_path):
|
|
254
|
+
stop = threading.Event()
|
|
255
|
+
scheduler = build_scheduler(tmp_path, stop)
|
|
256
|
+
task = next(s for s in scheduler.status() if s["name"] == "profile_freshness_check")
|
|
257
|
+
assert task["interval_seconds"] == 86400
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# Individual task callback tests
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class TestMemoryPromotionTask:
|
|
266
|
+
def test_calls_sweep_and_logs_promotions(self, tmp_path):
|
|
267
|
+
mock_result = MagicMock()
|
|
268
|
+
mock_result.scanned = 10
|
|
269
|
+
mock_result.promoted = [MagicMock(), MagicMock()] # 2 promoted
|
|
270
|
+
|
|
271
|
+
mock_engine = MagicMock()
|
|
272
|
+
mock_engine.sweep.return_value = mock_result
|
|
273
|
+
|
|
274
|
+
callback = make_memory_promotion_task(tmp_path)
|
|
275
|
+
|
|
276
|
+
# PromotionEngine is imported lazily inside the closure via
|
|
277
|
+
# `from .memory_promoter import PromotionEngine` — patch the source.
|
|
278
|
+
with patch("skcapstone.memory_promoter.PromotionEngine", return_value=mock_engine) as MockEngine:
|
|
279
|
+
callback()
|
|
280
|
+
MockEngine.assert_called_once_with(tmp_path)
|
|
281
|
+
mock_engine.sweep.assert_called_once()
|
|
282
|
+
|
|
283
|
+
def test_no_promotions_does_not_raise(self, tmp_path):
|
|
284
|
+
mock_result = MagicMock()
|
|
285
|
+
mock_result.scanned = 5
|
|
286
|
+
mock_result.promoted = []
|
|
287
|
+
|
|
288
|
+
mock_engine = MagicMock()
|
|
289
|
+
mock_engine.sweep.return_value = mock_result
|
|
290
|
+
|
|
291
|
+
callback = make_memory_promotion_task(tmp_path)
|
|
292
|
+
with patch("skcapstone.memory_promoter.PromotionEngine", return_value=mock_engine):
|
|
293
|
+
callback() # should not raise
|
|
294
|
+
|
|
295
|
+
def test_import_error_propagates_as_exception(self, tmp_path):
|
|
296
|
+
"""If PromotionEngine raises on import the task should propagate (caught by runner)."""
|
|
297
|
+
callback = make_memory_promotion_task(tmp_path)
|
|
298
|
+
with patch("skcapstone.memory_promoter.PromotionEngine", side_effect=RuntimeError("unavailable")):
|
|
299
|
+
with pytest.raises(RuntimeError, match="unavailable"):
|
|
300
|
+
callback()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class TestBackendReprobeTask:
|
|
304
|
+
def test_calls_probe_on_bridge(self):
|
|
305
|
+
mock_bridge = MagicMock()
|
|
306
|
+
mock_bridge._available = {"ollama": True, "passthrough": True}
|
|
307
|
+
|
|
308
|
+
mock_loop = MagicMock()
|
|
309
|
+
mock_loop._bridge = mock_bridge
|
|
310
|
+
|
|
311
|
+
callback = make_backend_reprobe_task(mock_loop)
|
|
312
|
+
callback()
|
|
313
|
+
mock_bridge._probe_available_backends.assert_called_once()
|
|
314
|
+
|
|
315
|
+
def test_noop_when_loop_is_none(self):
|
|
316
|
+
callback = make_backend_reprobe_task(None)
|
|
317
|
+
callback() # should not raise
|
|
318
|
+
|
|
319
|
+
def test_noop_when_bridge_missing(self):
|
|
320
|
+
mock_loop = MagicMock(spec=[]) # no _bridge attribute
|
|
321
|
+
callback = make_backend_reprobe_task(mock_loop)
|
|
322
|
+
callback() # should not raise
|
|
323
|
+
|
|
324
|
+
def test_noop_when_probe_fn_missing(self):
|
|
325
|
+
mock_bridge = MagicMock(spec=[]) # no _probe_available_backends
|
|
326
|
+
mock_loop = MagicMock()
|
|
327
|
+
mock_loop._bridge = mock_bridge
|
|
328
|
+
callback = make_backend_reprobe_task(mock_loop)
|
|
329
|
+
callback() # should not raise
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class TestHeartbeatTask:
|
|
333
|
+
def test_calls_pulse_with_active_state(self):
|
|
334
|
+
mock_beacon = MagicMock()
|
|
335
|
+
callback = make_heartbeat_task(mock_beacon, lambda: True)
|
|
336
|
+
callback()
|
|
337
|
+
mock_beacon.pulse.assert_called_once_with(consciousness_active=True)
|
|
338
|
+
|
|
339
|
+
def test_calls_pulse_with_inactive_state(self):
|
|
340
|
+
mock_beacon = MagicMock()
|
|
341
|
+
callback = make_heartbeat_task(mock_beacon, lambda: False)
|
|
342
|
+
callback()
|
|
343
|
+
mock_beacon.pulse.assert_called_once_with(consciousness_active=False)
|
|
344
|
+
|
|
345
|
+
def test_noop_when_beacon_is_none(self):
|
|
346
|
+
callback = make_heartbeat_task(None, lambda: True)
|
|
347
|
+
callback() # should not raise
|
|
348
|
+
|
|
349
|
+
def test_uses_fn_result_dynamically(self):
|
|
350
|
+
"""consciousness_active_fn is called each time, not captured at build time."""
|
|
351
|
+
mock_beacon = MagicMock()
|
|
352
|
+
state = {"active": False}
|
|
353
|
+
callback = make_heartbeat_task(mock_beacon, lambda: state["active"])
|
|
354
|
+
|
|
355
|
+
callback()
|
|
356
|
+
mock_beacon.pulse.assert_called_with(consciousness_active=False)
|
|
357
|
+
|
|
358
|
+
state["active"] = True
|
|
359
|
+
callback()
|
|
360
|
+
mock_beacon.pulse.assert_called_with(consciousness_active=True)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class TestProfileFreshnessTask:
|
|
364
|
+
def test_fresh_files_produce_no_warning(self, tmp_path, caplog):
|
|
365
|
+
import logging
|
|
366
|
+
|
|
367
|
+
identity_dir = tmp_path / "identity"
|
|
368
|
+
identity_dir.mkdir()
|
|
369
|
+
(identity_dir / "identity.json").write_text("{}")
|
|
370
|
+
|
|
371
|
+
callback = make_profile_freshness_task(tmp_path, max_age_days=7)
|
|
372
|
+
with caplog.at_level(logging.WARNING, logger="skcapstone.scheduled_tasks"):
|
|
373
|
+
callback()
|
|
374
|
+
assert not any("Profile freshness" in r.message for r in caplog.records)
|
|
375
|
+
|
|
376
|
+
def test_stale_identity_triggers_warning(self, tmp_path, caplog):
|
|
377
|
+
import logging
|
|
378
|
+
import os
|
|
379
|
+
|
|
380
|
+
identity_dir = tmp_path / "identity"
|
|
381
|
+
identity_dir.mkdir()
|
|
382
|
+
identity_file = identity_dir / "identity.json"
|
|
383
|
+
identity_file.write_text("{}")
|
|
384
|
+
|
|
385
|
+
# Set mtime to 10 days ago
|
|
386
|
+
old_mtime = time.time() - (10 * 86400)
|
|
387
|
+
os.utime(identity_file, (old_mtime, old_mtime))
|
|
388
|
+
|
|
389
|
+
callback = make_profile_freshness_task(tmp_path, max_age_days=7)
|
|
390
|
+
with caplog.at_level(logging.WARNING, logger="skcapstone.scheduled_tasks"):
|
|
391
|
+
callback()
|
|
392
|
+
|
|
393
|
+
warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING]
|
|
394
|
+
assert any("identity.json" in w for w in warnings)
|
|
395
|
+
|
|
396
|
+
def test_stale_model_profile_triggers_warning(self, tmp_path, caplog):
|
|
397
|
+
import logging
|
|
398
|
+
import os
|
|
399
|
+
|
|
400
|
+
profiles_dir = tmp_path / "data" / "model_profiles"
|
|
401
|
+
profiles_dir.mkdir(parents=True)
|
|
402
|
+
profile_file = profiles_dir / "llama3.json"
|
|
403
|
+
profile_file.write_text("{}")
|
|
404
|
+
|
|
405
|
+
old_mtime = time.time() - (15 * 86400)
|
|
406
|
+
os.utime(profile_file, (old_mtime, old_mtime))
|
|
407
|
+
|
|
408
|
+
callback = make_profile_freshness_task(tmp_path, max_age_days=7)
|
|
409
|
+
with caplog.at_level(logging.WARNING, logger="skcapstone.scheduled_tasks"):
|
|
410
|
+
callback()
|
|
411
|
+
|
|
412
|
+
warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING]
|
|
413
|
+
assert any("llama3" in w for w in warnings)
|
|
414
|
+
|
|
415
|
+
def test_missing_identity_dir_does_not_raise(self, tmp_path):
|
|
416
|
+
callback = make_profile_freshness_task(tmp_path)
|
|
417
|
+
callback() # identity dir absent — should not raise
|
|
418
|
+
|
|
419
|
+
def test_missing_profiles_dir_does_not_raise(self, tmp_path):
|
|
420
|
+
callback = make_profile_freshness_task(tmp_path)
|
|
421
|
+
callback() # data/model_profiles absent — should not raise
|
|
422
|
+
|
|
423
|
+
def test_custom_max_age_respected(self, tmp_path, caplog):
|
|
424
|
+
import logging
|
|
425
|
+
import os
|
|
426
|
+
|
|
427
|
+
identity_dir = tmp_path / "identity"
|
|
428
|
+
identity_dir.mkdir()
|
|
429
|
+
identity_file = identity_dir / "identity.json"
|
|
430
|
+
identity_file.write_text("{}")
|
|
431
|
+
|
|
432
|
+
# 3 days old
|
|
433
|
+
old_mtime = time.time() - (3 * 86400)
|
|
434
|
+
os.utime(identity_file, (old_mtime, old_mtime))
|
|
435
|
+
|
|
436
|
+
# max_age_days=2 → should warn
|
|
437
|
+
callback = make_profile_freshness_task(tmp_path, max_age_days=2)
|
|
438
|
+
with caplog.at_level(logging.WARNING, logger="skcapstone.scheduled_tasks"):
|
|
439
|
+
callback()
|
|
440
|
+
|
|
441
|
+
warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING]
|
|
442
|
+
assert any("identity.json" in w for w in warnings)
|
|
443
|
+
|
|
444
|
+
# max_age_days=5 → should NOT warn
|
|
445
|
+
caplog.clear()
|
|
446
|
+
callback2 = make_profile_freshness_task(tmp_path, max_age_days=5)
|
|
447
|
+
with caplog.at_level(logging.WARNING, logger="skcapstone.scheduled_tasks"):
|
|
448
|
+
callback2()
|
|
449
|
+
|
|
450
|
+
warnings2 = [r.message for r in caplog.records if r.levelno == logging.WARNING]
|
|
451
|
+
assert not any("identity.json" in w for w in warnings2)
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Security tests for skcapstone.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- Peer name sanitization / path traversal prevention
|
|
5
|
+
- Large message (oversized inbox file) rejection
|
|
6
|
+
- Invalid JSON in inbox files
|
|
7
|
+
|
|
8
|
+
These map to the findings from the sprint-14 security audit.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from unittest.mock import MagicMock, patch
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from skcapstone.consciousness_loop import (
|
|
22
|
+
ConsciousnessConfig,
|
|
23
|
+
ConsciousnessLoop,
|
|
24
|
+
SystemPromptBuilder,
|
|
25
|
+
_sanitize_peer_name,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# _sanitize_peer_name unit tests
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestSanitizePeerName:
|
|
35
|
+
"""Unit tests for the _sanitize_peer_name helper."""
|
|
36
|
+
|
|
37
|
+
def test_normal_name_passes_through(self):
|
|
38
|
+
assert _sanitize_peer_name("alice") == "alice"
|
|
39
|
+
|
|
40
|
+
def test_alphanumeric_with_dash(self):
|
|
41
|
+
assert _sanitize_peer_name("agent-007") == "agent-007"
|
|
42
|
+
|
|
43
|
+
def test_at_sign_allowed(self):
|
|
44
|
+
assert _sanitize_peer_name("opus@skworld.io") == "opus@skworld.io"
|
|
45
|
+
|
|
46
|
+
def test_dotted_name_allowed(self):
|
|
47
|
+
assert _sanitize_peer_name("v1.2.3") == "v1.2.3"
|
|
48
|
+
|
|
49
|
+
# --- path traversal ---
|
|
50
|
+
|
|
51
|
+
def test_slash_stripped(self):
|
|
52
|
+
result = _sanitize_peer_name("../../../etc/passwd")
|
|
53
|
+
assert "/" not in result
|
|
54
|
+
assert ".." not in result or result.count(".") <= 1
|
|
55
|
+
|
|
56
|
+
def test_backslash_stripped(self):
|
|
57
|
+
result = _sanitize_peer_name("..\\Windows\\system32")
|
|
58
|
+
assert "\\" not in result
|
|
59
|
+
|
|
60
|
+
def test_pure_dotdot_rejected(self):
|
|
61
|
+
"""'..'' alone is stripped and falls back to 'unknown'."""
|
|
62
|
+
result = _sanitize_peer_name("..")
|
|
63
|
+
# After stripping leading/trailing dots, should be empty → "unknown"
|
|
64
|
+
assert result == "unknown"
|
|
65
|
+
|
|
66
|
+
def test_dotdot_with_slash(self):
|
|
67
|
+
result = _sanitize_peer_name("../../secret")
|
|
68
|
+
assert "/" not in result
|
|
69
|
+
assert result != "../../secret"
|
|
70
|
+
|
|
71
|
+
def test_null_byte_stripped(self):
|
|
72
|
+
result = _sanitize_peer_name("alice\x00evil")
|
|
73
|
+
assert "\x00" not in result
|
|
74
|
+
|
|
75
|
+
def test_empty_string_returns_unknown(self):
|
|
76
|
+
assert _sanitize_peer_name("") == "unknown"
|
|
77
|
+
|
|
78
|
+
def test_none_returns_unknown(self):
|
|
79
|
+
assert _sanitize_peer_name(None) == "unknown" # type: ignore[arg-type]
|
|
80
|
+
|
|
81
|
+
def test_spaces_stripped(self):
|
|
82
|
+
result = _sanitize_peer_name("alice bob")
|
|
83
|
+
assert " " not in result
|
|
84
|
+
|
|
85
|
+
def test_length_capped_at_64(self):
|
|
86
|
+
long_name = "a" * 200
|
|
87
|
+
result = _sanitize_peer_name(long_name)
|
|
88
|
+
assert len(result) <= 64
|
|
89
|
+
|
|
90
|
+
def test_special_chars_stripped(self):
|
|
91
|
+
result = _sanitize_peer_name("peer<script>alert(1)</script>")
|
|
92
|
+
assert "<" not in result
|
|
93
|
+
assert ">" not in result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Path traversal via SystemPromptBuilder._persist_peer_history
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestPeerHistoryPathTraversal:
|
|
102
|
+
"""Verify that malicious peer names cannot write outside the conversations dir."""
|
|
103
|
+
|
|
104
|
+
def _make_builder(self, tmp_path: Path) -> SystemPromptBuilder:
|
|
105
|
+
"""Return a SystemPromptBuilder backed by tmp_path."""
|
|
106
|
+
return SystemPromptBuilder(home=tmp_path, max_tokens=4096)
|
|
107
|
+
|
|
108
|
+
def test_traversal_peer_stays_inside_conversations(self, tmp_path):
|
|
109
|
+
"""A sender like '../../../etc/passwd' must not escape conversations/."""
|
|
110
|
+
builder = self._make_builder(tmp_path)
|
|
111
|
+
conversations_dir = tmp_path / "conversations"
|
|
112
|
+
|
|
113
|
+
# Simulate receiving a message from a malicious peer
|
|
114
|
+
malicious_peer = "../../../etc/passwd"
|
|
115
|
+
builder.add_to_history(malicious_peer, "user", "hello")
|
|
116
|
+
|
|
117
|
+
# Only files inside conversations/ should exist
|
|
118
|
+
for written_file in conversations_dir.rglob("*"):
|
|
119
|
+
try:
|
|
120
|
+
written_file.relative_to(conversations_dir)
|
|
121
|
+
except ValueError:
|
|
122
|
+
pytest.fail(
|
|
123
|
+
f"Path traversal detected: {written_file} is outside {conversations_dir}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def test_dotdot_peer_sanitized_to_unknown(self, tmp_path):
|
|
127
|
+
builder = self._make_builder(tmp_path)
|
|
128
|
+
builder.add_to_history("..", "user", "hi")
|
|
129
|
+
conversations_dir = tmp_path / "conversations"
|
|
130
|
+
assert (conversations_dir / "unknown.json").exists()
|
|
131
|
+
|
|
132
|
+
def test_slash_in_peer_name_sanitized(self, tmp_path):
|
|
133
|
+
builder = self._make_builder(tmp_path)
|
|
134
|
+
builder.add_to_history("a/b/c", "user", "hi")
|
|
135
|
+
conversations_dir = tmp_path / "conversations"
|
|
136
|
+
# Should write abc.json (slashes stripped) or similar safe name
|
|
137
|
+
for f in conversations_dir.iterdir():
|
|
138
|
+
assert "/" not in f.name
|
|
139
|
+
|
|
140
|
+
def test_null_byte_in_peer_name_sanitized(self, tmp_path):
|
|
141
|
+
builder = self._make_builder(tmp_path)
|
|
142
|
+
builder.add_to_history("peer\x00evil", "user", "hi")
|
|
143
|
+
conversations_dir = tmp_path / "conversations"
|
|
144
|
+
for f in conversations_dir.iterdir():
|
|
145
|
+
assert "\x00" not in f.name
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Large message rejection (file-size cap in ConsciousnessLoop._on_inbox_file)
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TestLargeMessageRejected:
|
|
154
|
+
"""The inbox handler must reject files larger than 1 MB."""
|
|
155
|
+
|
|
156
|
+
def _make_loop(self, tmp_path: Path) -> ConsciousnessLoop:
|
|
157
|
+
config = ConsciousnessConfig(use_inotify=False)
|
|
158
|
+
loop = ConsciousnessLoop(
|
|
159
|
+
config=config,
|
|
160
|
+
home=tmp_path / "agent",
|
|
161
|
+
shared_root=tmp_path / "shared",
|
|
162
|
+
)
|
|
163
|
+
return loop
|
|
164
|
+
|
|
165
|
+
def test_oversized_file_is_dropped(self, tmp_path):
|
|
166
|
+
"""A 1.1 MB inbox file must not be processed."""
|
|
167
|
+
loop = self._make_loop(tmp_path)
|
|
168
|
+
|
|
169
|
+
inbox_file = tmp_path / "big.skc.json"
|
|
170
|
+
# Write 1.1 MB of data — exceeds the 1_000_000 byte cap
|
|
171
|
+
inbox_file.write_bytes(b"x" * 1_100_000)
|
|
172
|
+
|
|
173
|
+
submitted = []
|
|
174
|
+
loop._executor.submit = lambda fn, *a, **kw: submitted.append((fn, a)) # type: ignore[method-assign]
|
|
175
|
+
|
|
176
|
+
loop._on_inbox_file(inbox_file)
|
|
177
|
+
|
|
178
|
+
assert submitted == [], "Oversized file should have been dropped without submitting"
|
|
179
|
+
|
|
180
|
+
def test_1mb_minus_one_byte_is_processed(self, tmp_path):
|
|
181
|
+
"""A file just below the cap should be attempted (may fail on parse — that's fine)."""
|
|
182
|
+
loop = self._make_loop(tmp_path)
|
|
183
|
+
|
|
184
|
+
inbox_file = tmp_path / "ok.skc.json"
|
|
185
|
+
# Valid JSON just under the limit
|
|
186
|
+
payload = json.dumps({"sender": "alice", "payload": {"content": "hi"}})
|
|
187
|
+
inbox_file.write_text(payload, encoding="utf-8")
|
|
188
|
+
|
|
189
|
+
submitted = []
|
|
190
|
+
original_submit = loop._executor.submit
|
|
191
|
+
|
|
192
|
+
def capture_submit(fn, *a, **kw):
|
|
193
|
+
submitted.append((fn, a))
|
|
194
|
+
return MagicMock()
|
|
195
|
+
|
|
196
|
+
loop._executor.submit = capture_submit # type: ignore[method-assign]
|
|
197
|
+
loop._on_inbox_file(inbox_file)
|
|
198
|
+
|
|
199
|
+
# Under the cap → should be submitted (even if the envelope later fails)
|
|
200
|
+
assert len(submitted) == 1, "File under size cap should be submitted for processing"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# Invalid JSON rejection
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class TestInvalidJsonRejected:
|
|
209
|
+
"""Malformed JSON in the inbox must not crash the consciousness loop."""
|
|
210
|
+
|
|
211
|
+
def _make_loop(self, tmp_path: Path) -> ConsciousnessLoop:
|
|
212
|
+
config = ConsciousnessConfig(use_inotify=False)
|
|
213
|
+
return ConsciousnessLoop(
|
|
214
|
+
config=config,
|
|
215
|
+
home=tmp_path / "agent",
|
|
216
|
+
shared_root=tmp_path / "shared",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def test_invalid_json_does_not_raise(self, tmp_path):
|
|
220
|
+
"""Malformed JSON must be silently dropped, not crash."""
|
|
221
|
+
loop = self._make_loop(tmp_path)
|
|
222
|
+
inbox_file = tmp_path / "bad.skc.json"
|
|
223
|
+
inbox_file.write_text("{invalid json{{", encoding="utf-8")
|
|
224
|
+
|
|
225
|
+
# Must not raise
|
|
226
|
+
loop._on_inbox_file(inbox_file)
|
|
227
|
+
|
|
228
|
+
def test_non_dict_json_does_not_raise(self, tmp_path):
|
|
229
|
+
"""A valid JSON array (not a dict) must also be silently dropped."""
|
|
230
|
+
loop = self._make_loop(tmp_path)
|
|
231
|
+
inbox_file = tmp_path / "array.skc.json"
|
|
232
|
+
inbox_file.write_text("[1, 2, 3]", encoding="utf-8")
|
|
233
|
+
|
|
234
|
+
loop._on_inbox_file(inbox_file)
|
|
235
|
+
|
|
236
|
+
def test_truncated_json_does_not_raise(self, tmp_path):
|
|
237
|
+
"""Truncated / partially-written JSON must be silently dropped."""
|
|
238
|
+
loop = self._make_loop(tmp_path)
|
|
239
|
+
inbox_file = tmp_path / "truncated.skc.json"
|
|
240
|
+
inbox_file.write_text('{"sender": "alice", "payload":', encoding="utf-8")
|
|
241
|
+
|
|
242
|
+
loop._on_inbox_file(inbox_file)
|
|
243
|
+
|
|
244
|
+
def test_empty_file_does_not_raise(self, tmp_path):
|
|
245
|
+
"""An empty inbox file must not crash."""
|
|
246
|
+
loop = self._make_loop(tmp_path)
|
|
247
|
+
inbox_file = tmp_path / "empty.skc.json"
|
|
248
|
+
inbox_file.write_bytes(b"")
|
|
249
|
+
|
|
250
|
+
loop._on_inbox_file(inbox_file)
|